feat: Update Insight category, document sentiment and archive insights (#4038)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Johannes
2024-11-04 23:07:27 -08:00
committed by GitHub
parent 31c3f9730e
commit 74bd40e0ff
52 changed files with 764 additions and 234 deletions

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>
))}

View File

@@ -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">

View File

@@ -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 {

View File

@@ -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)}

View File

@@ -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" />

View File

@@ -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}>

View File

@@ -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>
);

View File

@@ -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 ">

View File

@@ -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>

View File

@@ -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);
});

View File

@@ -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>

View File

@@ -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;
}
};

View File

@@ -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>
))

View 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;

View File

@@ -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");
}
});

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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;
}
};

View File

@@ -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 {

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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) => (

View File

@@ -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>
)
)}

View File

@@ -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 ? (

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"

View File

@@ -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),

View File

@@ -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"

View File

@@ -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"
)}>

View 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>
);
};

View 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",
},
};

View File

@@ -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 (

View File

@@ -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",

View File

@@ -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}

View File

@@ -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" />

View File

@@ -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">

View File

@@ -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" />

View File

@@ -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

View File

@@ -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>

View File

@@ -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>
)
)}

View File

@@ -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")}

View File

@@ -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,
};

View File

@@ -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`

View File

@@ -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}