Compare commits

...

37 Commits

Author SHA1 Message Date
Piyush Gupta
c1d4ddb203 Merge branch 'feature/ai-summary' of https://github.com/formbricks/formbricks into 363-ai-fix-current-feature 2024-10-03 17:21:37 +05:30
Piyush Gupta
7337e94538 Merge branch 'main' of https://github.com/formbricks/formbricks into feature/ai-summary 2024-10-03 17:19:59 +05:30
Piyush Gupta
f55df95f8c Merge branch 'main' of https://github.com/formbricks/formbricks into 363-ai-fix-current-feature 2024-10-03 17:16:14 +05:30
Piyush Gupta
dc894bae2a feat: adds feedback component 2024-10-03 17:16:06 +05:30
Matthias Nannt
126a4c8989 Merge branch 'main' of github.com:formbricks/formbricks into feature/ai-summary 2024-10-03 10:07:20 +02:00
Matthias Nannt
add567ffdc update prompt 2024-10-03 10:07:10 +02:00
Piyush Gupta
5e17b7919f Merge branch 'main' of https://github.com/formbricks/formbricks into 363-ai-fix-current-feature 2024-10-03 10:02:52 +05:30
Piyush Gupta
4f5ac2b27f Merge branch 'feature/ai-summary' of https://github.com/formbricks/formbricks into 363-ai-fix-current-feature 2024-10-03 09:55:05 +05:30
Piyush Gupta
897c2de656 fix: pnpm-lock file 2024-10-02 21:07:10 +05:30
Piyush Gupta
5c802c2fe8 Merge branch 'main' of https://github.com/formbricks/formbricks into 363-ai-fix-current-feature 2024-10-02 21:04:41 +05:30
Matthias Nannt
57927f6a3e solve build errors 2024-10-02 15:11:28 +02:00
Matthias Nannt
4ffbbad9fe merge newest changes 2024-10-02 14:50:39 +02:00
Piyush Gupta
7221a12964 Merge branch 'main' of https://github.com/formbricks/formbricks into 363-ai-fix-current-feature 2024-10-02 14:16:39 +05:30
Piyush Gupta
27fc47144e fix: UI changes 2024-10-02 14:09:39 +05:30
Piyush Gupta
6a691e2b68 sync with main 2024-10-01 19:17:33 +05:30
Johannes
7eca969496 tweaks 2024-09-09 18:27:40 +02:00
Johannes
96292130a8 Merge branch 'main' of https://github.com/formbricks/formbricks into feature/ai-summary 2024-09-09 12:33:14 +02:00
Matthias Nannt
8891000c64 add comments 2024-08-30 09:08:30 +02:00
Matthias Nannt
b23088bd2f show only survey feedbacks 2024-08-29 19:42:21 +02:00
Matthias Nannt
470151d79b add document view 2024-08-29 19:23:37 +02:00
Matthias Nannt
df054537ee solve merge conflicts 2024-08-29 17:27:20 +02:00
Matthias Nannt
12f721982f add openText insights summary 2024-08-29 17:12:28 +02:00
Matthias Nannt
9c6aaf5365 remove open text summary 2024-08-28 18:20:14 +02:00
Matthias Nannt
519f7838c6 documents can have multiple insights 2024-08-28 17:47:44 +02:00
Matthias Nannt
32d870b063 add new database model including documentGroups 2024-08-27 17:13:04 +02:00
Matthias Nannt
81738b77f5 update prompt 2024-08-23 17:20:28 +02:00
Matthias Nannt
5e9df605e4 add document search 2024-08-22 18:07:00 +02:00
Matthias Nannt
590f9305b8 remove commented out code 2024-08-22 10:34:37 +02:00
Matthias Nannt
a69df3baf0 add llm functionality 2024-08-21 16:35:33 +02:00
Matthias Nannt
da124bbbbe update server action to new format 2024-08-21 14:18:08 +02:00
Matthias Nannt
539e0e2fd3 fix conflicts 2024-08-21 13:24:39 +02:00
Matthias Nannt
9ffd6d4121 add environmentId to embeddings 2024-08-21 12:57:06 +02:00
Matthias Nannt
cc9ea82e5c fix embeddings creation, add embeddings retrieval 2024-08-05 20:37:45 +02:00
Matthias Nannt
14e3bb07ec add embedding service 2024-08-05 16:47:18 +02:00
Matthias Nannt
ab1fe677d9 update schema 2024-08-05 15:33:29 +02:00
Matthias Nannt
703260b906 solve merge conflicts 2024-08-05 11:25:29 +02:00
Matthias Nannt
7a24badff1 add embeddings to response model 2024-07-29 18:15:53 +02:00
47 changed files with 2783 additions and 440 deletions

View File

@@ -180,3 +180,9 @@ UNSPLASH_ACCESS_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# Azure AI settings
# AI_AZURE_RESSOURCE_NAME=
# AI_AZURE_API_KEY=
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
# AI_AZURE_LLM_DEPLOYMENT_ID=

View File

@@ -18,8 +18,8 @@ import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/ser
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getWebhookCountBySource } from "@formbricks/lib/webhook/service";
import { TIntegrationType } from "@formbricks/types/integration";
import { Card } from "@formbricks/ui/components/Card";
import { ErrorComponent } from "@formbricks/ui/components/ErrorComponent";
import { Card } from "@formbricks/ui/components/IntegrationCard";
import { PageContentWrapper } from "@formbricks/ui/components/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/components/PageHeader";

View File

