mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-07 08:50:25 -06:00
feat: Update Insight category, document sentiment and archive insights (#4038)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -34,7 +34,7 @@ export const Placement = ({
|
||||
}: TPlacementProps) => {
|
||||
const t = useTranslations();
|
||||
const overlayStyle =
|
||||
currentPlacement === "center" && overlay === "dark" ? "bg-gray-700/80" : "bg-slate-200";
|
||||
currentPlacement === "center" && overlay === "dark" ? "bg-slate-700/80" : "bg-slate-200";
|
||||
return (
|
||||
<>
|
||||
<div className="flex">
|
||||
|
||||
@@ -78,7 +78,7 @@ export const SavedActionsTab = ({
|
||||
|
||||
<h4 className="text-sm font-semibold text-slate-600">{action.name}</h4>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{action.description}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">{action.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -205,7 +205,7 @@ export const WhenToSendCard = ({
|
||||
|
||||
<h4 className="text-sm font-semibold text-slate-600">{trigger.actionClass.name}</h4>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{trigger.actionClass.description && (
|
||||
<span className="mr-1">{trigger.actionClass.description}</span>
|
||||
)}
|
||||
|
||||
@@ -24,17 +24,17 @@ const Loading = async () => {
|
||||
className="m-2 grid h-16 grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="ph-no-capture h-10 w-10 flex-shrink-0 animate-pulse rounded-full bg-gray-200"></div>{" "}
|
||||
<div className="ph-no-capture h-10 w-10 flex-shrink-0 animate-pulse rounded-full bg-slate-200"></div>{" "}
|
||||
<div className="ml-4">
|
||||
<div className="ph-no-capture h-4 w-28 animate-pulse rounded-full bg-gray-200 font-medium text-slate-900"></div>
|
||||
<div className="ph-no-capture h-4 w-28 animate-pulse rounded-full bg-slate-200 font-medium text-slate-900"></div>
|
||||
</div>{" "}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="ph-no-capture m-12 h-4 animate-pulse rounded-full bg-gray-200 text-slate-900"></div>
|
||||
<div className="ph-no-capture m-12 h-4 animate-pulse rounded-full bg-slate-200 text-slate-900"></div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="ph-no-capture m-12 h-4 animate-pulse rounded-full bg-gray-200 text-slate-900"></div>
|
||||
<div className="ph-no-capture m-12 h-4 animate-pulse rounded-full bg-slate-200 text-slate-900"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -10,8 +10,8 @@ const LoadingCard = () => {
|
||||
return (
|
||||
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
|
||||
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
|
||||
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-gray-100 text-lg font-medium leading-6"></h3>
|
||||
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-gray-100 text-sm text-slate-500"></p>
|
||||
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6"></h3>
|
||||
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500"></p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-4 pt-4">
|
||||
|
||||
@@ -51,7 +51,7 @@ export const EditPlacementForm = ({ product }: EditPlacementProps) => {
|
||||
const clickOutsideClose = form.watch("clickOutsideClose");
|
||||
const isSubmitting = form.formState.isSubmitting;
|
||||
|
||||
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-gray-700/80" : "bg-slate-200";
|
||||
const overlayStyle = currentPlacement === "center" && darkOverlay ? "bg-slate-700/80" : "bg-slate-200";
|
||||
|
||||
const onSubmit: SubmitHandler<EditPlacementFormValues> = async (data) => {
|
||||
try {
|
||||
|
||||
@@ -40,8 +40,8 @@ export const RankingSummary = ({
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<div className="flex w-full items-center">
|
||||
<span className="mr-2 text-gray-400">#{resultsIdx + 1}</span>
|
||||
<div className="rounded bg-gray-100 px-2 py-1">{result.value}</div>
|
||||
<span className="mr-2 text-slate-400">#{resultsIdx + 1}</span>
|
||||
<div className="rounded bg-slate-100 px-2 py-1">{result.value}</div>
|
||||
<span className="ml-auto flex items-center space-x-1">
|
||||
<span className="font-bold text-slate-600">
|
||||
#{convertFloatToNDecimal(result.avgRanking, 2)}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const ShareEmbedSurvey = ({
|
||||
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">
|
||||
<DialogTitle>
|
||||
<p className="pt-2 text-xl font-semibold text-slate-800">
|
||||
{t("environments.surveys.summary.results_are_public")} 🎉
|
||||
{t("environments.surveys.summary.your_survey_is_public")} 🎉
|
||||
</p>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="hidden" />
|
||||
|
||||
@@ -105,7 +105,7 @@ export const CopySurveyForm = ({
|
||||
field.onChange([...field.value, environment.id]);
|
||||
}
|
||||
}}
|
||||
className="mr-2 h-4 w-4 appearance-none border-gray-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
className="mr-2 h-4 w-4 appearance-none border-slate-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
|
||||
id={environment.id}
|
||||
/>
|
||||
<Label htmlFor={environment.id}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from "@formbricks/ui/components/Button";
|
||||
export const BackToLoginButton = async () => {
|
||||
const t = await getTranslations();
|
||||
return (
|
||||
<Button variant="secondary" href="/auth/login" className="w-full justify-center">
|
||||
<Button size="base" variant="secondary" href="/auth/login" className="w-full justify-center">
|
||||
{t("auth.signup.log_in")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -26,10 +26,10 @@ export const GET = async (req: NextRequest) => {
|
||||
<div tw="flex flex-col w-full">
|
||||
<div tw="flex flex-col md:flex-row w-full md:items-center justify-between ">
|
||||
<div tw="flex flex-col px-8">
|
||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-gray-900 text-left mt-15">
|
||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||
{name}
|
||||
</h2>
|
||||
<span tw="text-gray-600 text-xl">Complete in ~ 4 minutes</span>
|
||||
<span tw="text-slate-600 text-xl">Complete in ~ 4 minutes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex justify-end mr-10 ">
|
||||
|
||||
@@ -29,7 +29,7 @@ const Page = async () => {
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button href="/setup/signup" className="mt-6">
|
||||
<Button size="base" href="/setup/signup" className="mt-6">
|
||||
{t("setup.intro.get_started")}
|
||||
</Button>
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { ZSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getDocument, updateDocument } from "./lib/documents";
|
||||
|
||||
const ZGetDocumentsByInsightIdSurveyIdQuestionIdAction = z.object({
|
||||
insightId: ZId,
|
||||
@@ -94,3 +95,30 @@ export const getDocumentsByInsightIdAction = authenticatedActionClient
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
});
|
||||
|
||||
const ZUpdateDocumentAction = z.object({
|
||||
documentId: ZId,
|
||||
data: z
|
||||
.object({
|
||||
sentiment: z.enum(["positive", "negative", "neutral"]).optional(),
|
||||
})
|
||||
.strict(),
|
||||
});
|
||||
|
||||
export const updateDocumentAction = authenticatedActionClient
|
||||
.schema(ZUpdateDocumentAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const document = await getDocument(parsedInput.documentId);
|
||||
|
||||
if (!document) {
|
||||
throw new Error("Document not found");
|
||||
}
|
||||
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(document.environmentId),
|
||||
rules: ["response", "update"],
|
||||
});
|
||||
|
||||
return await updateDocument(parsedInput.documentId, parsedInput.data);
|
||||
});
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
|
||||
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useDeferredValue, useEffect, useState } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TDocument, TDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { TInsight } from "@formbricks/types/insights";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Badge } from "@formbricks/ui/components/Badge";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import { Card, CardContent, CardFooter } from "@formbricks/ui/components/Card";
|
||||
import {
|
||||
@@ -19,6 +18,8 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@formbricks/ui/components/Sheet";
|
||||
import CategoryBadge from "../../experience/components/category-select";
|
||||
import SentimentSelect from "../sentiment-select";
|
||||
import { getDocumentsByInsightIdAction, getDocumentsByInsightIdSurveyIdQuestionIdAction } from "./actions";
|
||||
|
||||
interface InsightSheetProps {
|
||||
@@ -47,54 +48,68 @@ export const InsightSheet = ({
|
||||
const t = useTranslations();
|
||||
const [documents, setDocuments] = useState<TDocument[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
if (!insight) return;
|
||||
|
||||
let documentsResponse;
|
||||
if (questionId && surveyId) {
|
||||
documentsResponse = await getDocumentsByInsightIdSurveyIdQuestionIdAction({
|
||||
insightId: insight.id,
|
||||
surveyId,
|
||||
questionId,
|
||||
limit: documentsPerPage,
|
||||
offset: (page - 1) * documentsPerPage,
|
||||
});
|
||||
} else {
|
||||
documentsResponse = await getDocumentsByInsightIdAction({
|
||||
insightId: insight.id,
|
||||
filterCriteria: documentsFilter,
|
||||
limit: documentsPerPage,
|
||||
offset: (page - 1) * documentsPerPage,
|
||||
});
|
||||
}
|
||||
|
||||
if (!documentsResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(documentsResponse);
|
||||
console.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchedDocuments = documentsResponse.data;
|
||||
|
||||
if (fetchedDocuments.length < documentsPerPage) {
|
||||
setHasMore(false); // No more documents to fetch
|
||||
}
|
||||
|
||||
setDocuments((prevDocuments) => [...prevDocuments, ...fetchedDocuments]);
|
||||
}, [insight, page, surveyId, questionId, documentsFilter]);
|
||||
const [isLoading, setIsLoading] = useState(false); // New state for loading
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDocuments([]);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
setHasMore(false); // Reset hasMore when the sheet is opened
|
||||
}
|
||||
if (insight) {
|
||||
if (isOpen && insight) {
|
||||
fetchDocuments();
|
||||
}
|
||||
}, [fetchDocuments, isOpen]);
|
||||
|
||||
async function fetchDocuments() {
|
||||
if (!insight) return;
|
||||
if (isLoading) return; // Prevent fetching if already loading
|
||||
setIsLoading(true); // Set loading state to true
|
||||
|
||||
try {
|
||||
let documentsResponse;
|
||||
if (questionId && surveyId) {
|
||||
documentsResponse = await getDocumentsByInsightIdSurveyIdQuestionIdAction({
|
||||
insightId: insight.id,
|
||||
surveyId,
|
||||
questionId,
|
||||
limit: documentsPerPage,
|
||||
offset: (page - 1) * documentsPerPage,
|
||||
});
|
||||
} else {
|
||||
documentsResponse = await getDocumentsByInsightIdAction({
|
||||
insightId: insight.id,
|
||||
filterCriteria: documentsFilter,
|
||||
limit: documentsPerPage,
|
||||
offset: (page - 1) * documentsPerPage,
|
||||
});
|
||||
}
|
||||
|
||||
if (!documentsResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(documentsResponse);
|
||||
console.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchedDocuments = documentsResponse.data;
|
||||
|
||||
setDocuments((prevDocuments) => {
|
||||
// Remove duplicates based on document ID
|
||||
const uniqueDocuments = new Map<string, TDocument>([
|
||||
...prevDocuments.map((doc) => [doc.id, doc]),
|
||||
...fetchedDocuments.map((doc) => [doc.id, doc]),
|
||||
]);
|
||||
return Array.from(uniqueDocuments.values()) as TDocument[];
|
||||
});
|
||||
|
||||
setHasMore(fetchedDocuments.length === documentsPerPage);
|
||||
} finally {
|
||||
setIsLoading(false); // Reset loading state
|
||||
}
|
||||
}
|
||||
}, [isOpen, insight]);
|
||||
|
||||
const deferredDocuments = useDeferredValue(documents);
|
||||
|
||||
const handleFeedbackClick = (feedback: "positive" | "negative") => {
|
||||
setIsOpen(false);
|
||||
@@ -113,48 +128,35 @@ export const InsightSheet = ({
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={(v) => setIsOpen(v)}>
|
||||
<SheetContent className="flex h-full w-[400rem] flex-col bg-white lg:max-w-lg xl:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<span className="mr-3">{insight.title}</span>
|
||||
{insight.category === "complaint" ? (
|
||||
<Badge text="Complaint" type="error" size="tiny" />
|
||||
) : insight.category === "featureRequest" ? (
|
||||
<Badge text="Feature Request" type="warning" size="tiny" />
|
||||
) : insight.category === "praise" ? (
|
||||
<Badge text="Praise" type="success" size="tiny" />
|
||||
) : null}
|
||||
<SheetContent className="flex h-full flex-col bg-white">
|
||||
<SheetHeader className="flex flex-col gap-1.5">
|
||||
<SheetTitle className="flex items-center gap-x-2">
|
||||
<span>{insight.title}</span>
|
||||
<CategoryBadge category={insight.category} insightId={insight.id} />
|
||||
</SheetTitle>
|
||||
<SheetDescription>{insight.description}</SheetDescription>
|
||||
<div className="flex w-fit items-center gap-2 rounded-lg border border-slate-300 px-2 py-1 text-sm text-slate-600">
|
||||
<p>{t("environments.experience.did_you_find_this_insight_helpful")}</p>
|
||||
<ThumbsUpIcon
|
||||
className="upvote h-4 w-4 cursor-pointer hover:text-black"
|
||||
className="upvote h-4 w-4 cursor-pointer text-slate-700 hover:text-emerald-500"
|
||||
onClick={() => handleFeedbackClick("positive")}
|
||||
/>
|
||||
<ThumbsDownIcon
|
||||
className="downvote h-4 w-4 cursor-pointer hover:text-black"
|
||||
className="downvote h-4 w-4 cursor-pointer text-slate-700 hover:text-amber-600"
|
||||
onClick={() => handleFeedbackClick("negative")}
|
||||
/>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col space-y-2 overflow-auto pt-4">
|
||||
{documents.map((document) => (
|
||||
<Card key={document.id}>
|
||||
<hr className="my-2" />
|
||||
<div className="flex flex-1 flex-col gap-y-2 overflow-auto">
|
||||
{deferredDocuments.map((document, index) => (
|
||||
<Card key={`${document.id}-${index}`} className="transition-opacity duration-200">
|
||||
<CardContent className="p-4 text-sm">
|
||||
<Markdown className="whitespace-pre-wrap">{document.text}</Markdown>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
<CardFooter className="flex justify-between rounded-bl-xl rounded-br-xl border-t border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-600">
|
||||
<p>
|
||||
{t("environments.experience.sentiment")}:{" "}
|
||||
{document.sentiment === "positive" ? (
|
||||
<Badge text={t("environments.experience.positive")} size="tiny" type="success" />
|
||||
) : document.sentiment === "neutral" ? (
|
||||
<Badge text="Neutral" size="tiny" type="gray" />
|
||||
) : document.sentiment === "negative" ? (
|
||||
<Badge text="Negative" size="tiny" type="error" />
|
||||
) : null}
|
||||
Sentiment: <SentimentSelect documentId={document.id} sentiment={document.sentiment} />
|
||||
</p>
|
||||
<p>{timeSince(new Date(document.createdAt).toISOString(), locale)}</p>
|
||||
</CardFooter>
|
||||
|
||||
@@ -8,7 +8,12 @@ import { cache } from "@formbricks/lib/cache";
|
||||
import { DOCUMENTS_PER_PAGE } from "@formbricks/lib/constants";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDocument, TDocumentFilterCriteria, ZDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import {
|
||||
TDocument,
|
||||
TDocumentFilterCriteria,
|
||||
ZDocument,
|
||||
ZDocumentFilterCriteria,
|
||||
} from "@formbricks/types/documents";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurveyQuestionId, ZSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -124,3 +129,52 @@ export const getDocumentsByInsightIdSurveyIdQuestionId = reactCache(
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getDocument = reactCache(
|
||||
(documentId: string): Promise<TDocument | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([documentId, ZId]);
|
||||
|
||||
try {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
});
|
||||
|
||||
return document;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getDocumentById-${documentId}`],
|
||||
{
|
||||
tags: [documentCache.tag.byId(documentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const updateDocument = async (documentId: string, data: Partial<TDocument>): Promise<TDocument> => {
|
||||
validateInputs([documentId, ZId], [data, ZDocument.partial()]);
|
||||
try {
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: { id: documentId },
|
||||
data,
|
||||
});
|
||||
|
||||
documentCache.revalidate({ environmentId: updatedDocument.environmentId });
|
||||
|
||||
return updatedDocument;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { cn } from "@formbricks/lib/cn";
|
||||
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { TInsight, TInsightCategory } from "@formbricks/types/insights";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Badge } from "@formbricks/ui/components/Badge";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import {
|
||||
Table,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
TableRow,
|
||||
} from "@formbricks/ui/components/Table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@formbricks/ui/components/Tabs";
|
||||
import CategoryBadge from "../experience/components/category-select";
|
||||
|
||||
interface InsightViewProps {
|
||||
insights: TInsight[];
|
||||
@@ -78,7 +78,18 @@ export const InsightView = ({
|
||||
|
||||
useEffect(() => {
|
||||
handleFilterSelect(activeTab);
|
||||
}, [insights]);
|
||||
|
||||
// Update currentInsight if it exists in the new insights array
|
||||
if (currentInsight) {
|
||||
const updatedInsight = insights.find((insight) => insight.id === currentInsight.id);
|
||||
if (updatedInsight) {
|
||||
setCurrentInsight(updatedInsight);
|
||||
} else {
|
||||
setCurrentInsight(null);
|
||||
setIsInsightSheetOpen(false);
|
||||
}
|
||||
}
|
||||
}, [insights, activeTab, handleFilterSelect]);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
setVisibleInsights((prevVisibleInsights) => Math.min(prevVisibleInsights + 10, insights.length));
|
||||
@@ -136,19 +147,7 @@ export const InsightView = ({
|
||||
{insight.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{insight.category === "complaint" ? (
|
||||
<Badge text={t("environments.experience.complaint")} type="error" size="tiny" />
|
||||
) : insight.category === "featureRequest" ? (
|
||||
<Badge
|
||||
text={t("environments.experience.feature_request")}
|
||||
type="warning"
|
||||
size="tiny"
|
||||
/>
|
||||
) : insight.category === "praise" ? (
|
||||
<Badge text={t("environments.experience.praise")} type="success" size="tiny" />
|
||||
) : insight.category === "other" ? (
|
||||
<Badge text={t("common.other")} type="gray" size="tiny" />
|
||||
) : null}
|
||||
<CategoryBadge category={insight.category} insightId={insight.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
63
apps/web/modules/ee/insights/components/sentiment-select.tsx
Normal file
63
apps/web/modules/ee/insights/components/sentiment-select.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import { TDocument, TDocumentSentiment } from "@formbricks/types/documents";
|
||||
import { BadgeSelect, TBadgeSelectOption } from "@formbricks/ui/components/BadgeSelect";
|
||||
import { updateDocumentAction } from "./insight-sheet/actions";
|
||||
|
||||
interface SentimentSelectProps {
|
||||
sentiment: TDocument["sentiment"];
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
const sentimentOptions: TBadgeSelectOption[] = [
|
||||
{ text: "Positive", type: "success" },
|
||||
{ text: "Neutral", type: "gray" },
|
||||
{ text: "Negative", type: "error" },
|
||||
];
|
||||
|
||||
const getSentimentIndex = (sentiment: TDocumentSentiment) => {
|
||||
switch (sentiment) {
|
||||
case "positive":
|
||||
return 0;
|
||||
case "neutral":
|
||||
return 1;
|
||||
case "negative":
|
||||
return 2;
|
||||
default:
|
||||
return 1; // Default to neutral
|
||||
}
|
||||
};
|
||||
|
||||
const SentimentSelect = ({ sentiment, documentId }: SentimentSelectProps) => {
|
||||
const [currentSentiment, setCurrentSentiment] = useState(sentiment);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleUpdateSentiment = async (newSentiment: TDocumentSentiment) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateDocumentAction({
|
||||
documentId,
|
||||
data: { sentiment: newSentiment },
|
||||
});
|
||||
setCurrentSentiment(newSentiment); // Update the state with the new sentiment
|
||||
} catch (error) {
|
||||
console.error("Failed to update document sentiment:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BadgeSelect
|
||||
options={sentimentOptions}
|
||||
selectedIndex={getSentimentIndex(currentSentiment)}
|
||||
onChange={(newIndex) => {
|
||||
const newSentiment = sentimentOptions[newIndex].text.toLowerCase() as TDocumentSentiment;
|
||||
handleUpdateSentiment(newSentiment);
|
||||
}}
|
||||
size="tiny"
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SentimentSelect;
|
||||
@@ -1,12 +1,15 @@
|
||||
"use server";
|
||||
|
||||
import { insightCache } from "@/lib/cache/insight";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZInsightFilterCriteria } from "@formbricks/types/insights";
|
||||
import { getInsights } from "./lib/insights";
|
||||
import { ZInsight, ZInsightFilterCriteria } from "@formbricks/types/insights";
|
||||
import { getInsights, updateInsight } from "./lib/insights";
|
||||
import { getStats } from "./lib/stats";
|
||||
|
||||
const ZGetEnvironmentInsightsAction = z.object({
|
||||
@@ -49,3 +52,51 @@ export const getStatsAction = authenticatedActionClient
|
||||
|
||||
return await getStats(parsedInput.environmentId, parsedInput.statsFrom);
|
||||
});
|
||||
|
||||
const ZUpdateInsightAction = z.object({
|
||||
insightId: ZId,
|
||||
data: ZInsight.partial(),
|
||||
});
|
||||
|
||||
export const updateInsightAction = authenticatedActionClient
|
||||
.schema(ZUpdateInsightAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
try {
|
||||
const insight = await cache(
|
||||
() =>
|
||||
prisma.insight.findUnique({
|
||||
where: {
|
||||
id: parsedInput.insightId,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
}),
|
||||
[`getInsight-${parsedInput.insightId}`],
|
||||
{
|
||||
tags: [insightCache.tag.byId(parsedInput.insightId)],
|
||||
}
|
||||
)();
|
||||
|
||||
if (!insight) {
|
||||
throw new Error("Insight not found");
|
||||
}
|
||||
|
||||
await checkAuthorization({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(insight.environmentId),
|
||||
rules: ["response", "update"],
|
||||
});
|
||||
|
||||
return await updateInsight(parsedInput.insightId, parsedInput.data);
|
||||
} catch (error) {
|
||||
console.error("Error updating insight:", {
|
||||
insightId: parsedInput.insightId,
|
||||
error,
|
||||
});
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to update insight: ${error.message}`);
|
||||
}
|
||||
throw new Error("An unexpected error occurred while updating the insight");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TInsight } from "@formbricks/types/insights";
|
||||
import { BadgeSelect, TBadgeSelectOption } from "@formbricks/ui/components/BadgeSelect";
|
||||
import { updateInsightAction } from "../actions";
|
||||
|
||||
interface CategoryBadgeProps {
|
||||
category: TInsight["category"];
|
||||
insightId: string;
|
||||
}
|
||||
|
||||
const categoryOptions: TBadgeSelectOption[] = [
|
||||
{ text: "Complaint", type: "error" },
|
||||
{ text: "Request", type: "warning" },
|
||||
{ text: "Praise", type: "success" },
|
||||
{ text: "Other", type: "gray" },
|
||||
];
|
||||
|
||||
const categoryMapping: Record<string, TInsight["category"]> = {
|
||||
Complaint: "complaint",
|
||||
Request: "featureRequest",
|
||||
Praise: "praise",
|
||||
Other: "other",
|
||||
};
|
||||
|
||||
const getCategoryIndex = (category: TInsight["category"]) => {
|
||||
switch (category) {
|
||||
case "complaint":
|
||||
return 0;
|
||||
case "featureRequest":
|
||||
return 1;
|
||||
case "praise":
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
};
|
||||
|
||||
const CategoryBadge = ({ category, insightId }: CategoryBadgeProps) => {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleUpdateCategory = async (newCategory: TInsight["category"]) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateInsightAction({ insightId, data: { category: newCategory } });
|
||||
toast.success("Category updated successfully!");
|
||||
} catch (error) {
|
||||
console.error("Failed to update insight:", error);
|
||||
toast.error("Failed to update category.");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BadgeSelect
|
||||
options={categoryOptions}
|
||||
selectedIndex={getCategoryIndex(category)}
|
||||
onChange={(newIndex) => {
|
||||
const newCategoryText = categoryOptions[newIndex].text;
|
||||
const newCategory = categoryMapping[newCategoryText];
|
||||
handleUpdateCategory(newCategory);
|
||||
}}
|
||||
size="tiny"
|
||||
isLoading={isUpdating}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryBadge;
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Greeting } from "@/modules/ee/insights/experience/components/greeting";
|
||||
import { InsightsCard } from "@/modules/ee/insights/experience/components/insights-card";
|
||||
import { ExperiencePageStats } from "@/modules/ee/insights/experience/components/stats";
|
||||
import { TemplatesCard } from "@/modules/ee/insights/experience/components/templates-card";
|
||||
import { getDateFromTimeRange } from "@/modules/ee/insights/experience/lib/utils";
|
||||
import { TStatsPeriod } from "@/modules/ee/insights/experience/types/stats";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -39,7 +38,12 @@ export const Dashboard = ({
|
||||
<hr className="border-slate-200" />
|
||||
<Tabs
|
||||
value={statsPeriod}
|
||||
onValueChange={(value) => value && setStatsPeriod(value as TStatsPeriod)}
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
console.log("Stats period changed to:", value);
|
||||
setStatsPeriod(value as TStatsPeriod);
|
||||
}
|
||||
}}
|
||||
className="flex justify-center">
|
||||
<TabsList>
|
||||
<TabsTrigger value="day" aria-label="Toggle day">
|
||||
@@ -68,12 +72,6 @@ export const Dashboard = ({
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
<TemplatesCard
|
||||
environment={environment}
|
||||
product={product}
|
||||
user={user}
|
||||
prefilledFilters={["link", null, "customerSuccess"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
const LoadingRow = () => (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="ph-no-capture h-6 w-10 animate-pulse rounded-full bg-slate-200"></div>
|
||||
<div className="ph-no-capture h-6 w-40 animate-pulse rounded-full bg-slate-200"></div>
|
||||
<div className="ph-no-capture h-6 w-48 animate-pulse rounded-full bg-slate-200"></div>
|
||||
<div className="ph-no-capture h-6 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const InsightLoading = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="ph-no-capture animate-pulse rounded-lg bg-white shadow-md">
|
||||
<div className="ph-no-capture animate-pulse rounded-lg bg-white">
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="ph-no-capture h-8 w-10 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-40 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-48 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-16 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="ph-no-capture h-8 w-10 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-40 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-48 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-16 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="ph-no-capture h-8 w-10 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-40 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-48 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
<div className="ph-no-capture h-8 w-16 animate-pulse rounded-lg bg-gray-200"></div>
|
||||
</div>
|
||||
<LoadingRow />
|
||||
<LoadingRow />
|
||||
<LoadingRow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,6 @@ import formbricks from "@formbricks/js";
|
||||
import { TDocumentFilterCriteria } from "@formbricks/types/documents";
|
||||
import { TInsight, TInsightFilterCriteria } from "@formbricks/types/insights";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { Badge } from "@formbricks/ui/components/Badge";
|
||||
import { Button } from "@formbricks/ui/components/Button";
|
||||
import {
|
||||
Table,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
} from "@formbricks/ui/components/Table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@formbricks/ui/components/Tabs";
|
||||
import { getEnvironmentInsightsAction } from "../actions";
|
||||
import CategoryBadge from "./category-select";
|
||||
import { InsightLoading } from "./insight-loading";
|
||||
|
||||
interface InsightViewProps {
|
||||
@@ -40,10 +40,10 @@ export const InsightView = ({
|
||||
const t = useTranslations();
|
||||
const [insights, setInsights] = useState<TInsight[]>([]);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [isFetching, setIsFetching] = useState(true);
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(false);
|
||||
const [currentInsight, setCurrentInsight] = useState<TInsight | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>("all");
|
||||
const [activeTab, setActiveTab] = useState<string>("featureRequest");
|
||||
|
||||
const handleFeedback = (feedback: "positive" | "negative") => {
|
||||
formbricks.track("AI Insight Feedback", {
|
||||
@@ -81,16 +81,27 @@ export const InsightView = ({
|
||||
const fetchInitialInsights = async () => {
|
||||
setIsFetching(true);
|
||||
setInsights([]);
|
||||
const res = await getEnvironmentInsightsAction({
|
||||
environmentId,
|
||||
limit: insightsPerPage,
|
||||
offset: 0,
|
||||
insightsFilter,
|
||||
});
|
||||
if (res?.data) {
|
||||
setInsights(res.data);
|
||||
setHasMore(res.data.length >= insightsPerPage);
|
||||
setIsFetching(false);
|
||||
try {
|
||||
const res = await getEnvironmentInsightsAction({
|
||||
environmentId,
|
||||
limit: insightsPerPage,
|
||||
offset: 0,
|
||||
insightsFilter,
|
||||
});
|
||||
if (res?.data) {
|
||||
setInsights(res.data);
|
||||
setHasMore(res.data.length >= insightsPerPage);
|
||||
|
||||
// Find the updated currentInsight based on its id
|
||||
const updatedCurrentInsight = res.data.find((insight) => insight.id === currentInsight?.id);
|
||||
|
||||
// Update currentInsight with the matched insight or default to the first one
|
||||
setCurrentInsight(updatedCurrentInsight || (res.data.length > 0 ? res.data[0] : null));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch insights:", error);
|
||||
} finally {
|
||||
setIsFetching(false); // Ensure isFetching is set to false in all cases
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,14 +130,16 @@ export const InsightView = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs defaultValue="all" onValueChange={handleFilterSelect}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">{t("environments.experience.all")}</TabsTrigger>
|
||||
<TabsTrigger value="complaint">{t("environments.experience.complaint")}</TabsTrigger>
|
||||
<TabsTrigger value="featureRequest">{t("environments.experience.feature_request")}</TabsTrigger>
|
||||
<TabsTrigger value="praise">{t("environments.experience.praise")}</TabsTrigger>
|
||||
<TabsTrigger value="other">{t("common.other")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<Tabs defaultValue="featureRequest" onValueChange={handleFilterSelect}>
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">{t("environments.experience.all")}</TabsTrigger>
|
||||
<TabsTrigger value="complaint">{t("environments.experience.complaint")}</TabsTrigger>
|
||||
<TabsTrigger value="featureRequest">{t("environments.experience.feature_request")}</TabsTrigger>
|
||||
<TabsTrigger value="praise">{t("environments.experience.praise")}</TabsTrigger>
|
||||
<TabsTrigger value="other">{t("common.other")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value={activeTab}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -145,34 +158,28 @@ export const InsightView = ({
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
insights.map((insight) => (
|
||||
<TableRow
|
||||
key={insight.id}
|
||||
className="group cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
setCurrentInsight(insight);
|
||||
setIsInsightSheetOpen(true);
|
||||
}}>
|
||||
<TableCell className="flex font-medium">
|
||||
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{insight.title}</TableCell>
|
||||
<TableCell className="underline-offset-2 group-hover:underline">
|
||||
{insight.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{insight.category === "complaint" ? (
|
||||
<Badge text="Complaint" type="error" size="tiny" />
|
||||
) : insight.category === "featureRequest" ? (
|
||||
<Badge text="Feature Request" type="warning" size="tiny" />
|
||||
) : insight.category === "praise" ? (
|
||||
<Badge text="Praise" type="success" size="tiny" />
|
||||
) : (
|
||||
<Badge text="Other" type="gray" size="tiny" />
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
insights
|
||||
.sort((a, b) => b._count.documentInsights - a._count.documentInsights)
|
||||
.map((insight) => (
|
||||
<TableRow
|
||||
key={insight.id}
|
||||
className="group cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => {
|
||||
setCurrentInsight(insight);
|
||||
setIsInsightSheetOpen(true);
|
||||
}}>
|
||||
<TableCell className="flex font-medium">
|
||||
{insight._count.documentInsights} <UserIcon className="ml-2 h-4 w-4" />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{insight.title}</TableCell>
|
||||
<TableCell className="underline-offset-2 group-hover:underline">
|
||||
{insight.description}
|
||||
</TableCell>
|
||||
<TableCell className="flex items-center justify-between gap-2">
|
||||
<CategoryBadge category={insight.category} insightId={insight.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -9,6 +9,7 @@ import toast from "react-hot-toast";
|
||||
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
|
||||
import { Badge } from "@formbricks/ui/components/Badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@formbricks/ui/components/Card";
|
||||
import { TooltipRenderer } from "@formbricks/ui/components/Tooltip";
|
||||
import { cn } from "@formbricks/ui/lib/utils";
|
||||
|
||||
interface ExperiencePageStatsProps {
|
||||
@@ -84,20 +85,23 @@ export const ExperiencePageStats = ({ statsFrom, environmentId }: ExperiencePage
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="text-2xl font-bold capitalize">
|
||||
{isLoading ? (
|
||||
<div className={cn("h-8 animate-pulse rounded bg-gray-200", stat.width)}></div>
|
||||
<div className={cn("h-4 animate-pulse rounded-full bg-slate-200", stat.width)}></div>
|
||||
) : stat.key === "sentimentScore" ? (
|
||||
<div className="flex items-center font-medium text-slate-700">
|
||||
<TooltipRenderer tooltipContent={`${stat.value} positive`}>
|
||||
{stats.overallSentiment === "positive" ? (
|
||||
<Badge text="Positive" type="success" size="large" />
|
||||
) : stats.overallSentiment === "negative" ? (
|
||||
<Badge text="Negative" type="error" size="large" />
|
||||
) : (
|
||||
<Badge text="Neutral" type="gray" size="large" />
|
||||
)}
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
) : (
|
||||
(stat.value ?? "-")
|
||||
)}
|
||||
</div>
|
||||
{stat.key === "sentimentScore" && stats.overallSentiment && (
|
||||
<div>
|
||||
{stats.overallSentiment === "positive" ? (
|
||||
<Badge text={t("environments.experience.positive")} type="success" size="tiny" />
|
||||
) : (
|
||||
<Badge text={t("environments.experience.negative")} type="error" size="tiny" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -82,3 +82,22 @@ export const getInsights = reactCache(
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const updateInsight = async (insightId: string, updates: Partial<TInsight>): Promise<void> => {
|
||||
try {
|
||||
await prisma.insight.update({
|
||||
where: { id: insightId },
|
||||
data: updates,
|
||||
});
|
||||
|
||||
// Invalidate the cache for the updated insight
|
||||
insightCache.revalidate({ id: insightId });
|
||||
} catch (error) {
|
||||
console.error("Error in updateInsight:", error);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -75,7 +75,8 @@ export const getStats = reactCache(
|
||||
if (sentimentCounts.positive || sentimentCounts.negative) {
|
||||
sentimentScore = sentimentCounts.positive / (sentimentCounts.positive + sentimentCounts.negative);
|
||||
|
||||
overallSentiment = sentimentScore > 0.5 ? "positive" : "negative";
|
||||
overallSentiment =
|
||||
sentimentScore > 0.5 ? "positive" : sentimentScore < 0.5 ? "negative" : "neutral";
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
|
||||
export const ZStats = z.object({
|
||||
sentimentScore: z.number().optional(),
|
||||
overallSentiment: z.enum(["positive", "negative"]).optional(),
|
||||
overallSentiment: z.enum(["positive", "negative", "neutral"]).optional(),
|
||||
activeSurveys: z.number(),
|
||||
newResponses: z.number(),
|
||||
analysedFeedbacks: z.number(),
|
||||
|
||||
@@ -78,7 +78,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
className="block cursor-pointer rounded-md px-4 py-2 text-gray-700 hover:bg-gray-100 active:bg-blue-100"
|
||||
className="block cursor-pointer rounded-md px-4 py-2 text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleOptionSelect(item);
|
||||
|
||||
@@ -115,7 +115,7 @@ export function PreviewEmailTemplate({
|
||||
<Container className="mx-0 mt-4 w-full items-center justify-center">
|
||||
<Section
|
||||
className={cn("w-full overflow-hidden", {
|
||||
"border border-solid border-gray-200": firstQuestion.scale === "number",
|
||||
"border border-solid border-slate-200": firstQuestion.scale === "number",
|
||||
})}>
|
||||
<Column className="mb-4 flex w-full justify-between gap-0">
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
|
||||
@@ -24,7 +24,7 @@ export const renderEmailResponseValue = (
|
||||
{Array.isArray(response) &&
|
||||
response.map((responseItem) => (
|
||||
<Link
|
||||
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-gray-200 p-2 text-black shadow-sm"
|
||||
className="mt-2 flex flex-col items-center justify-center rounded-lg bg-slate-200 p-2 text-black shadow-sm"
|
||||
href={responseItem}
|
||||
key={responseItem}>
|
||||
<FileIcon />
|
||||
@@ -57,8 +57,8 @@ export const renderEmailResponseValue = (
|
||||
(item, index) =>
|
||||
item && (
|
||||
<Row key={item} className="mb-1 flex items-center">
|
||||
<Column className="w-6 text-gray-400">#{index + 1}</Column>
|
||||
<Column className="rounded bg-gray-100 px-2 py-1">{item}</Column>
|
||||
<Column className="w-6 text-slate-400">#{index + 1}</Column>
|
||||
<Column className="rounded bg-slate-100 px-2 py-1">{item}</Column>
|
||||
</Row>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ export const LiveSurveyNotification = ({
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
className={`ml-2 inline ${isInProgress ? "bg-green-400 text-gray-100" : "bg-gray-300 text-blue-800"} rounded-full px-2 py-1 text-sm`}>
|
||||
className={`ml-2 inline ${isInProgress ? "bg-green-400 text-slate-100" : "bg-slate-300 text-blue-800"} rounded-full px-2 py-1 text-sm`}>
|
||||
{displayStatus}
|
||||
</Text>
|
||||
{noResponseLastWeek ? (
|
||||
|
||||
@@ -557,7 +557,7 @@
|
||||
"category": "Kategorie",
|
||||
"complaint": "Beschwerde",
|
||||
"did_you_find_this_insight_helpful": "War diese Erkenntnis hilfreich?",
|
||||
"feature_request": "Feature-Anfrage",
|
||||
"feature_request": "Anfrage",
|
||||
"good_afternoon": "🌤️ Guten Nachmittag",
|
||||
"good_evening": "🌙 Guten Abend",
|
||||
"good_morning": "☀️ Guten Morgen",
|
||||
|
||||
@@ -553,18 +553,18 @@
|
||||
"experience": {
|
||||
"all": "All",
|
||||
"all_time": "All time",
|
||||
"analysed_feedbacks": "Analysed Feedbacks",
|
||||
"analysed_feedbacks": "Analysed Free Text Answers",
|
||||
"category": "Category",
|
||||
"complaint": "Complaint",
|
||||
"did_you_find_this_insight_helpful": "Did you find this insight helpful?",
|
||||
"feature_request": "Feature Request",
|
||||
"feature_request": "Request",
|
||||
"good_afternoon": "🌤️ Good afternoon",
|
||||
"good_evening": "🌙 Good evening",
|
||||
"good_morning": "☀️ Good morning",
|
||||
"insights_description": "All insights generated from responses across all your surveys",
|
||||
"insights_for_product": "Insights for {productName}",
|
||||
"negative": "Negative",
|
||||
"new_responses": "New Responses",
|
||||
"new_responses": "Responses",
|
||||
"no_insights_for_this_filter": "No insights for this filter",
|
||||
"no_insights_found": "No insights found. Collect more survey responses or enable insights for your existing surveys to get started.",
|
||||
"positive": "Positive",
|
||||
@@ -1775,7 +1775,7 @@
|
||||
"setup": {
|
||||
"intro": {
|
||||
"get_started": "Get started",
|
||||
"made_with_love_in_kiel": "Made with 🤍 in Kiel, Germany",
|
||||
"made_with_love_in_kiel": "Made with 🤍 in Germany",
|
||||
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
|
||||
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
|
||||
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>. Always",
|
||||
|
||||
@@ -1775,7 +1775,7 @@
|
||||
"setup": {
|
||||
"intro": {
|
||||
"get_started": "Começar",
|
||||
"made_with_love_in_kiel": "Feito com 🤍 em Kiel, Alemanha",
|
||||
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
|
||||
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no mundo.",
|
||||
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
|
||||
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
|
||||
|
||||
@@ -64,7 +64,7 @@ export const QuestionMedia = ({ imgUrl, videoUrl, altText = "Image" }: QuestionM
|
||||
href={!!imgUrl ? imgUrl : parseVideoUrl(videoUrl ?? "")}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-rounded-md fb-bg-gray-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
|
||||
@@ -150,7 +150,7 @@ export const MatrixQuestion = ({
|
||||
<td
|
||||
key={columnIndex}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-gray-800 ${columnIndex === question.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
|
||||
className={`fb-outline-brand fb-px-4 fb-py-2 fb-text-slate-800 ${columnIndex === question.columns.length - 1 ? "fb-rounded-r-custom" : ""}`}
|
||||
onClick={() =>
|
||||
handleSelect(
|
||||
getLocalizedValue(column, languageCode),
|
||||
|
||||
@@ -144,7 +144,7 @@ export const PictureSelectionQuestion = ({
|
||||
title="Open in new tab"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-gray-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
|
||||
@@ -73,7 +73,7 @@ export const Modal = ({ children, isOpen, placement, clickOutside, darkOverlay,
|
||||
"fb-relative fb-h-full fb-w-full",
|
||||
isCenter
|
||||
? darkOverlay
|
||||
? "fb-bg-gray-700/80"
|
||||
? "fb-bg-slate-700/80"
|
||||
: "fb-bg-white/50"
|
||||
: "fb-bg-none fb-transition-all fb-duration-500 fb-ease-in-out"
|
||||
)}>
|
||||
|
||||
117
packages/ui/components/BadgeSelect/index.tsx
Normal file
117
packages/ui/components/BadgeSelect/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../DropdownMenu";
|
||||
|
||||
const ZBadgeSelectOptionSchema = z.object({
|
||||
text: z.string(),
|
||||
type: z.enum(["warning", "success", "error", "gray"]),
|
||||
});
|
||||
|
||||
const ZBadgeSelectPropsSchema = z.object({
|
||||
text: z.string().optional(),
|
||||
type: z.enum(["warning", "success", "error", "gray"]).optional(),
|
||||
options: z.array(ZBadgeSelectOptionSchema).optional(),
|
||||
selectedIndex: z.number().optional(),
|
||||
onChange: z.function().args(z.number()).returns(z.void()).optional(),
|
||||
size: z.enum(["tiny", "normal", "large"]),
|
||||
className: z.string().optional(),
|
||||
isLoading: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TBadgeSelectOption = z.infer<typeof ZBadgeSelectOptionSchema>;
|
||||
export type TBadgeSelectProps = z.infer<typeof ZBadgeSelectPropsSchema>;
|
||||
|
||||
export const BadgeSelect: React.FC<TBadgeSelectProps & { isLoading?: boolean }> = ({
|
||||
text,
|
||||
type,
|
||||
options,
|
||||
selectedIndex = 0,
|
||||
onChange,
|
||||
size,
|
||||
className,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const bgColor = {
|
||||
warning: "bg-amber-100",
|
||||
success: "bg-emerald-100",
|
||||
error: "bg-red-100",
|
||||
gray: "bg-slate-100",
|
||||
};
|
||||
|
||||
const borderColor = {
|
||||
warning: "border-amber-200",
|
||||
success: "border-emerald-200",
|
||||
error: "border-red-200",
|
||||
gray: "border-slate-200",
|
||||
};
|
||||
|
||||
const textColor = {
|
||||
warning: "text-amber-800",
|
||||
success: "text-emerald-800",
|
||||
error: "text-red-800",
|
||||
gray: "text-slate-600",
|
||||
};
|
||||
|
||||
const padding = {
|
||||
tiny: "px-1.5 py-0.5",
|
||||
normal: "px-2.5 py-0.5",
|
||||
large: "px-3.5 py-1",
|
||||
};
|
||||
|
||||
const textSize = size === "large" ? "text-sm" : "text-xs";
|
||||
|
||||
const currentOption = options ? options[selectedIndex] : { text, type: type || "gray" };
|
||||
|
||||
const renderContent = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<span className="animate-pulse" aria-busy="true">
|
||||
<span className={cn("inline-block h-2 w-8 rounded-full bg-black/10")}></span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{currentOption.text}
|
||||
{options && <ChevronDownIcon className="ml-1 h-3 w-3" aria-hidden="true" />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border border-opacity-50 font-medium",
|
||||
options && !isLoading ? "cursor-pointer hover:border-opacity-100" : "pointer-events-none",
|
||||
bgColor[currentOption.type],
|
||||
borderColor[currentOption.type],
|
||||
textColor[currentOption.type],
|
||||
padding[size],
|
||||
textSize,
|
||||
className
|
||||
)}>
|
||||
{renderContent()}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
{options && (
|
||||
<DropdownMenuContent className="mt-1 bg-white shadow-lg">
|
||||
{options.map((option, index) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
className={cn("cursor-pointer px-4 py-2 hover:bg-slate-100", textSize)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onChange?.(index);
|
||||
}}>
|
||||
{option.text}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
)}
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
119
packages/ui/components/BadgeSelect/stories.ts
Normal file
119
packages/ui/components/BadgeSelect/stories.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["warning", "success", "error", "gray"],
|
||||
},
|
||||
size: { control: "select", options: ["small", "normal", "large"] },
|
||||
className: { control: "text" },
|
||||
},
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Gray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinySuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
@@ -11,7 +11,7 @@ interface FileUploadResponseProps {
|
||||
export const FileUploadResponse = ({ selected }: FileUploadResponseProps) => {
|
||||
const t = useTranslations();
|
||||
if (selected.length === 0) {
|
||||
return <div className="font-semibold text-gray-500">{t("common.skipped")}</div>;
|
||||
return <div className="font-semibold text-slate-500">{t("common.skipped")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -60,7 +60,7 @@ export const WithValue: Story = {
|
||||
|
||||
export const WithCustomClass: Story = {
|
||||
args: {
|
||||
className: "rounded-lg bg-gray-50 text-base",
|
||||
className: "rounded-lg bg-slate-50 text-base",
|
||||
isInvalid: false,
|
||||
disabled: false,
|
||||
placeholder: "Input with custom class",
|
||||
|
||||
@@ -39,7 +39,7 @@ export const Card: React.FC<CardProps> = ({
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative mr-1 flex h-2 w-2">
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-slate-400"></span>
|
||||
</span>
|
||||
)}
|
||||
{statusText}
|
||||
|
||||
@@ -42,8 +42,8 @@ export const LimitsReachedBanner = ({
|
||||
<TriangleAlertIcon className="text-error h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-base font-medium text-gray-900">{t("common.limits_reached")}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<p className="text-base font-medium text-slate-900">{t("common.limits_reached")}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{isPeopleLimitReached && isResponseLimitReached ? (
|
||||
<>
|
||||
{t("common.you_have_reached_your_monthly_miu_limit_of")}{" "}
|
||||
@@ -72,7 +72,7 @@ export const LimitsReachedBanner = ({
|
||||
<div className="absolute right-0 top-0 ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
className="inline-flex rounded-md bg-white text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
onClick={() => setShow(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
export const LoadingSpinner = () => {
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
export const LoadingSpinner = ({ className = "h-6 w-6" }: { className?: string }) => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<svg
|
||||
className="m-2 h-6 w-6 animate-spin text-slate-700"
|
||||
className={cn("m-2 animate-spin text-slate-700", className)}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
|
||||
@@ -43,8 +43,8 @@ export const PendingDowngradeBanner = ({
|
||||
<TriangleAlertIcon className="text-error h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-base font-medium text-gray-900">{t("common.pending_downgrade")}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
<p className="text-base font-medium text-slate-900">{t("common.pending_downgrade")}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{t(
|
||||
"common.we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable"
|
||||
)}
|
||||
@@ -65,7 +65,7 @@ export const PendingDowngradeBanner = ({
|
||||
<div className="absolute right-0 top-0 ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
className="inline-flex rounded-md bg-white text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
onClick={() => setShow(false)}>
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
|
||||
@@ -135,7 +135,7 @@ export const Modal = ({
|
||||
aria-live="assertive"
|
||||
className={cn(
|
||||
"relative h-full w-full overflow-hidden rounded-b-md",
|
||||
overlayVisible ? (darkOverlay ? "bg-gray-700/80" : "bg-white/50") : "",
|
||||
overlayVisible ? (darkOverlay ? "bg-slate-700/80" : "bg-white/50") : "",
|
||||
"transition-all duration-500 ease-in-out"
|
||||
)}>
|
||||
<div
|
||||
|
||||
@@ -644,7 +644,7 @@ export const QuestionFormInput = ({
|
||||
)}
|
||||
</div>
|
||||
{usedLanguageCode !== "default" && value && typeof value["default"] !== undefined && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
<strong>{t("environments.product.languages.translate")}:</strong>{" "}
|
||||
{recallToHeadline(value, localSurvey, false, "default", attributeClasses)["default"]}
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ export const RankingRespone = ({ value, isExpanded }: RankingResponseProps) => {
|
||||
(item, index) =>
|
||||
item && (
|
||||
<div key={index} className="mb-1 flex items-center">
|
||||
<span className="mr-2 text-gray-400">#{index + 1}</span>
|
||||
<div className="rounded bg-gray-100 px-2 py-1">{item}</div>
|
||||
<span className="mr-2 text-slate-400">#{index + 1}</span>
|
||||
<div className="rounded bg-slate-100 px-2 py-1">{item}</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -73,6 +73,7 @@ export const ShareSurveyLink = ({
|
||||
<div className="mt-2 flex items-center justify-center space-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={setLanguage} locale={locale} />
|
||||
<Button
|
||||
size="base"
|
||||
title={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
onClick={() => {
|
||||
@@ -88,6 +89,7 @@ export const ShareSurveyLink = ({
|
||||
{t("common.preview")}
|
||||
</Button>
|
||||
<Button
|
||||
size="base"
|
||||
variant="secondary"
|
||||
title={t("environments.surveys.copy_survey_link_to_clipboard")}
|
||||
aria-label={t("environments.surveys.copy_survey_link_to_clipboard")}
|
||||
|
||||
@@ -37,9 +37,9 @@ const sheetVariants = cva(
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-md",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -87,7 +87,7 @@ const SheetTitle = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-foreground text-lg font-semibold", className)}
|
||||
className={cn("text-foreground flex items-center text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -107,13 +107,13 @@ SheetDescription.displayName = SheetPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetOverlay,
|
||||
SheetPortal,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ export const SignupOptions = ({
|
||||
e.target.elements.email.value,
|
||||
e.target.elements.password.value,
|
||||
userLocale,
|
||||
inviteToken
|
||||
inviteToken || ""
|
||||
);
|
||||
const url = emailVerificationDisabled
|
||||
? `/auth/signup-without-verification-success`
|
||||
|
||||
@@ -49,7 +49,7 @@ export const StylingTabs = <T extends string | number>({
|
||||
</>
|
||||
|
||||
<div
|
||||
className={cn("flex overflow-hidden rounded-md border border-gray-300 p-2", tabsContainerClassName)}>
|
||||
className={cn("flex overflow-hidden rounded-md border border-slate-300 p-2", tabsContainerClassName)}>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
|
||||
Reference in New Issue
Block a user