@@ -1,11 +1,26 @@
import { UserIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import formbricks from "@formbricks/js/app";
import { getPersonIdentifier } from "@formbricks/lib/person/utils";
import { timeSince } from "@formbricks/lib/time";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TInsight } from "@formbricks/types/insights";
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
import { PersonAvatar } from "@formbricks/ui/components/Avatars";
import { Badge } from "@formbricks/ui/components/Badge";
import { Button } from "@formbricks/ui/components/Button";
import { InsightFilter } from "@formbricks/ui/components/InsightFilter";
import { InsightSheet } from "@formbricks/ui/components/InsightSheet";
import { SecondaryNavigation } from "@formbricks/ui/components/SecondaryNavigation";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@formbricks/ui/components/Table";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface OpenTextSummaryProps {
@@ -13,6 +28,9 @@ interface OpenTextSummaryProps {
environmentId: string;
survey: TSurvey;
attributeClasses: TAttributeClass[];
isAiEnabled: boolean;
productId: string;
productName: string;
}
export const OpenTextSummary = ({
@@ -20,8 +38,17 @@ export const OpenTextSummary = ({
environmentId,
survey,
attributeClasses,
isAiEnabled,
productId,
productName,
}: OpenTextSummaryProps) => {
const [visibleResponses, setVisibleResponses] = useState(10);
const [activeTab, setActiveTab] = useState<"insights" | "responses">(
isAiEnabled ? "insights" : "responses"
);
const [isInsightSheetOpen, setIsInsightSheetOpen] = useState(true);
const [insights, setInsights] = useState<TInsight[]>(questionSummary.insights);
const [currentInsight, setCurrentInsight] = useState<TInsight | null>(null);
const handleLoadMore = () => {
// Increase the number of visible responses by 10, not exceeding the total number of responses
@@ -30,6 +57,35 @@ export const OpenTextSummary = ({
);
};
const tabNavigation = [
{
id: "insights",
label: "Insights",
onClick: () => setActiveTab("insights"),
},
{
id: "responses",
label: "Responses",
onClick: () => setActiveTab("responses"),
},
];
const handleFeedback = (feedback: "positive" | "negative") => {
formbricks.track("Insight Feedback", {
hiddenFields: {
feedbackSentiment: feedback,
productId,
productName,
surveyId: survey.id,
surveyName: survey.name,
insightId: currentInsight?.id,
insightCategory: currentInsight?.category,
questionId: questionSummary.question.id,
environmentId,
},
});
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader
@@ -37,54 +93,113 @@ export const OpenTextSummary = ({
survey={survey}
attributeClasses={attributeClasses}
/>
<div className="">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
<InsightSheet
isOpen={isInsightSheetOpen}
setIsOpen={setIsInsightSheetOpen}
insight={currentInsight}
surveyId={survey.id}
questionId={questionSummary.question.id}
handleFeedback={handleFeedback}
/>
{isAiEnabled && (
<div className="flex items-center justify-between pr-4">
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
<InsightFilter insights={questionSummary.insights} setInsights={setInsights} />
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
)}
<div className="max-h-[40vh] overflow-y-auto">
{activeTab === "insights" ? (
<Table className="border-t border-slate-200">
<TableBody>
{questionSummary.insights.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center">
<p className="text-slate-500">No insights found for this question.</p>
</TableCell>
</TableRow>
) : insights.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center">
<p className="text-slate-500">No insights found for this filter.</p>
</TableCell>
</TableRow>
) : (
insights.map((insight) => (
<TableRow
key={insight.id}
className="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>{insight.description}</TableCell>
<TableCell>
{insight.category === "complaint" ? (
<Badge text="Complaint" type="error" size="tiny" />
) : insight.category === "featureRequest" ? (
<Badge text="Request" type="warning" size="tiny" />
) : insight.category === "praise" ? (
<Badge text="Praise" type="success" size="tiny" />
) : null}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
) : activeTab === "responses" ? (
<>
<Table className="border-t border-slate-200">
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead>User</TableHead>
<TableHead>Response</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell>
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person, response.personAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</TableCell>
<TableCell className="font-medium">{response.value}</TableCell>
<TableCell>{timeSince(new Date(response.updatedAt).toISOString())}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
Load more
</Button>
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold" dir="auto">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
</div>
</div>
))}
</div>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
Load more
</Button>
</div>
)}
)}
</>
) : null}
</div>
</div>
);

View File

@@ -39,6 +39,8 @@ interface SummaryListProps {
survey: TSurvey;
totalResponseCount: number;
attributeClasses: TAttributeClass[];
isAiEnabled: boolean;
productName: string;
}
export const SummaryList = ({
@@ -48,6 +50,8 @@ export const SummaryList = ({
survey,
totalResponseCount,
attributeClasses,
isAiEnabled,
productName,
}: SummaryListProps) => {
const { setSelectedFilter, selectedFilter } = useResponseFilter();
const widgetSetupCompleted =
@@ -131,6 +135,9 @@ export const SummaryList = ({
environmentId={environment.id}
survey={survey}
attributeClasses={attributeClasses}
isAiEnabled={isAiEnabled}
productId={environment.productId}
productName={productName}
/>
);
}

View File

@@ -47,6 +47,8 @@ interface SummaryPageProps {
user?: TUser;
totalResponseCount: number;
attributeClasses: TAttributeClass[];
isAiEnabled: boolean;
productName: string;
}
export const SummaryPage = ({
@@ -56,6 +58,8 @@ export const SummaryPage = ({
webAppUrl,
totalResponseCount,
attributeClasses,
isAiEnabled,
productName,
}: SummaryPageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
@@ -174,6 +178,8 @@ export const SummaryPage = ({
environment={environment}
totalResponseCount={totalResponseCount}
attributeClasses={attributeClasses}
isAiEnabled={isAiEnabled}
productName={productName}
/>
</>
);

View File

@@ -5,7 +5,7 @@ import { getServerSession } from "next-auth";
import { notFound } from "next/navigation";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { IS_AI_ENABLED, IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -60,6 +60,16 @@ const Page = async ({ params }) => {
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
// I took this out cause it's cloud only right?
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const isAiEnabled =
// isEnterpriseEdition &&
IS_FORMBRICKS_CLOUD &&
(organization.billing.plan === "startup" ||
organization.billing.plan === "scale" ||
organization.billing.plan === "enterprise") &&
IS_AI_ENABLED;
return (
<PageContentWrapper>
@@ -89,6 +99,8 @@ const Page = async ({ params }) => {
user={user}
totalResponseCount={totalResponseCount}
attributeClasses={attributeClasses}
isAiEnabled={isAiEnabled}
productName={product.name}
/>
</PageContentWrapper>
);

View File

@@ -4,8 +4,10 @@ import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
import { sendResponseFinishedEmail } from "@formbricks/email";
import { cache } from "@formbricks/lib/cache";
import { CRON_SECRET } from "@formbricks/lib/constants";
import { CRON_SECRET, IS_AI_ENABLED, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { createDocument } from "@formbricks/lib/document/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { convertDatesInObject } from "@formbricks/lib/time";
@@ -139,6 +141,43 @@ export const POST = async (request: Request) => {
console.error("Promise rejected:", result.reason);
}
});
// generate embeddings for all open text question responses for all paid plans
// TODO: check longer surveys if documents get created multiple times
const hasSurveyOpenTextQuestions = survey.questions.some((question) => question.type === "openText");
if (hasSurveyOpenTextQuestions && IS_FORMBRICKS_CLOUD) {
// const { active: isEnterpriseEdition } = await getEnterpriseLicense();
const isAiEnabled = IS_AI_ENABLED;
if (hasSurveyOpenTextQuestions && isAiEnabled) {
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
}
if (
organization.billing.plan === "enterprise" ||
organization.billing.plan === "scale" ||
organization.billing.plan === "startup"
) {
for (const question of survey.questions) {
if (question.type === "openText") {
const isQuestionAnswered = response.data[question.id] !== undefined;
if (!isQuestionAnswered) {
continue;
}
const text = `**${question.headline.default}**\n${response.data[question.id]}`;
// TODO: check if subheadline gives more context and better embeddings
await createDocument({
environmentId,
surveyId,
responseId: response.id,
questionId: question.id,
text,
});
}
}
}
}
}
} else {
// Await webhook promises if no emails are sent (with allSettled to prevent early rejection)
const results = await Promise.allSettled(webhookPromises);

View File

@@ -56,6 +56,7 @@ const Page = async ({ params }) => {
webAppUrl={WEBAPP_URL}
totalResponseCount={totalResponseCount}
attributeClasses={attributeClasses}
isAiEnabled={false} // Disable AI for sharing page for now
/>
</PageContentWrapper>
</div>

View File

@@ -20,8 +20,8 @@ import { RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
export const middleware = async (request: NextRequest) => {
// issue with next auth types & Next 15; let's review when new fixes are available
const token = await getToken({ req: request });
// TODO: any type because of conflict between nextjs and next-auth types. Will likely be solved with future packae updates
const token = await getToken({ req: request as any });
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;

View File

@@ -46,6 +46,7 @@
"lodash": "^4.17.21",
"lru-cache": "^11.0.0",
"lucide-react": "^0.427.0",
"markdown-to-jsx": "^7.5.0",
"mime": "^4.0.4",
"next": "14.2.5",
"next-safe-action": "^7.6.2",

View File

@@ -3,11 +3,11 @@
"version": "0.0.0",
"private": true,
"devDependencies": {
"@next/eslint-plugin-next": "^14.2.5",
"@next/eslint-plugin-next": "14.2.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vercel/style-guide": "^6.0.0",
"eslint-config-next": "^14.2.5",
"eslint-config-next": "14.2.5",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^2.0.14",
"eslint-plugin-react": "7.35.0",

View File

@@ -14,7 +14,8 @@
"noUnusedParameters": true,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true
"strict": true,
"strictNullChecks": true
},
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,7 +1,7 @@
version: "3.3"
services:
postgres:
image: postgres:15-alpine
image: ankane/pgvector
volumes:
- formbricks-postgres:/var/lib/postgresql/data
environment:

View File

@@ -0,0 +1,70 @@
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "vector";
-- CreateEnum
CREATE TYPE "InsightCategory" AS ENUM ('featureRequest', 'complaint', 'praise');
-- CreateEnum
CREATE TYPE "Sentiment" AS ENUM ('positive', 'negative', 'neutral');
-- CreateTable
CREATE TABLE "Insight" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"environmentId" TEXT NOT NULL,
"category" "InsightCategory" NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"vector" vector(512),
CONSTRAINT "Insight_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DocumentInsight" (
"documentId" TEXT NOT NULL,
"insightId" TEXT NOT NULL,
CONSTRAINT "DocumentInsight_pkey" PRIMARY KEY ("documentId","insightId")
);
-- CreateTable
CREATE TABLE "Document" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"environmentId" TEXT NOT NULL,
"surveyId" TEXT,
"responseId" TEXT,
"questionId" TEXT,
"sentiment" "Sentiment" NOT NULL,
"text" TEXT NOT NULL,
"vector" vector(512),
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DocumentInsight_insightId_idx" ON "DocumentInsight"("insightId");
-- CreateIndex
CREATE UNIQUE INDEX "Document_responseId_questionId_key" ON "Document"("responseId", "questionId");
-- AddForeignKey
ALTER TABLE "Insight" ADD CONSTRAINT "Insight_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentInsight" ADD CONSTRAINT "DocumentInsight_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "Document"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DocumentInsight" ADD CONSTRAINT "DocumentInsight_insightId_fkey" FOREIGN KEY ("insightId") REFERENCES "Insight"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_environmentId_fkey" FOREIGN KEY ("environmentId") REFERENCES "Environment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_surveyId_fkey" FOREIGN KEY ("surveyId") REFERENCES "Survey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Document" ADD CONSTRAINT "Document_responseId_fkey" FOREIGN KEY ("responseId") REFERENCES "Response"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -61,13 +61,13 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/types": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2",
"@paralleldrive/cuid2": "2.2.2",
"@formbricks/eslint-config": "workspace:*",
"prisma": "^5.18.0",
"prisma-dbml-generator": "^0.12.0",
"prisma-json-types-generator": "^3.0.4",
"ts-node": "^10.9.2",
"zod": "^3.23.8",
"zod-prisma": "^0.5.4"
"prisma": "5.18.0",
"prisma-dbml-generator": "0.12.0",
"prisma-json-types-generator": "3.0.4",
"ts-node": "10.9.2",
"zod": "3.23.8",
"zod-prisma": "0.5.4"
}
}

View File

@@ -2,12 +2,14 @@
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [pgvector(map: "vector")]
}
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
// generator dbml {
@@ -134,6 +136,7 @@ model Response {
// singleUseId, used to prevent multiple responses
singleUseId String?
language String?
documents Document[]
displayId String? @unique
display Display? @relation(fields: [displayId], references: [id])
@@ -329,6 +332,7 @@ model Survey {
displayPercentage Decimal?
languages SurveyLanguage[]
showLanguageSwitch Boolean?
documents Document[]
@@index([environmentId, updatedAt])
@@index([segmentId])
@@ -404,6 +408,8 @@ model Environment {
tags Tag[]
segments Segment[]
integration Integration[]
documents Document[]
insights Insight[]
@@index([productId])
}
@@ -648,3 +654,57 @@ model SurveyLanguage {
@@index([surveyId])
@@index([languageId])
}
enum InsightCategory {
featureRequest
complaint
praise
}
model Insight {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
category InsightCategory
title String
description String
vector Unsupported("vector(512)")?
documentInsights DocumentInsight[]
}
model DocumentInsight {
documentId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
insightId String
insight Insight @relation(fields: [insightId], references: [id], onDelete: Cascade)
@@id([documentId, insightId])
@@index([insightId])
}
enum Sentiment {
positive
negative
neutral
}
model Document {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
environmentId String
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
surveyId String?
survey Survey? @relation(fields: [surveyId], references: [id], onDelete: Cascade)
responseId String?
response Response? @relation(fields: [responseId], references: [id], onDelete: Cascade)
questionId String?
sentiment Sentiment
text String
vector Unsupported("vector(512)")?
documentInsights DocumentInsight[]
@@unique([responseId, questionId])
}

View File

@@ -20,13 +20,15 @@
"@types/react": "18.3.3"
},
"dependencies": {
"@ai-sdk/azure": "^0.0.17",
"@formbricks/database": "workspace:*",
"@formbricks/lib": "workspace:*",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-collapsible": "^1.1.0",
"ai": "^3.2.37",
"https-proxy-agent": "^7.0.5",
"lucide-react": "^0.427.0",
"next": "^14.2.5",
"next": "14.2.5",
"next-auth": "^4.24.7",
"node-fetch": "^3.3.2",
"react-hook-form": "^7.52.2",

14
packages/lib/ai.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createAzure } from "@ai-sdk/azure";
import { env } from "./env";
export const llmModel = createAzure({
resourceName: env.AI_AZURE_LLM_RESSOURCE_NAME, // Azure resource name
apiKey: env.AI_AZURE_LLM_API_KEY, // Azure API key
})(env.AI_AZURE_LLM_DEPLOYMENT_ID || "llm");
export const embeddingsModel = createAzure({
resourceName: env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME, // Azure resource name
apiKey: env.AI_AZURE_EMBEDDINGS_API_KEY, // Azure API key
}).embedding(env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID || "embeddings", {
dimensions: 512,
});

View File

@@ -211,3 +211,12 @@ export const BILLING_LIMITS = {
MIU: 20000,
},
} as const;
export const IS_AI_ENABLED = !!(
env.AI_AZURE_EMBEDDINGS_API_KEY &&
env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID &&
env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME &&
env.AI_AZURE_LLM_API_KEY &&
env.AI_AZURE_LLM_DEPLOYMENT_ID &&
env.AI_AZURE_LLM_RESSOURCE_NAME
);

View File

@@ -0,0 +1,53 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string | null;
surveyId?: string | null;
responseId?: string | null;
questionId?: string | null;
insightId?: string | null;
}
export const documentCache = {
tag: {
byId(id: string) {
return `documents-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-documents`;
},
byResponseIdQuestionId(responseId: string, questionId: string) {
return `responses-${responseId}-questions-${questionId}-documents`;
},
bySurveyId(surveyId: string) {
return `surveys-${surveyId}-documents`;
},
bySurveyIdQuestionId(surveyId: string, questionId: string) {
return `surveys-${surveyId}-questions-${questionId}-documents`;
},
byInsightId(insightId: string) {
return `insights-${insightId}-documents`;
},
},
revalidate({ id, environmentId, surveyId, responseId, questionId, insightId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
if (surveyId) {
revalidateTag(this.tag.bySurveyId(surveyId));
}
if (responseId && questionId) {
revalidateTag(this.tag.byResponseIdQuestionId(responseId, questionId));
}
if (surveyId && questionId) {
revalidateTag(this.tag.bySurveyIdQuestionId(surveyId, questionId));
}
if (insightId) {
revalidateTag(this.tag.byInsightId(insightId));
}
},
};

View File

@@ -0,0 +1,288 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { embed, generateObject } from "ai";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import {
TDocument,
TDocumentCreateInput,
ZDocumentCreateInput,
ZDocumentSentiment,
} from "@formbricks/types/documents";
import { DatabaseError } from "@formbricks/types/errors";
import { ZInsightCategory } from "@formbricks/types/insights";
import { embeddingsModel, llmModel } from "../ai";
import { cache } from "../cache";
import { validateInputs } from "../utils/validate";
import { documentCache } from "./cache";
import { handleInsightAssignments } from "./utils";
const DOCUMENTS_PER_PAGE = 10;
export type TPrismaDocument = Omit<TDocument, "vector"> & {
vector: string;
};
export const getDocumentsByInsightId = reactCache(
(insightId: string, limit?: number, offset?: number): Promise<TDocument[]> =>
cache(
async () => {
validateInputs([insightId, ZId]);
limit = limit ?? DOCUMENTS_PER_PAGE;
try {
const documents = await prisma.document.findMany({
where: {
documentInsights: {
some: {
insightId,
},
},
},
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return documents;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDocumentsByInsightId-${insightId}-${limit}-${offset}`],
{
tags: [documentCache.tag.byInsightId(insightId)],
}
)()
);
export const getDocumentsByInsightIdSurveyIdQuestionId = reactCache(
(
insightId: string,
surveyId: string,
questionId: string,
limit?: number,
offset?: number
): Promise<TDocument[]> =>
cache(
async () => {
validateInputs([insightId, ZId]);
limit = limit ?? DOCUMENTS_PER_PAGE;
try {
const documents = await prisma.document.findMany({
where: {
questionId,
documentInsights: {
some: {
insightId,
},
},
},
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return documents;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDocumentsByInsightIdQuestionId-${insightId}-${limit}-${offset}`],
{
tags: [documentCache.tag.bySurveyIdQuestionId(surveyId, questionId)],
}
)()
);
export const createDocument = async (documentInput: TDocumentCreateInput): Promise<TDocument> => {
validateInputs([documentInput, ZDocumentCreateInput]);
try {
// Generate text embedding
const { embedding } = await embed({
model: embeddingsModel,
value: documentInput.text,
});
// generate sentiment and insights
const { object } = await generateObject({
model: llmModel,
schema: z.object({
sentiment: ZDocumentSentiment,
insights: z.array(
z.object({
title: z.string(),
description: z.string(),
category: ZInsightCategory,
})
),
}),
system: `You are an XM researcher. You analyse user feedback and extract insights and the sentiment from it. You are very objective, for the insights split the feedback in the smallest parts possible and only use the feedback itself to draw conclusions. An insight consist of a title and description (e.g. title: "Interactive charts and graphics", description: "Users would love to see a visualization of the analytics data") as well as tag it with the right category and tries to give insights while being not too specific as it might hold multiple documents.`,
prompt: `Analyze this feedback: "${documentInput.text}"`,
});
const sentiment = object.sentiment;
const insights = object.insights;
// create document
const prismaDocument = await prisma.document.create({
data: {
...documentInput,
sentiment,
},
});
const document = {
...prismaDocument,
vector: embedding,
};
// update document vector with the embedding
const vectorString = `[${embedding.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Document"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${document.id};
`;
// connect or create the insights
const insightPromises: Promise<void>[] = [];
for (const insight of insights) {
if (typeof insight.title !== "string" || typeof insight.description !== "string") {
throw new Error("Insight title and description must be a string");
}
// create or connect the insight
insightPromises.push(handleInsightAssignments(documentInput.environmentId, document.id, insight));
}
await Promise.all(insightPromises);
documentCache.revalidate({
id: document.id,
environmentId: document.environmentId,
surveyId: document.surveyId,
responseId: document.responseId,
questionId: document.questionId,
});
return document;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getDocumentsByResponseIdQuestionId = reactCache(
(responseId: string, questionId: string): Promise<TDocument[]> =>
cache(
async () => {
validateInputs([responseId, ZId], [questionId, ZId]);
try {
const prismaDocuments: TPrismaDocument[] = await prisma.$queryRaw`
SELECT
id,
created_at AS "createdAt",
updated_at AS "updatedAt",
"responseId",
"questionId",
text,
vector::text
FROM "Document" d
WHERE d."responseId" = ${responseId}
AND d."questionId" = ${questionId}
`;
const documents = prismaDocuments.map((prismaDocument) => {
// Convert the string representation of the vector back to an array of numbers
const vector = prismaDocument.vector
.slice(1, -1) // Remove the surrounding square brackets
.split(",") // Split the string into an array of strings
.map(Number); // Convert each string to a number
return {
...prismaDocument,
vector,
};
});
return documents;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
console.error(error);
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getDocumentsByResponseIdQuestionId-${responseId}-${questionId}`],
{
tags: [documentCache.tag.byResponseIdQuestionId(responseId, questionId)],
}
)()
);
export const findNearestDocuments = async (
environmentId: string,
vector: number[],
limit: number = 5,
threshold: number = 0.5
): Promise<TDocument[]> => {
validateInputs([environmentId, ZId]);
// Convert the embedding array to a JSON-like string representation
const vectorString = `[${vector.join(",")}]`;
// Execute raw SQL query to find nearest neighbors and exclude the vector column
const prismaDocuments: TPrismaDocument[] = await prisma.$queryRaw`
SELECT
id,
created_at AS "createdAt",
updated_at AS "updatedAt",
"environmentId",
text,
"responseId",
"questionId",
"documentGroupId",
vector::text
FROM "Document" d
WHERE d."environmentId" = ${environmentId}
AND d."vector" <=> ${vectorString}::vector(512) <= ${threshold}
ORDER BY d."vector" <=> ${vectorString}::vector(512)
LIMIT ${limit};
`;
const documents = prismaDocuments.map((prismaDocument) => {
// Convert the string representation of the vector back to an array of numbers
const vector = prismaDocument.vector
.slice(1, -1) // Remove the surrounding square brackets
.split(",") // Split the string into an array of strings
.map(Number); // Convert each string to a number
return {
...prismaDocument,
vector,
};
});
return documents;
};

View File

@@ -0,0 +1,62 @@
import "server-only";
import { embed } from "ai";
import { prisma } from "@formbricks/database";
import { TInsightCategory } from "@formbricks/types/insights";
import { embeddingsModel } from "../ai";
import { createInsight, findNearestInsights } from "../insight/service";
import { getInsightVectorText } from "../insight/utils";
import { documentCache } from "./cache";
export const getQuestionResponseReferenceId = (surveyId: string, questionId: string) => {
return `${surveyId}-${questionId}`;
};
export const handleInsightAssignments = async (
environmentId: string,
documentId: string,
insight: {
title: string;
description: string;
category: TInsightCategory;
}
) => {
// create embedding for insight
const { embedding } = await embed({
model: embeddingsModel,
value: getInsightVectorText(insight.title, insight.description),
});
// find close insight to merge it with
const nearestInsights = await findNearestInsights(environmentId, embedding, 1, 0.35);
if (nearestInsights.length > 0) {
// create a documentInsight with this insight
await prisma.documentInsight.create({
data: {
documentId,
insightId: nearestInsights[0].id,
},
});
documentCache.revalidate({
insightId: nearestInsights[0].id,
});
} else {
// create new insight and documentInsight
const newInsight = await createInsight({
environmentId: environmentId,
title: insight.title,
description: insight.description,
category: insight.category,
vector: embedding,
});
// create a documentInsight with this insight
await prisma.documentInsight.create({
data: {
documentId,
insightId: newInsight.id,
},
});
documentCache.revalidate({
insightId: newInsight.id,
});
}
};

View File

@@ -7,6 +7,12 @@ export const env = createEnv({
* Will throw if you access these variables on the client.
*/
server: {
AI_AZURE_EMBEDDINGS_API_KEY: z.string().optional(),
AI_AZURE_LLM_API_KEY: z.string().optional(),
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: z.string().optional(),
AI_AZURE_LLM_DEPLOYMENT_ID: z.string().optional(),
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: z.string().optional(),
AI_AZURE_LLM_RESSOURCE_NAME: z.string().optional(),
AIRTABLE_CLIENT_ID: z.string().optional(),
AZUREAD_CLIENT_ID: z.string().optional(),
AZUREAD_CLIENT_SECRET: z.string().optional(),
@@ -113,6 +119,12 @@ export const env = createEnv({
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
AI_AZURE_EMBEDDINGS_API_KEY: process.env.AI_AZURE_EMBEDDINGS_API_KEY,
AI_AZURE_LLM_API_KEY: process.env.AI_AZURE_LLM_API_KEY,
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: process.env.AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID,
AI_AZURE_LLM_DEPLOYMENT_ID: process.env.AI_AZURE_LLM_DEPLOYMENT_ID,
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: process.env.AI_AZURE_EMBEDDINGS_RESSOURCE_NAME,
AI_AZURE_LLM_RESSOURCE_NAME: process.env.AI_AZURE_LLM_RESSOURCE_NAME,
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,

View File

@@ -0,0 +1,25 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
}
export const insightCache = {
tag: {
byId(id: string) {
return `documentGroups-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-documentGroups`;
},
},
revalidate({ id, environmentId }: RevalidateProps): void {
if (id) {
revalidateTag(this.tag.byId(id));
}
if (environmentId) {
revalidateTag(this.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -0,0 +1,273 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TInsight, TInsightCreateInput, ZInsightCreateInput } from "@formbricks/types/insights";
import { cache } from "../cache";
import { documentCache } from "../document/cache";
import { validateInputs } from "../utils/validate";
import { insightCache } from "./cache";
const INSIGHTS_PER_PAGE = 10;
export const getInsight = reactCache(
(id: string): Promise<TInsight | null> =>
cache(
async () => {
validateInputs([id, ZId]);
try {
const insight = await prisma.insight.findUnique({
where: {
id,
},
include: {
_count: {
select: {
documentInsights: true,
},
},
},
});
return insight;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInsight-${id}`],
{
tags: [insightCache.tag.byId(id)],
}
)()
);
export const getInsights = reactCache(
(environmentId: string, limit?: number, offset?: number): Promise<TInsight[]> =>
cache(
async () => {
validateInputs([environmentId, ZId]);
limit = limit ?? INSIGHTS_PER_PAGE;
try {
const insights = await prisma.insight.findMany({
where: {
environmentId,
},
include: {
_count: {
select: {
documentInsights: true,
},
},
},
orderBy: [
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return insights;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInsights-${environmentId}-${limit}-${offset}`],
{
tags: [insightCache.tag.byEnvironmentId(environmentId)],
}
)()
);
export const getInsightsBySurveyId = reactCache(
(surveyId: string, limit?: number, offset?: number): Promise<TInsight[]> =>
cache(
async () => {
validateInputs([surveyId, ZId]);
limit = limit ?? INSIGHTS_PER_PAGE;
try {
const insights = await prisma.insight.findMany({
where: {
documentInsights: {
some: {
document: {
surveyId,
},
},
},
},
include: {
_count: {
select: {
documentInsights: true,
},
},
},
orderBy: [
{
documentInsights: {
_count: "desc",
},
},
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return insights;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInsightsBySurveyId-${surveyId}-${limit}-${offset}`],
{
tags: [documentCache.tag.bySurveyId(surveyId)],
}
)()
);
export const getInsightsBySurveyIdQuestionId = reactCache(
(surveyId: string, questionId: string, limit?: number, offset?: number): Promise<TInsight[]> =>
cache(
async () => {
validateInputs([surveyId, ZId], [questionId, ZId]);
limit = limit ?? INSIGHTS_PER_PAGE;
try {
const insights = await prisma.insight.findMany({
where: {
documentInsights: {
some: {
document: {
surveyId,
questionId,
},
},
},
},
include: {
_count: {
select: {
documentInsights: true,
},
},
},
orderBy: [
{
documentInsights: {
_count: "desc",
},
},
{
createdAt: "desc",
},
],
take: limit ? limit : undefined,
skip: offset ? offset : undefined,
});
return insights;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getInsightsBySurveyIdQuestionId-${surveyId}-${limit}-${offset}`],
{
tags: [documentCache.tag.bySurveyId(surveyId)],
}
)()
);
export const createInsight = async (insightGroupInput: TInsightCreateInput): Promise<TInsight> => {
validateInputs([insightGroupInput, ZInsightCreateInput]);
try {
// create document
const { vector, ...data } = insightGroupInput;
const prismaInsight = await prisma.insight.create({
data,
});
const insight = {
...prismaInsight,
_count: {
documentInsights: 0,
},
};
// update document vector with the embedding
const vectorString = `[${insightGroupInput.vector.join(",")}]`;
await prisma.$executeRaw`
UPDATE "Insight"
SET "vector" = ${vectorString}::vector(512)
WHERE "id" = ${insight.id};
`;
insightCache.revalidate({
id: insight.id,
environmentId: insight.environmentId,
});
return insight;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const findNearestInsights = async (
environmentId: string,
vector: number[],
limit: number = 5,
threshold: number = 0.5
): Promise<TInsight[]> => {
validateInputs([environmentId, ZId]);
// Convert the embedding array to a JSON-like string representation
const vectorString = `[${vector.join(",")}]`;
// Execute raw SQL query to find nearest neighbors and exclude the vector column
const insights: TInsight[] = await prisma.$queryRaw`
SELECT
id,
created_at AS "createdAt",
updated_at AS "updatedAt",
title,
description,
category,
"environmentId"
FROM "Insight" d
WHERE d."environmentId" = ${environmentId}
AND d."vector" <=> ${vectorString}::vector(512) <= ${threshold}
ORDER BY d."vector" <=> ${vectorString}::vector(512)
LIMIT ${limit};
`;
return insights;
};

View File

@@ -0,0 +1,2 @@
export const getInsightVectorText = (title: string, description: string): string =>
`${title}: ${description}`;

View File

@@ -3,6 +3,7 @@ import { getActionClass } from "../actionClass/service";
import { getApiKey } from "../apiKey/service";
import { getAttributeClass } from "../attributeClass/service";
import { getEnvironment } from "../environment/service";
import { getInsight } from "../insight/service";
import { getIntegration } from "../integration/service";
import { getInvite } from "../invite/service";
import { getLanguage } from "../language/service";
@@ -55,6 +56,15 @@ export const getOrganizationIdFromResponseId = async (responseId: string) => {
return await getOrganizationIdFromSurveyId(response.surveyId);
};
export const getOrganizationIdFromInsightId = async (insightId: string) => {
const insight = await getInsight(insightId);
if (!insight) {
throw new ResourceNotFoundError("insight", insightId);
}
return await getOrganizationIdFromEnvironmentId(insight.environmentId);
};
export const getOrganizationIdFromPersonId = async (personId: string) => {
const person = await getPerson(personId);
if (!person) {

View File

@@ -38,11 +38,11 @@
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@types/jsonwebtoken": "^9.0.6",
"@types/mime-types": "^2.1.4",
"@types/ungap__structured-clone": "^1.2.0",
"dotenv": "^16.4.5",
"@formbricks/eslint-config": "workspace:*",
"ts-node": "^10.9.2",
"vitest": "^2.0.5",
"vitest-mock-extended": "^2.0.0"

View File

@@ -548,7 +548,7 @@ export const getSurveySummary = reactCache(
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const meta = getSurveySummaryMeta(responses, displayCount);
const questionWiseSummary = getQuestionWiseSummary(survey, responses, dropOff);
const questionWiseSummary = await getQuestionWiseSummary(survey, responses, dropOff);
return { meta, dropOff, summary: questionWiseSummary };
} catch (error) {

View File

@@ -29,6 +29,7 @@ import {
TSurveySummary,
} from "@formbricks/types/surveys/types";
import { getLocalizedValue } from "../i18n/utils";
import { getInsightsBySurveyIdQuestionId } from "../insight/service";
import { structuredClone } from "../pollyfills/structuredClone";
import { processResponseData } from "../responses";
import { evaluateLogic, performActions } from "../surveyLogic/utils";
@@ -815,15 +816,15 @@ const checkForI18n = (response: TResponse, id: string, survey: TSurvey, language
return getLocalizedValue(choice?.label, "default") || response.data[id];
};
export const getQuestionWiseSummary = (
export const getQuestionWiseSummary = async (
survey: TSurvey,
responses: TResponse[],
dropOff: TSurveySummary["dropOff"]
): TSurveySummary["summary"] => {
): Promise<TSurveySummary["summary"]> => {
const VALUES_LIMIT = 50;
let summary: TSurveySummary["summary"] = [];
survey.questions.forEach((question, idx) => {
for (const question of survey.questions) {
switch (question.type) {
case TSurveyQuestionTypeEnum.OpenText: {
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
@@ -839,15 +840,17 @@ export const getQuestionWiseSummary = (
});
}
});
const insights = await getInsightsBySurveyIdQuestionId(survey.id, question.id);
summary.push({
type: question.type,
question,
responseCount: values.length,
samples: values.slice(0, VALUES_LIMIT),
insights,
});
values = [];
values;
break;
}
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
@@ -1088,6 +1091,7 @@ export const getQuestionWiseSummary = (
});
const totalResponses = data.clicked + data.dismissed;
const idx = survey.questions.findIndex((q) => q.id === question.id);
const impressions = dropOff[idx].impressions;
summary.push({
@@ -1356,7 +1360,7 @@ export const getQuestionWiseSummary = (
break;
}
}
});
}
survey.hiddenFields?.fieldIds?.forEach((hiddenFieldId) => {
let values: TSurveyQuestionSummaryHiddenFields["samples"] = [];

View File

@@ -49,7 +49,7 @@ export type TQuestion = {
export const questionTypes: TQuestion[] = [
{
id: QuestionId.OpenText,
label: "Free text",
label: "Free Text",
description: "Ask for a text-based answer",
icon: MessageSquareTextIcon,
preset: {
@@ -208,7 +208,7 @@ export const questionTypes: TQuestion[] = [
},
{
id: QuestionId.Cal,
label: "Schedule a meeting",
label: "Schedule a Meeting",
description: "Allow respondents to schedule a meet",
icon: PhoneIcon,
preset: {

View File

@@ -0,0 +1,9 @@
import { z } from "zod";
import { ZId } from "./common";
export const ZDocumentInsight = z.object({
documentId: ZId,
insightId: ZId,
});
export type TDocumentInsight = z.infer<typeof ZDocumentInsight>;

View File

@@ -0,0 +1,29 @@
import { z } from "zod";
import { ZId } from "./common";
export const ZDocumentSentiment = z.enum(["positive", "negative", "neutral"]);
export type TDocumentSentiment = z.infer<typeof ZDocumentSentiment>;
export const ZDocument = z.object({
id: ZId,
createdAt: z.date(),
updatedAt: z.date(),
environmentId: ZId,
responseId: ZId.nullable(),
questionId: ZId.nullable(),
sentiment: ZDocumentSentiment,
text: z.string(),
});
export type TDocument = z.infer<typeof ZDocument>;
export const ZDocumentCreateInput = z.object({
environmentId: ZId,
surveyId: ZId,
responseId: ZId,
questionId: ZId,
text: z.string(),
});
export type TDocumentCreateInput = z.infer<typeof ZDocumentCreateInput>;

View File

@@ -0,0 +1,31 @@
import { z } from "zod";
import { ZId } from "./common";
export const ZInsightCategory = z.enum(["featureRequest", "complaint", "praise"]);
export type TInsightCategory = z.infer<typeof ZInsightCategory>;
export const ZInsight = z.object({
id: ZId,
createdAt: z.date(),
updatedAt: z.date(),
environmentId: ZId,
title: z.string(),
description: z.string(),
category: ZInsightCategory,
_count: z.object({
documentInsights: z.number(),
}),
});
export type TInsight = z.infer<typeof ZInsight>;
export const ZInsightCreateInput = z.object({
environmentId: ZId,
title: z.string(),
description: z.string(),
category: ZInsightCategory,
vector: z.array(z.number()).length(512),
});
export type TInsightCreateInput = z.infer<typeof ZInsightCreateInput>;

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZAttributes } from "../attributes";
import { ZAllowedFileExtension, ZColor, ZId, ZPlacement } from "../common";
import { ZInsight } from "../insights";
import { ZLanguage } from "../product";
import { ZSegment } from "../segment";
import { ZBaseStyling } from "../styling";
@@ -2093,6 +2094,7 @@ export const ZSurveyQuestionSummaryOpenText = z.object({
personAttributes: ZAttributes.nullable(),
})
),
insights: z.array(ZInsight),
});
export type TSurveyQuestionSummaryOpenText = z.infer<typeof ZSurveyQuestionSummaryOpenText>;
@@ -2429,6 +2431,8 @@ export const ZSurveySummary = z.object({
summary: z.array(z.union([ZSurveyQuestionSummary, ZSurveyQuestionSummaryHiddenFields])),
});
export type TSurveySummary = z.infer<typeof ZSurveySummary>;
export const ZSurveyFilterCriteria = z.object({
name: z.string().optional(),
status: z.array(ZSurveyStatus).optional(),
@@ -2467,7 +2471,6 @@ const ZSortOption = z.object({
});
export type TSortOption = z.infer<typeof ZSortOption>;
export type TSurveySummary = z.infer<typeof ZSurveySummary>;
export const ZSurveyRecallItem = z.object({
id: z.string(),

View File

@@ -1,65 +1,52 @@
import { Button } from "../Button";
import * as React from "react";
import { cn } from "@formbricks/lib/cn";
interface CardProps {
connectText?: string;
connectHref?: string;
connectNewTab?: boolean;
docsText?: string;
docsHref?: string;
docsNewTab?: boolean;
label: string;
description: string;
icon?: React.ReactNode;
connected?: boolean;
statusText?: string;
}
export type { CardProps };
export const Card: React.FC<CardProps> = ({
connectText,
connectHref,
connectNewTab,
docsText,
docsHref,
docsNewTab,
label,
description,
icon,
connected,
statusText,
}) => (
<div className="relative rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
{connected != undefined && statusText != undefined && (
<div className="absolute right-4 top-4 flex items-center rounded bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{connected === true ? (
<span className="relative mr-1 flex h-2 w-2">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</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>
)}
{statusText}
</div>
)}
{icon && <div className="mb-6 h-8 w-8">{icon}</div>}
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
<p className="text-xs text-slate-500">{description}</p>
<div className="mt-4 flex space-x-2">
{connectHref && (
<Button href={connectHref} target={connectNewTab ? "_blank" : "_self"} size="sm">
{connectText}
</Button>
)}
{docsHref && (
<Button href={docsHref} target={docsNewTab ? "_blank" : "_self"} size="sm" variant="secondary">
{docsText}
</Button>
)}
</div>
</div>
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("bg-card text-card-foreground rounded-lg border shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-muted-foreground text-sm", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -3,7 +3,7 @@ import { BellRing } from "lucide-react";
import { Card } from "./index";
const meta = {
title: "ui/Card",
title: "ui/IntegrationCard",
component: Card,
tags: ["autodocs"],
parameters: {

View File

@@ -0,0 +1,61 @@
"use client";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TInsight, TInsightCategory } from "@formbricks/types/insights";
interface InsightFilterProps {
insights: TInsight[];
setInsights: (insights: TInsight[]) => void;
}
export const InsightFilter = ({ insights, setInsights }: InsightFilterProps) => {
const [selectedFilter, setSelectedFilter] = useState<string>("all");
const filters = [
{
label: "All",
value: "all",
},
{
label: "Complaint",
value: "complaint",
},
{
label: "Feature Request",
value: "featureRequest",
},
{
label: "Praise",
value: "praise",
},
];
const handleFilterSelect = (filterValue: string) => {
setSelectedFilter(filterValue);
if (filterValue === "all") {
setInsights(insights);
} else {
setInsights(insights.filter((insight) => insight.category === (filterValue as TInsightCategory)));
}
};
return (
<div className="flex gap-1">
{filters.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => handleFilterSelect(filter.value)}
className={cn(
selectedFilter === filter.value
? "bg-slate-800 font-semibold text-white"
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
)}>
{filter.label}
</button>
))}
</div>
);
};

View File

@@ -0,0 +1,30 @@
"use server";
import { z } from "zod";
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
import { getDocumentsByInsightIdSurveyIdQuestionId } from "@formbricks/lib/document/service";
import { getOrganizationIdFromInsightId } from "@formbricks/lib/organization/utils";
import { ZId } from "@formbricks/types/common";
const ZGetDocumentsByInsightIdAction = z.object({
insightId: ZId,
surveyId: ZId,
questionId: ZId,
});
export const getDocumentsByInsightIdSurveyIdQuestionIdAction = authenticatedActionClient
.schema(ZGetDocumentsByInsightIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorization({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromInsightId(parsedInput.insightId),
rules: ["response", "read"],
});
return await getDocumentsByInsightIdSurveyIdQuestionId(
parsedInput.insightId,
parsedInput.surveyId,
parsedInput.questionId
);
});

View File

@@ -0,0 +1,125 @@
"use client";
import { ThumbsDownIcon, ThumbsUpIcon } from "lucide-react";
import { useEffect, useState } from "react";
import Markdown from "react-markdown";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { timeSince } from "@formbricks/lib/time";
import { TDocument } from "@formbricks/types/documents";
import { TInsight } from "@formbricks/types/insights";
import { Badge } from "../Badge";
import { Card, CardContent, CardFooter } from "../Card";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "../Sheet";
import { getDocumentsByInsightIdSurveyIdQuestionIdAction } from "./actions";
interface InsightSheetProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
insight: TInsight | null;
surveyId: string;
questionId: string;
handleFeedback: (feedback: "positive" | "negative") => void;
}
export const InsightSheet = ({
isOpen,
setIsOpen,
insight,
surveyId,
questionId,
handleFeedback,
}: InsightSheetProps) => {
const [documents, setDocuments] = useState<TDocument[]>([]);
useEffect(() => {
if (insight) {
fetchDocuments();
}
async function fetchDocuments() {
if (!insight) {
throw Error("Insight is required to fetch documents");
}
const documentsResponse = await getDocumentsByInsightIdSurveyIdQuestionIdAction({
insightId: insight.id,
surveyId,
questionId,
});
console.log(documentsResponse);
if (!documentsResponse?.data) {
const errorMessage = getFormattedErrorMessage(documentsResponse);
console.error(errorMessage);
return;
}
setDocuments(documentsResponse.data);
}
}, [insight]);
if (!insight) {
return null;
}
const handleFeedbackClick = (feedback: "positive" | "negative") => {
setIsOpen(false);
handleFeedback(feedback);
};
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="Request" type="warning" size="tiny" />
) : insight.category === "praise" ? (
<Badge text="Praise" type="success" size="tiny" />
) : null}
</SheetTitle>
<SheetDescription>{insight.description}</SheetDescription>
</SheetHeader>
<div className="flex flex-1 flex-col space-y-2 overflow-auto pt-4">
{documents.map((document) => (
<Card>
<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">
<p>
Sentiment:{" "}
{document.sentiment === "positive" ? (
<Badge text="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}
</p>
<p>{timeSince(new Date(document.createdAt).toISOString())}</p>
</CardFooter>
</Card>
))}
</div>
<SheetFooter>
<div className="flex items-center gap-2">
<p>Did you find this insight helpful?</p>
<ThumbsUpIcon
className="upvote h-5 w-5 cursor-pointer"
onClick={() => handleFeedbackClick("positive")}
/>
<ThumbsDownIcon
className="downvote h-5 w-5 cursor-pointer"
onClick={() => handleFeedbackClick("negative")}
/>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
</>
);
};

View File

@@ -0,0 +1,65 @@
import { Button } from "../Button";
interface CardProps {
connectText?: string;
connectHref?: string;
connectNewTab?: boolean;
docsText?: string;
docsHref?: string;
docsNewTab?: boolean;
label: string;
description: string;
icon?: React.ReactNode;
connected?: boolean;
statusText?: string;
}
export type { CardProps };
export const Card: React.FC<CardProps> = ({
connectText,
connectHref,
connectNewTab,
docsText,
docsHref,
docsNewTab,
label,
description,
icon,
connected,
statusText,
}) => (
<div className="relative rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
{connected != undefined && statusText != undefined && (
<div className="absolute right-4 top-4 flex items-center rounded bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{connected === true ? (
<span className="relative mr-1 flex h-2 w-2">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</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>
)}
{statusText}
</div>
)}
{icon && <div className="mb-6 h-8 w-8">{icon}</div>}
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
<p className="text-xs text-slate-500">{description}</p>
<div className="mt-4 flex space-x-2">
{connectHref && (
<Button href={connectHref} target={connectNewTab ? "_blank" : "_self"} size="sm">
{connectText}
</Button>
)}
{docsHref && (
<Button href={docsHref} target={docsNewTab ? "_blank" : "_self"} size="sm" variant="secondary">
{docsText}
</Button>
)}
</div>
</div>
);

View File

@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BellRing } from "lucide-react";
import { Card } from "./index";
const meta = {
title: "ui/IntegrationCard",
component: Card,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `The **card** component is used to display a card with a label, description, and optional icon. It can also display a status and buttons for connecting and viewing documentation.`,
},
},
argTypes: {
icon: { control: "text" },
},
},
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
label: "Card Label",
description: "This is the description of the card.",
connectText: "Connect",
connectHref: "#",
connectNewTab: false,
docsText: "Docs",
docsHref: "#",
docsNewTab: false,
connected: true,
statusText: "Connected",
},
};
export const Disconnected: Story = {
args: {
label: "Card Label",
description: "This is the description of the card.",
connectText: "Connect",
connectHref: "#",
connectNewTab: false,
docsText: "Docs",
docsHref: "#",
docsNewTab: false,
connected: false,
statusText: "Disconnected",
},
};
export const WithIcon: Story = {
args: {
label: "Card Label",
description: "This is the description of the card.",
connectText: "Connect",
connectHref: "#",
connectNewTab: false,
docsText: "Docs",
docsHref: "#",
docsNewTab: false,
connected: true,
statusText: "Connected",
icon: <BellRing />,
},
};

View File

@@ -2,7 +2,13 @@ import Link from "next/link";
import { cn } from "@formbricks/lib/cn";
interface SecondaryNavbarProps {
navigation: { id: string; label: string; href: string; icon?: React.ReactNode; hidden?: boolean }[];
navigation: {
id: string;
label: string;
href?: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
hidden?: boolean;
}[];
activeId: string;
loading?: boolean;
}
@@ -15,37 +21,70 @@ export const SecondaryNavigation = ({ navigation, activeId, loading, ...props }:
{loading ? (
<>
{navigation.map((navElem) => (
<span
key={navElem.id}
aria-disabled="true"
className={cn(
navElem.id === activeId
? "border-slate600-dark border-b-2 font-semibold text-slate-900"
: "border-transparent text-slate-500",
"flex h-full items-center border-b-2 px-3 text-sm font-medium",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</span>
<div className="group flex h-full flex-col">
<div
key={navElem.id}
aria-disabled="true"
className={cn(
navElem.id === activeId ? "font-semibold text-slate-900" : "text-slate-500",
"flex h-full items-center px-3 text-sm font-medium",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</div>
<div
className={cn(
"bottom-0 mt-auto h-[2px] w-full rounded-t-lg transition-all duration-150 ease-in-out",
navElem.id === activeId ? "bg-slate-300" : "bg-transparent group-hover:bg-slate-300",
navElem.hidden && "hidden"
)}
/>
</div>
))}
</>
) : (
<>
{navigation.map((navElem) => (
<Link
key={navElem.id}
href={navElem.href}
className={cn(
navElem.id === activeId
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "border-transparent text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700",
"flex h-full items-center border-b-2 px-3 text-sm font-medium",
navElem.hidden && "hidden"
<div className="group flex h-full flex-col">
{navElem.href ? (
<Link
key={navElem.id}
href={navElem.href}
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
className={cn(
navElem.id === activeId
? "font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"flex h-full items-center px-3 text-sm font-medium",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</Link>
) : (
<button
key={navElem.id}
{...(navElem.onClick ? { onClick: navElem.onClick } : {})}
className={cn(
navElem.id === activeId
? "font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700",
"grow items-center px-3 text-sm font-medium transition-all duration-150 ease-in-out",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</button>
)}
aria-current={navElem.id === activeId ? "page" : undefined}>
{navElem.label}
</Link>
<div
className={cn(
"bottom-0 mt-auto h-[2px] w-full rounded-t-lg transition-all duration-150 ease-in-out",
navElem.id === activeId ? "bg-brand-dark" : "bg-transparent group-hover:bg-slate-300",
navElem.hidden && "hidden"
)}
/>
</div>
))}
</>
)}

View File

@@ -0,0 +1,119 @@
"use client";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@formbricks/lib/cn";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
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",
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",
},
},
defaultVariants: {
side: "right",
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
);
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-foreground text-lg font-semibold", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -4,7 +4,7 @@ module.exports = {
"./app/**/*.{js,ts,jsx,tsx}", // Note the addition of the `app` directory.
"./pages/**/*.{js,ts,jsx,tsx}",
// include packages if not transpiling
"../../packages/ui/**/*.{ts,tsx}",
"../../packages/ui/components/**/*.{ts,tsx}",
],
theme: {
extend: {

1244
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,12 @@
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],
"env": [
"AI_AZURE_EMBEDDINGS_API_KEY",
"AI_AZURE_LLM_API_KEY",
"AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID",
"AI_AZURE_EMBEDDINGS_RESSOURCE_NAME",
"AI_AZURE_LLM_DEPLOYMENT_ID",
"AI_AZURE_LLM_RESSOURCE_NAME",
"AIRTABLE_CLIENT_ID",
"ASSET_PREFIX_URL",
"AZUREAD_CLIENT_ID",