mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-13 18:19:24 -06:00
feat: Add welcome card (#1073)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com> Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com> Co-authored-by: Matthias Nannt <mail@matthiasnannt.com> Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
@@ -23,6 +23,8 @@ import {
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { isLight } from "@/app/lib/utils";
|
||||
import { CornerDownLeft } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface EmailTabProps {
|
||||
survey: TSurvey;
|
||||
@@ -138,88 +140,129 @@ const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => {
|
||||
const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => {
|
||||
const url = preview ? `${surveyUrl}?preview=true` : surveyUrl;
|
||||
const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`;
|
||||
if (survey?.welcomeCard?.enabled) {
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
{survey?.welcomeCard?.fileUrl && (
|
||||
<Image
|
||||
src={survey?.welcomeCard?.fileUrl}
|
||||
className="mb-4"
|
||||
width={75}
|
||||
height={75}
|
||||
alt="Company Logo"
|
||||
/>
|
||||
)}
|
||||
|
||||
const firstQuestion = survey.questions[0];
|
||||
switch (firstQuestion.type) {
|
||||
case QuestionType.OpenText:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{survey?.welcomeCard?.headline}
|
||||
</Text>
|
||||
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text
|
||||
className="m-0 p-0"
|
||||
dangerouslySetInnerHTML={{ __html: survey?.welcomeCard?.html || "" }}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
|
||||
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
|
||||
</Container>
|
||||
<Container className="mx-0 mt-4 flex max-w-none justify-end">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
Reject
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
|
||||
className={cn(
|
||||
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
Accept
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.NPS:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<Container className=" mx-0 mt-4 max-w-none">
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}start=clicked`}
|
||||
className={cn(
|
||||
"bg-brand-color inline-flex cursor-pointer appearance-none gap-4 rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
{survey?.welcomeCard?.buttonLabel || "Next"}
|
||||
</EmailButton>
|
||||
<Text className="ml-4 inline-flex items-center text-slate-600">
|
||||
Press Enter
|
||||
<CornerDownLeft className="h-3" />
|
||||
</Text>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
} else {
|
||||
const firstQuestion = survey.questions[0];
|
||||
switch (firstQuestion.type) {
|
||||
case QuestionType.OpenText:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mt-4 block h-20 w-full rounded-lg border border-solid border-gray-200 bg-slate-50" />
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.Consent:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex w-max flex-col">
|
||||
<Section className="block overflow-hidden rounded-md border border-gray-200">
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
|
||||
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
|
||||
{i}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block w-max p-0 text-right">{firstQuestion.upperLabel}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Container className="m-0 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
{/* {!firstQuestion.required && (
|
||||
|
||||
<Container className="m-0 mt-4 block w-full max-w-none rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 font-medium text-slate-800">
|
||||
<Text className="m-0 inline-block">{firstQuestion.label}</Text>
|
||||
</Container>
|
||||
<Container className="mx-0 mt-4 flex max-w-none justify-end">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
Reject
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=accepted`}
|
||||
className={cn(
|
||||
"bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
Accept
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.NPS:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex w-max flex-col">
|
||||
<Section className="block overflow-hidden rounded-md border border-gray-200">
|
||||
{Array.from({ length: 11 }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i}`}
|
||||
className="m-0 inline-flex h-10 w-10 items-center justify-center border-gray-200 p-0 text-slate-800">
|
||||
{i}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="mt-2 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block w-max p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block w-max p-0 text-right">
|
||||
{firstQuestion.upperLabel}
|
||||
</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
{/* {!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className={cn(
|
||||
@@ -230,83 +273,83 @@ const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string
|
||||
</EmailButton>
|
||||
)} */}
|
||||
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
{firstQuestion.dismissButtonLabel || "Skip"}
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
|
||||
className={cn(
|
||||
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
{firstQuestion.buttonLabel}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.CTA:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex">
|
||||
<Section
|
||||
className={cn("inline-block w-max overflow-hidden rounded-md", {
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
})}>
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
className={cn(
|
||||
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
|
||||
{
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
}
|
||||
)}>
|
||||
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
|
||||
{firstQuestion.scale === "number" && i + 1}
|
||||
{firstQuestion.scale === "star" && <Text className="text-3xl">⭐</Text>}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<Container className="mt-2 text-sm font-normal leading-6 text-slate-500">
|
||||
<Text className="m-0 p-0" dangerouslySetInnerHTML={{ __html: firstQuestion.html || "" }}></Text>
|
||||
</Container>
|
||||
{/* {!firstQuestion.required && (
|
||||
|
||||
<Container className="mx-0 mt-4 max-w-none">
|
||||
{!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className="inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium text-black">
|
||||
{firstQuestion.dismissButtonLabel || "Skip"}
|
||||
</EmailButton>
|
||||
)}
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=clicked`}
|
||||
className={cn(
|
||||
"bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
)}>
|
||||
{firstQuestion.buttonLabel}
|
||||
</EmailButton>
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.Rating:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Section>
|
||||
<Text className="m-0 block text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 mt-4 flex">
|
||||
<Section
|
||||
className={cn("inline-block w-max overflow-hidden rounded-md", {
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
})}>
|
||||
{Array.from({ length: firstQuestion.range }, (_, i) => (
|
||||
<EmailButton
|
||||
key={i}
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${i + 1}`}
|
||||
className={cn(
|
||||
"m-0 inline-flex h-16 w-16 items-center justify-center p-0 text-slate-800",
|
||||
{
|
||||
["border border-solid border-gray-200"]: firstQuestion.scale === "number",
|
||||
}
|
||||
)}>
|
||||
{firstQuestion.scale === "smiley" && <Text className="text-3xl">😃</Text>}
|
||||
{firstQuestion.scale === "number" && i + 1}
|
||||
{firstQuestion.scale === "star" && <Text className="text-3xl">⭐</Text>}
|
||||
</EmailButton>
|
||||
))}
|
||||
</Section>
|
||||
<Section className="m-0 px-1.5 text-xs leading-6 text-slate-500">
|
||||
<Row>
|
||||
<Column>
|
||||
<Text className="m-0 inline-block p-0">{firstQuestion.lowerLabel}</Text>
|
||||
</Column>
|
||||
<Column className="text-right">
|
||||
<Text className="m-0 inline-block p-0 text-right">{firstQuestion.upperLabel}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
</Container>
|
||||
{/* {!firstQuestion.required && (
|
||||
<EmailButton
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=dismissed`}
|
||||
className={cn(
|
||||
@@ -316,55 +359,56 @@ const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string
|
||||
{firstQuestion.buttonLabel || "Skip"}
|
||||
</EmailButton>
|
||||
)} */}
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.MultipleChoiceMulti:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices.map((choice) => (
|
||||
<Section
|
||||
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
|
||||
key={choice.id}>
|
||||
{choice.label}
|
||||
</Section>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.MultipleChoiceSingle:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices
|
||||
.filter((choice) => choice.id !== "other")
|
||||
.map((choice) => (
|
||||
<Link
|
||||
key={choice.id}
|
||||
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
|
||||
<EmailFooter />
|
||||
</Section>
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.MultipleChoiceMulti:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices.map((choice) => (
|
||||
<Section
|
||||
className="mt-2 block w-full rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800"
|
||||
key={choice.id}>
|
||||
{choice.label}
|
||||
</Link>
|
||||
</Section>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case QuestionType.MultipleChoiceSingle:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 mb-2 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Container className="mx-0 max-w-none">
|
||||
{firstQuestion.choices
|
||||
.filter((choice) => choice.id !== "other")
|
||||
.map((choice) => (
|
||||
<Link
|
||||
key={choice.id}
|
||||
className="mt-2 block rounded-lg border border-solid border-gray-200 bg-slate-50 p-4 text-slate-800 hover:bg-slate-100"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.label}`}>
|
||||
{choice.label}
|
||||
</Link>
|
||||
))}
|
||||
</Container>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { md } from "@formbricks/lib/markdownIt";
|
||||
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
|
||||
import { Editor } from "@formbricks/ui/Editor";
|
||||
import FileInput from "@formbricks/ui/FileInput";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface EditWelcomeCardProps {
|
||||
localSurvey: TSurveyWithAnalytics;
|
||||
setLocalSurvey: (survey: TSurveyWithAnalytics) => void;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
}
|
||||
|
||||
export default function EditWelcomeCard({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
setActiveQuestionId,
|
||||
activeQuestionId,
|
||||
}: EditWelcomeCardProps) {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
const path = usePathname();
|
||||
const environmentId = path?.split("/environments/")[1]?.split("/")[0];
|
||||
// const [open, setOpen] = useState(false);
|
||||
let open = activeQuestionId == "start";
|
||||
const setOpen = (e) => {
|
||||
if (e) {
|
||||
setActiveQuestionId("start");
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
welcomeCard: {
|
||||
...localSurvey.welcomeCard,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg " : "scale-97 shadow-md",
|
||||
"flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"flex w-10 items-center justify-center rounded-l-lg hover:bg-slate-600 group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>✋</p>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Welcome Card</p>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{localSurvey?.welcomeCard?.enabled ? "Shown" : "Hidden"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="welcome-toggle">Enabled</Label>
|
||||
|
||||
<Switch
|
||||
id="welcome-toggle"
|
||||
checked={localSurvey?.welcomeCard?.enabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateSurvey({ enabled: !localSurvey.welcomeCard?.enabled });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<form>
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="companyLogo">Company Logo</Label>
|
||||
</div>
|
||||
<div className="mt-3 flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string) => {
|
||||
updateSurvey({ fileUrl: url });
|
||||
}}
|
||||
fileUrl={localSurvey?.welcomeCard?.fileUrl}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="headline">Headline</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
id="headline"
|
||||
name="headline"
|
||||
defaultValue={localSurvey?.welcomeCard?.headline}
|
||||
onChange={(e) => {
|
||||
updateSurvey({ headline: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="subheader">Welcome Message</Label>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
getText={() =>
|
||||
md.render(
|
||||
localSurvey?.welcomeCard?.html || "Thanks for providing your feedback - let's go!"
|
||||
)
|
||||
}
|
||||
setText={(value: string) => {
|
||||
updateSurvey({ html: value });
|
||||
}}
|
||||
excludedToolbarItems={["blockType"]}
|
||||
disableLists
|
||||
firstRender={firstRender}
|
||||
setFirstRender={setFirstRender}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-between gap-8">
|
||||
<div className="flex w-full space-x-2">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="buttonLabel">Button Label</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
id="buttonLabel"
|
||||
name="buttonLabel"
|
||||
value={localSurvey?.welcomeCard?.buttonLabel || "Next"}
|
||||
onChange={(e) => updateSurvey({ buttonLabel: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="mt-8 flex items-center">
|
||||
<div className="mr-2">
|
||||
<Switch
|
||||
id="timeToFinish"
|
||||
name="timeToFinish"
|
||||
checked={localSurvey?.welcomeCard?.timeToFinish}
|
||||
onCheckedChange={() =>
|
||||
updateSurvey({ timeToFinish: !localSurvey.welcomeCard.timeToFinish })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-column">
|
||||
<Label htmlFor="timeToFinish" className="">
|
||||
Time to Finish
|
||||
</Label>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Display an estimate of completion time for survey
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</form>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -299,17 +299,19 @@ export default function QuestionCard({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="required-toggle">Required</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
checked={question.required}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuestion(questionIdx, { required: !question.required });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
<div className="my-4 flex items-center justify-end space-x-2">
|
||||
<Label htmlFor="required-toggle">Required</Label>
|
||||
<Switch
|
||||
id="required-toggle"
|
||||
checked={question.required}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateQuestion(questionIdx, { required: !question.required });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DragDropContext } from "react-beautiful-dnd";
|
||||
import toast from "react-hot-toast";
|
||||
import AddQuestionButton from "./AddQuestionButton";
|
||||
import EditThankYouCard from "./EditThankYouCard";
|
||||
import EditWelcomeCard from "./EditWelcomeCard";
|
||||
import QuestionCard from "./QuestionCard";
|
||||
import { StrictModeDroppable } from "./StrictModeDroppable";
|
||||
import { validateQuestion } from "./Validation";
|
||||
@@ -132,6 +133,7 @@ export default function QuestionsView({
|
||||
|
||||
const duplicateQuestion = (questionIdx: number) => {
|
||||
const questionToDuplicate = JSON.parse(JSON.stringify(localSurvey.questions[questionIdx]));
|
||||
|
||||
const newQuestionId = createId();
|
||||
|
||||
// create a copy of the question with a new id
|
||||
@@ -156,7 +158,9 @@ export default function QuestionsView({
|
||||
if (backButtonLabel) {
|
||||
question.backButtonLabel = backButtonLabel;
|
||||
}
|
||||
|
||||
updatedSurvey.questions.push({ ...question, isDraft: true });
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
setActiveQuestionId(question.id);
|
||||
internalQuestionIdMap[question.id] = createId();
|
||||
@@ -174,7 +178,6 @@ export default function QuestionsView({
|
||||
};
|
||||
|
||||
const moveQuestion = (questionIndex: number, up: boolean) => {
|
||||
// move the question up or down in the localSurvey questions array
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
|
||||
const destinationIndex = up ? questionIndex - 1 : questionIndex + 1;
|
||||
@@ -185,6 +188,14 @@ export default function QuestionsView({
|
||||
|
||||
return (
|
||||
<div className="mt-12 px-5 py-4">
|
||||
<div className="mb-5 flex flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
</div>
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div className="mb-5 grid grid-cols-1 gap-5 ">
|
||||
<StrictModeDroppable droppableId="questionsList">
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function PreviewSurvey({
|
||||
setPreviewMode(storePreviewMode);
|
||||
}, 10);
|
||||
|
||||
setActiveQuestionId(survey.questions[0].id);
|
||||
setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QuestionType } from "@formbricks/types/questions";
|
||||
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/v1/surveys";
|
||||
import { TSurvey, TSurveyHiddenFields, TSurveyWelcomeCard } from "@formbricks/types/v1/surveys";
|
||||
import { TTemplate } from "@formbricks/types/v1/templates";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
@@ -14,6 +14,13 @@ const hiddenFieldsDefault: TSurveyHiddenFields = {
|
||||
fieldIds: [],
|
||||
};
|
||||
|
||||
const welcomeCardDefault: TSurveyWelcomeCard = {
|
||||
enabled: true,
|
||||
headline: "Welcome!",
|
||||
html: "Thanks for providing your feedback - let's go!",
|
||||
timeToFinish: false,
|
||||
};
|
||||
|
||||
export const templates: TTemplate[] = [
|
||||
{
|
||||
name: "Product Market Fit (Superhuman)",
|
||||
@@ -22,6 +29,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Measure PMF by assessing how disappointed users would be if your product disappeared.",
|
||||
preset: {
|
||||
name: "Product Market Fit (Superhuman)",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -120,6 +128,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Learn more about who signed up to your product and why.",
|
||||
preset: {
|
||||
name: "Onboarding Segmentation",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -224,6 +233,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out why people cancel their subscriptions. These insights are pure gold!",
|
||||
preset: {
|
||||
name: "Churn Survey",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -312,6 +322,7 @@ export const templates: TTemplate[] = [
|
||||
"The EAS is a riff off the NPS but asking for actual past behaviour instead of lofty intentions.",
|
||||
preset: {
|
||||
name: "Earned Advocacy Score (EAS)",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -376,6 +387,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out why people stopped their trial. These insights help you improve your funnel.",
|
||||
preset: {
|
||||
name: "Improve Trial Conversion",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -480,6 +492,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Invite users who love your product to review it publicly.",
|
||||
preset: {
|
||||
name: "Review Prompt",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -527,6 +540,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Invite a specific subset of your users to schedule an interview with your product team.",
|
||||
preset: {
|
||||
name: "Interview Prompt",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -550,6 +564,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Identify weaknesses in your onboarding flow to increase user activation.",
|
||||
preset: {
|
||||
name: "Onboarding Drop-Off Reasons",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -646,6 +661,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out what users like and don't like about your product or offering.",
|
||||
preset: {
|
||||
name: "Uncover Strengths & Weaknesses",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -695,6 +711,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Measure PMF by assessing how disappointed users would be if your product disappeared.",
|
||||
preset: {
|
||||
name: "Product Market Fit Survey (Short)",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -739,6 +756,7 @@ export const templates: TTemplate[] = [
|
||||
description: "How did you first hear about us?",
|
||||
preset: {
|
||||
name: "Marketing Attribution",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -783,6 +801,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out what goes through peoples minds when changing their subscriptions.",
|
||||
preset: {
|
||||
name: "Changing subscription experience",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -849,6 +868,7 @@ export const templates: TTemplate[] = [
|
||||
"Better understand if your messaging creates the right expectations of the value your product provides.",
|
||||
preset: {
|
||||
name: "Identify Customer Goals",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -888,6 +908,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Follow up with users who just used a specific feature.",
|
||||
preset: {
|
||||
name: "Feature Chaser",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -926,6 +947,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Follow up with users who ran into one of your Fake Door experiments.",
|
||||
preset: {
|
||||
name: "Fake Door Follow-Up",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -975,6 +997,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Give your users the chance to seamlessly share what's on their minds.",
|
||||
preset: {
|
||||
name: "Feedback Box",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1038,6 +1061,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Evaluate how easily users can add integrations to your product. Find blind spots.",
|
||||
preset: {
|
||||
name: "Integration Usage Survey",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: "s6ss6znzxdwjod1hv16fow4w",
|
||||
@@ -1080,6 +1104,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out which integrations your users would like to see next.",
|
||||
preset: {
|
||||
name: "New Integration Survey",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1120,6 +1145,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Measure how clear each page of your developer documentation is.",
|
||||
preset: {
|
||||
name: "{{productName}} Docs Feedback",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1165,6 +1191,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Measure the Net Promoter Score of your product.",
|
||||
preset: {
|
||||
name: "{{productName}} NPS",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1194,6 +1221,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Measure the Customer Satisfaction Score of your product.",
|
||||
preset: {
|
||||
name: "{{productName}} CSAT",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1237,6 +1265,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out how much time your product saves your user. Use it to upsell.",
|
||||
preset: {
|
||||
name: "Identify upsell opportunities",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1277,6 +1306,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Identify features your users need most and least.",
|
||||
preset: {
|
||||
name: "Feature Prioritization",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1328,6 +1358,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Evaluate the satisfaction of specific features of your product.",
|
||||
preset: {
|
||||
name: "Gauge Feature Satisfaction",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1359,6 +1390,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Identify users dropping off your marketing site. Improve your messaging.",
|
||||
preset: {
|
||||
name: "Marketing Site Clarity",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1410,6 +1442,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Determine how easy it is to use a feature.",
|
||||
preset: {
|
||||
name: "Customer Effort Score (CES)",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1444,6 +1477,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Let customers rate the checkout experience to tweak conversion.",
|
||||
preset: {
|
||||
name: "Rate Checkout Experience",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1487,6 +1521,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Measure how relevant your search results are.",
|
||||
preset: {
|
||||
name: "Measure Search Experience",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1530,6 +1565,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Measure if your content marketing pieces hit right.",
|
||||
preset: {
|
||||
name: "Evaluate Content Quality",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1573,6 +1609,7 @@ export const templates: TTemplate[] = [
|
||||
description: "See if people get their 'Job To Be Done' done. Successful people are better customers.",
|
||||
preset: {
|
||||
name: "Measure Task Accomplishment",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1648,6 +1685,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Offer a discount to gather insights about sign up barriers.",
|
||||
preset: {
|
||||
name: "{{productName}} Sign Up Barriers",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1770,6 +1808,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Identify the ONE thing your users want the most and build it.",
|
||||
preset: {
|
||||
name: "{{productName}} Roadmap Input",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1804,6 +1843,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out how close your visitors are to buy or subscribe.",
|
||||
preset: {
|
||||
name: "Purchase Intention Survey",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1855,6 +1895,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Find out how your subscribers like your newsletter content.",
|
||||
preset: {
|
||||
name: "Improve Newsletter Content",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1907,6 +1948,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Survey users about product or feature ideas. Get feedback rapidly.",
|
||||
preset: {
|
||||
name: "Evaluate a Product Idea",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -2006,6 +2048,7 @@ export const templates: TTemplate[] = [
|
||||
description: "Identify reasons for low engagement to improve user adoption.",
|
||||
preset: {
|
||||
name: "Reasons for Low Engagement",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: "aq9dafe9nxe0kpm67b1os2z9",
|
||||
@@ -2105,7 +2148,9 @@ export const templates: TTemplate[] = [
|
||||
description: "X",
|
||||
preset: {
|
||||
name: "X",
|
||||
questions: [
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
|
||||
{
|
||||
id: createId(),
|
||||
type: "X",
|
||||
@@ -2126,6 +2171,7 @@ export const customSurvey: TTemplate = {
|
||||
description: "Create a survey without template.",
|
||||
preset: {
|
||||
name: "New Survey",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -2156,6 +2202,7 @@ export const minimalSurvey: TSurvey = {
|
||||
triggers: [],
|
||||
redirectUrl: null,
|
||||
recontactDays: null,
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [],
|
||||
thankYouCard: {
|
||||
enabled: false,
|
||||
|
||||
@@ -107,6 +107,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
|
||||
},
|
||||
},
|
||||
thankYouCard: true,
|
||||
welcomeCard: true,
|
||||
autoClose: true,
|
||||
delay: true,
|
||||
},
|
||||
@@ -182,6 +183,7 @@ export const getSettings = async (environmentId: string, personId: string): Prom
|
||||
questions: JSON.parse(JSON.stringify(survey.questions)),
|
||||
triggers: survey.triggers,
|
||||
thankYouCard: JSON.parse(JSON.stringify(survey.thankYouCard)),
|
||||
welcomeCard: JSON.parse(JSON.stringify(survey.welcomeCard)),
|
||||
autoClose: survey.autoClose,
|
||||
delay: survey.delay,
|
||||
};
|
||||
|
||||
@@ -41,7 +41,9 @@ export default function LinkSurvey({
|
||||
const isPreview = searchParams?.get("preview") === "true";
|
||||
// pass in the responseId if the survey is a single use survey, ensures survey state is updated with the responseId
|
||||
const [surveyState, setSurveyState] = useState(new SurveyState(survey.id, singleUseId, responseId));
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string>(survey.questions[0].id);
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string>(
|
||||
survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id
|
||||
);
|
||||
const prefillResponseData: TResponseData | undefined = prefillAnswer
|
||||
? getPrefillResponseData(survey.questions[0], survey, prefillAnswer)
|
||||
: undefined;
|
||||
@@ -122,7 +124,9 @@ export default function LinkSurvey({
|
||||
Survey Preview 👀
|
||||
<button
|
||||
className="flex items-center rounded-full bg-slate-500 px-3 py-1 hover:bg-slate-400"
|
||||
onClick={() => setActiveQuestionId(survey.questions[0].id)}>
|
||||
onClick={() =>
|
||||
setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
|
||||
}>
|
||||
Restart <ArrowPathIcon className="ml-2 h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,14 @@ const nextConfig = {
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "app.formbricks.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TActionClassNoCodeConfig } from "@formbricks/types/v1/actionClasses";
|
||||
import { TIntegrationConfig } from "@formbricks/types/v1/integrations";
|
||||
import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/v1/responses";
|
||||
import {
|
||||
TSurveyWelcomeCard,
|
||||
TSurveyClosedMessage,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyProductOverwrites,
|
||||
@@ -20,6 +21,7 @@ declare global {
|
||||
export type ResponseData = TResponseData;
|
||||
export type ResponseMeta = TResponseMeta;
|
||||
export type ResponsePersonAttributes = TResponsePersonAttributes;
|
||||
export type welcomeCard = TSurveyWelcomeCard;
|
||||
export type SurveyQuestions = TSurveyQuestions;
|
||||
export type SurveyThankYouCard = TSurveyThankYouCard;
|
||||
export type SurveyHiddenFields = TSurveyHiddenFields;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "welcomeCard" JSONB NOT NULL DEFAULT '{"enabled": false}';
|
||||
@@ -234,6 +234,9 @@ model Survey {
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
status SurveyStatus @default(draft)
|
||||
/// @zod.custom(imports.ZSurveyWelcomeCard)
|
||||
/// [SurveyWelcomeCard]
|
||||
welcomeCard Json @default("{\"enabled\": false}")
|
||||
/// @zod.custom(imports.ZSurveyQuestions)
|
||||
/// [SurveyQuestions]
|
||||
questions Json @default("[]")
|
||||
|
||||
@@ -7,6 +7,7 @@ export { ZIntegrationConfig } from "@formbricks/types/v1/integrations";
|
||||
export { ZResponseData, ZResponsePersonAttributes, ZResponseMeta } from "@formbricks/types/v1/responses";
|
||||
|
||||
export {
|
||||
ZSurveyWelcomeCard,
|
||||
ZSurveyQuestions,
|
||||
ZSurveyThankYouCard,
|
||||
ZSurveyHiddenFields,
|
||||
|
||||
@@ -47,13 +47,10 @@ type TransformPersonInput = {
|
||||
};
|
||||
|
||||
export const transformPrismaPerson = (person: TransformPersonInput): TPerson => {
|
||||
const attributes = person.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
);
|
||||
const attributes = person.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeClass.name] = attr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string | number>);
|
||||
|
||||
return {
|
||||
id: person.id,
|
||||
|
||||
@@ -36,6 +36,7 @@ export const selectSurvey = {
|
||||
type: true,
|
||||
environmentId: true,
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
thankYouCard: true,
|
||||
hiddenFields: true,
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function CTAQuestion({
|
||||
</button>
|
||||
)}
|
||||
<SubmitButton
|
||||
question={question}
|
||||
buttonLabel={question.buttonLabel}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
focus={true}
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function ConsentQuestion({
|
||||
<SubmitButton
|
||||
tabIndex={2}
|
||||
brandColor={brandColor}
|
||||
question={question}
|
||||
buttonLabel={question.buttonLabel}
|
||||
isLastQuestion={isLastQuestion}
|
||||
onClick={() => {}}
|
||||
/>
|
||||
|
||||
@@ -221,7 +221,7 @@ export default function MultipleChoiceSingleQuestion({
|
||||
<div></div>
|
||||
<SubmitButton
|
||||
tabIndex={questionChoices.length + 2}
|
||||
question={question}
|
||||
buttonLabel={question.buttonLabel}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
onClick={() => {}}
|
||||
|
||||
@@ -181,7 +181,7 @@ export default function MultipleChoiceSingleQuestion({
|
||||
<div></div>
|
||||
<SubmitButton
|
||||
tabIndex={questionChoices.length + 2}
|
||||
question={question}
|
||||
buttonLabel={question.buttonLabel}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
onClick={() => {}}
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function NPSQuestion({
|
||||
{!question.required && (
|
||||
<SubmitButton
|
||||
tabIndex={12}
|
||||
question={question}
|
||||
buttonLabel={question.buttonLabel}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
onClick={() => {}}
|
||||
|
||||
@@ -105,7 +105,7 @@ export default function OpenTextQuestion({
|
||||
)}
|
||||
<div></div>
|
||||
<SubmitButton
|
||||
question={question}
|
||||
buttonLabel={question.buttonLabel}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
onClick={() => {}}
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function RatingQuestion({
|
||||
{(!question.required || value) && (
|
||||
<SubmitButton
|
||||
tabIndex={question.range + 1}
|
||||
question={question}
|
||||
buttonLabel={question.buttonLabel}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
onClick={() => {}}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback } from "preact/hooks";
|
||||
import { cn } from "../../../lib/cn";
|
||||
import { isLight } from "../lib/utils";
|
||||
import { TSurveyQuestion } from "../../../types/v1/surveys";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
question: TSurveyQuestion;
|
||||
buttonLabel: string | undefined;
|
||||
isLastQuestion: boolean;
|
||||
brandColor: string;
|
||||
onClick: () => void;
|
||||
@@ -14,7 +13,7 @@ interface SubmitButtonProps {
|
||||
}
|
||||
|
||||
function SubmitButton({
|
||||
question,
|
||||
buttonLabel,
|
||||
isLastQuestion,
|
||||
brandColor,
|
||||
onClick,
|
||||
@@ -42,7 +41,7 @@ function SubmitButton({
|
||||
)}
|
||||
style={{ backgroundColor: brandColor }}
|
||||
onClick={onClick}>
|
||||
{question.buttonLabel || (isLastQuestion ? "Finish" : "Next")}
|
||||
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import FormbricksSignature from "./FormbricksSignature";
|
||||
import ProgressBar from "./ProgressBar";
|
||||
import QuestionConditional from "./QuestionConditional";
|
||||
import ThankYouCard from "./ThankYouCard";
|
||||
import WelcomeCard from "./WelcomeCard";
|
||||
|
||||
export function Survey({
|
||||
survey,
|
||||
@@ -22,7 +23,9 @@ export function Survey({
|
||||
isRedirectDisabled = false,
|
||||
prefillResponseData,
|
||||
}: SurveyBaseProps) {
|
||||
const [questionId, setQuestionId] = useState(activeQuestionId || survey.questions[0]?.id);
|
||||
const [questionId, setQuestionId] = useState(
|
||||
activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
|
||||
);
|
||||
const [loadingElement, setLoadingElement] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [responseData, setResponseData] = useState<TResponseData>({});
|
||||
@@ -31,7 +34,7 @@ export function Survey({
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setQuestionId(activeQuestionId || survey.questions[0].id);
|
||||
setQuestionId(activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id));
|
||||
}, [activeQuestionId, survey.questions]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,11 +51,14 @@ export function Survey({
|
||||
onSubmit(prefillResponseData);
|
||||
}
|
||||
}, []);
|
||||
|
||||
function getNextQuestionId(data: TResponseData): string {
|
||||
const questions = survey.questions;
|
||||
const responseValue = data[questionId];
|
||||
|
||||
if (questionId === "start") {
|
||||
return questions[0]?.id || "end";
|
||||
}
|
||||
|
||||
if (currentQuestionIndex === -1) throw new Error("Question not found");
|
||||
|
||||
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
|
||||
@@ -109,7 +115,17 @@ export function Survey({
|
||||
<AutoCloseWrapper survey={survey} brandColor={brandColor} onClose={onClose}>
|
||||
<div className="flex h-full w-full flex-col justify-between bg-white px-6 pb-3 pt-6">
|
||||
<div ref={contentRef} className={cn(loadingElement ? "animate-pulse opacity-60" : "", "my-auto")}>
|
||||
{questionId === "end" && survey.thankYouCard.enabled ? (
|
||||
{questionId === "start" && survey.welcomeCard.enabled ? (
|
||||
<WelcomeCard
|
||||
headline={survey.welcomeCard.headline}
|
||||
html={survey.welcomeCard.html}
|
||||
fileUrl={survey.welcomeCard.fileUrl}
|
||||
buttonLabel={survey.welcomeCard.buttonLabel}
|
||||
timeToFinish={survey.welcomeCard.timeToFinish}
|
||||
brandColor={brandColor}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
) : questionId === "end" && survey.thankYouCard.enabled ? (
|
||||
<ThankYouCard
|
||||
headline={survey.thankYouCard.headline}
|
||||
subheader={survey.thankYouCard.subheader}
|
||||
|
||||
51
packages/surveys/src/components/WelcomeCard.tsx
Normal file
51
packages/surveys/src/components/WelcomeCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import Headline from "./Headline";
|
||||
import HtmlBody from "./HtmlBody";
|
||||
import SubmitButton from "./SubmitButton";
|
||||
|
||||
interface WelcomeCardProps {
|
||||
headline?: string;
|
||||
html?: string;
|
||||
fileUrl?: string;
|
||||
buttonLabel?: string;
|
||||
timeToFinish?: boolean;
|
||||
brandColor: string;
|
||||
onSubmit: (data: { [x: string]: any }) => void;
|
||||
}
|
||||
|
||||
export default function WelcomeCard({
|
||||
headline,
|
||||
html,
|
||||
fileUrl,
|
||||
buttonLabel,
|
||||
timeToFinish,
|
||||
brandColor,
|
||||
onSubmit,
|
||||
}: WelcomeCardProps) {
|
||||
return (
|
||||
<div>
|
||||
{fileUrl && (
|
||||
<img src={fileUrl} className="mb-8 max-h-96 w-1/3 rounded-lg object-contain" alt="Company Logo" />
|
||||
)}
|
||||
|
||||
<Headline headline={headline} questionId="welcomeCard" />
|
||||
<HtmlBody htmlString={html} questionId="welcomeCard" />
|
||||
|
||||
<div className="mt-10 flex w-full justify-between">
|
||||
<div className="flex w-full justify-start gap-4">
|
||||
<SubmitButton
|
||||
buttonLabel={buttonLabel}
|
||||
isLastQuestion={false}
|
||||
brandColor={brandColor}
|
||||
focus={true}
|
||||
onClick={() => {
|
||||
onSubmit({ ["welcomeCard"]: "clicked" });
|
||||
}}
|
||||
type="button"
|
||||
/>
|
||||
<div className="flex items-center text-xs text-slate-600">Press Enter ↵</div>
|
||||
</div>
|
||||
</div>
|
||||
{timeToFinish && <></>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Question } from "./questions";
|
||||
import { ThankYouCard } from "./surveys";
|
||||
import { WelcomeCard } from "./surveys";
|
||||
|
||||
export interface ResponseCreateRequest {
|
||||
surveyId: string;
|
||||
@@ -79,6 +80,7 @@ export interface Person {
|
||||
|
||||
export interface Survey {
|
||||
id: string;
|
||||
welcomeCard: WelcomeCard;
|
||||
questions: Question[];
|
||||
triggers: Trigger[];
|
||||
thankYouCard: ThankYouCard;
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface CTALogic extends LogicBase {
|
||||
condition: "clicked" | "skipped" | undefined;
|
||||
value?: undefined;
|
||||
}
|
||||
|
||||
export interface RatingLogic extends LogicBase {
|
||||
condition:
|
||||
| "submitted"
|
||||
|
||||
@@ -7,6 +7,15 @@ export interface ThankYouCard {
|
||||
subheader?: string;
|
||||
}
|
||||
|
||||
export interface WelcomeCard {
|
||||
enabled: boolean;
|
||||
headline?: string;
|
||||
html?: string;
|
||||
fileUrl?: string;
|
||||
buttonLabel?: string;
|
||||
timeToFinish?: boolean;
|
||||
}
|
||||
|
||||
export interface SurveyClosedMessage {
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
@@ -41,6 +50,7 @@ export interface Survey {
|
||||
environmentId: string;
|
||||
status: "draft" | "inProgress" | "paused" | "completed";
|
||||
recontactDays: number | null;
|
||||
welcomeCard: WelcomeCard;
|
||||
questions: Question[];
|
||||
thankYouCard: ThankYouCard;
|
||||
triggers: string[];
|
||||
|
||||
@@ -8,6 +8,15 @@ export const ZSurveyThankYouCard = z.object({
|
||||
subheader: z.optional(z.string()),
|
||||
});
|
||||
|
||||
export const ZSurveyWelcomeCard = z.object({
|
||||
enabled: z.boolean(),
|
||||
headline: z.optional(z.string()),
|
||||
html: z.string().optional(),
|
||||
fileUrl: z.string().optional(),
|
||||
buttonLabel: z.string().optional(),
|
||||
timeToFinish: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const ZSurveyHiddenFields = z.object({
|
||||
enabled: z.boolean(),
|
||||
fieldIds: z.optional(z.array(z.string())),
|
||||
@@ -52,6 +61,8 @@ export const ZSurveyVerifyEmail = z
|
||||
|
||||
export type TSurveyVerifyEmail = z.infer<typeof ZSurveyVerifyEmail>;
|
||||
|
||||
export type TSurveyWelcomeCard = z.infer<typeof ZSurveyWelcomeCard>;
|
||||
|
||||
export type TSurveyThankYouCard = z.infer<typeof ZSurveyThankYouCard>;
|
||||
|
||||
export type TSurveyHiddenFields = z.infer<typeof ZSurveyHiddenFields>;
|
||||
@@ -230,6 +241,17 @@ export const ZSurveyCTAQuestion = ZSurveyQuestionBase.extend({
|
||||
|
||||
export type TSurveyCTAQuestion = z.infer<typeof ZSurveyCTAQuestion>;
|
||||
|
||||
// export const ZSurveyWelcomeQuestion = ZSurveyQuestionBase.extend({
|
||||
// type: z.literal(QuestionType.Welcome),
|
||||
// html: z.string().optional(),
|
||||
// fileUrl: z.string().optional(),
|
||||
// buttonUrl: z.string().optional(),
|
||||
// timeToFinish: z.boolean().default(false),
|
||||
// logic: z.array(ZSurveyCTALogic).optional(),
|
||||
// });
|
||||
|
||||
// export type TSurveyWelcomeQuestion = z.infer<typeof ZSurveyWelcomeQuestion>;
|
||||
|
||||
export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(QuestionType.Rating),
|
||||
scale: z.enum(["number", "smiley", "star"]),
|
||||
@@ -242,6 +264,7 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
|
||||
export type TSurveyRatingQuestion = z.infer<typeof ZSurveyRatingQuestion>;
|
||||
|
||||
export const ZSurveyQuestion = z.union([
|
||||
// ZSurveyWelcomeQuestion,
|
||||
ZSurveyOpenTextQuestion,
|
||||
ZSurveyConsentQuestion,
|
||||
ZSurveyMultipleChoiceSingleQuestion,
|
||||
@@ -291,6 +314,7 @@ export const ZSurvey = z.object({
|
||||
triggers: z.array(z.string()),
|
||||
redirectUrl: z.string().url().nullable(),
|
||||
recontactDays: z.number().nullable(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
questions: ZSurveyQuestions,
|
||||
thankYouCard: ZSurveyThankYouCard,
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
@@ -312,6 +336,7 @@ export const ZSurveyInput = z.object({
|
||||
autoClose: z.number().optional(),
|
||||
redirectUrl: z.string().url().optional(),
|
||||
recontactDays: z.number().optional(),
|
||||
welcomeCard: ZSurveyWelcomeCard.optional(),
|
||||
questions: ZSurveyQuestions.optional(),
|
||||
thankYouCard: ZSurveyThankYouCard.optional(),
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { ZSurveyHiddenFields, ZSurveyQuestions, ZSurveyThankYouCard } from "./surveys";
|
||||
import { ZSurveyWelcomeCard, ZSurveyHiddenFields, ZSurveyQuestions, ZSurveyThankYouCard } from "./surveys";
|
||||
|
||||
const ZTemplateObjective = z.enum([
|
||||
"increase_user_adoption",
|
||||
@@ -20,6 +20,7 @@ export const ZTemplate = z.object({
|
||||
objectives: z.array(ZTemplateObjective).optional(),
|
||||
preset: z.object({
|
||||
name: z.string(),
|
||||
welcomeCard: ZSurveyWelcomeCard,
|
||||
questions: ZSurveyQuestions,
|
||||
thankYouCard: ZSurveyThankYouCard,
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
|
||||
@@ -358,6 +358,10 @@ export default function ToolbarPlugin(props: TextEditorProps) {
|
||||
const dom = parser.parseFromString(props.getText(), "text/html");
|
||||
|
||||
const nodes = $generateNodesFromDOM(editor, dom);
|
||||
const paragraph = $createParagraphNode();
|
||||
$getRoot().clear().append(paragraph);
|
||||
|
||||
paragraph.select();
|
||||
|
||||
$getRoot().select();
|
||||
$insertNodes(nodes);
|
||||
|
||||
154
packages/ui/FileInput/index.tsx
Normal file
154
packages/ui/FileInput/index.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
import { PhotoIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { ArrowUpTrayIcon } from "@heroicons/react/24/solid";
|
||||
import { FileIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { uploadFile } from "./lib/fileUpload";
|
||||
|
||||
interface FileInputProps {
|
||||
allowedFileExtensions: string[];
|
||||
environmentId: string | undefined;
|
||||
onFileUpload: (uploadedUrl: string | undefined) => void;
|
||||
fileUrl: string | undefined;
|
||||
}
|
||||
|
||||
const FileInput: React.FC<FileInputProps> = ({
|
||||
allowedFileExtensions,
|
||||
environmentId,
|
||||
onFileUpload,
|
||||
fileUrl,
|
||||
}) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploaded, setIsUploaded] = useState<boolean>(!!fileUrl);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (
|
||||
file &&
|
||||
file.type &&
|
||||
allowedFileExtensions.includes(file.type.substring(file.type.lastIndexOf("/") + 1))
|
||||
) {
|
||||
setIsUploaded(false);
|
||||
setSelectedFile(file);
|
||||
const response = await uploadFile(file, allowedFileExtensions, environmentId);
|
||||
setIsUploaded(true);
|
||||
onFileUpload(response.data.url);
|
||||
} else {
|
||||
toast.error("File not supported");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor="selectedFile"
|
||||
className="relative flex h-52 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-600 dark:hover:bg-slate-800"
|
||||
onDragOver={(e) => handleDragOver(e)}
|
||||
onDrop={(e) => handleDrop(e)}>
|
||||
{isUploaded && fileUrl ? (
|
||||
<>
|
||||
<div className="absolute inset-0 mr-4 mt-2 flex items-start justify-end gap-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 hover:bg-slate-200/50 text-slate-800 hover:text-slate-900 bg-opacity-50">
|
||||
<label htmlFor="modifyFile">
|
||||
<PhotoIcon className="h-5 text-slate-700 hover:text-slate-900 cursor-pointer" />
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="modifyFile"
|
||||
name="modifyFile"
|
||||
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const selectedFile = e.target?.files?.[0];
|
||||
if (selectedFile) {
|
||||
setIsUploaded(false);
|
||||
setSelectedFile(selectedFile);
|
||||
const response = await uploadFile(selectedFile, allowedFileExtensions, environmentId);
|
||||
setIsUploaded(true);
|
||||
onFileUpload(response.data.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-300 hover:bg-slate-200/50 bg-opacity-50">
|
||||
<TrashIcon className="h-5 text-slate-700 hover:text-slate-900" onClick={() => onFileUpload(undefined)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fileUrl.endsWith("jpg") || fileUrl.endsWith("jpeg") || fileUrl.endsWith("png") ? (
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt="Company Logo"
|
||||
className="max-h-full max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="dark.text-slate-400 mt-2 text-sm text-slate-500">
|
||||
<span className="font-semibold">{fileUrl.split("/").pop()}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : !isUploaded && selectedFile ? (
|
||||
<>
|
||||
{selectedFile.type.startsWith("image/") ? (
|
||||
<img
|
||||
src={URL.createObjectURL(selectedFile)}
|
||||
alt="Company Logo"
|
||||
className="max-h-full max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<FileIcon className="h-6 text-slate-500" />
|
||||
<p className="dark.text-slate-400 mt-2 text-sm text-slate-500">
|
||||
<span className="font-semibold">{selectedFile.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="hover.bg-opacity-60 absolute inset-0 flex items-center justify-center bg-black bg-opacity-20 transition-opacity duration-300">
|
||||
<label htmlFor="selectedFile" className="cursor-pointer text-sm font-semibold text-white">
|
||||
Uploading
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<ArrowUpTrayIcon className="h-6 text-slate-500" />
|
||||
<p className="dark.text-slate-400 mt-2 text-sm text-slate-500">
|
||||
<span className="font-semibold">Click or drag to upload files.</span>
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
id="selectedFile"
|
||||
name="selectedFile"
|
||||
accept={allowedFileExtensions.map((ext) => `.${ext}`).join(",")}
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const selectedFile = e.target?.files?.[0];
|
||||
if (selectedFile) {
|
||||
setIsUploaded(false);
|
||||
setSelectedFile(selectedFile);
|
||||
const response = await uploadFile(selectedFile, allowedFileExtensions, environmentId);
|
||||
setIsUploaded(true);
|
||||
onFileUpload(response.data.url);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileInput;
|
||||
40
packages/ui/FileInput/lib/fileUpload.ts
Normal file
40
packages/ui/FileInput/lib/fileUpload.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const uploadFile = async (
|
||||
file: File | Blob,
|
||||
allowedFileExtensions: string[],
|
||||
environmentId: string | undefined
|
||||
) => {
|
||||
try {
|
||||
if (!(file instanceof Blob) || !(file instanceof File)) {
|
||||
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
|
||||
}
|
||||
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
const payload = {
|
||||
fileBuffer: Array.from(new Uint8Array(fileBuffer)),
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
allowedFileExtensions: allowedFileExtensions,
|
||||
environmentId: environmentId,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/v1/management/storage", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export { uploadFile };
|
||||
@@ -80,8 +80,8 @@ const TagsCombobox: React.FC<ITagsComboboxProps> = ({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && searchValue !== "") {
|
||||
if (
|
||||
!tagsToSearch?.find(
|
||||
(tag) => tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
|
||||
!tagsToSearch?.find((tag) =>
|
||||
tag?.label?.toLowerCase().includes(searchValue?.toLowerCase())
|
||||
)
|
||||
) {
|
||||
createTag?.(searchValue);
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-radio-group": "^3.0.3",
|
||||
"react-use": "^17.4.0"
|
||||
"react-use": "^17.4.0",
|
||||
"mime": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -28,7 +28,7 @@ importers:
|
||||
version: 3.13.0
|
||||
turbo:
|
||||
specifier: latest
|
||||
version: 1.10.13
|
||||
version: 1.10.15
|
||||
|
||||
apps/demo:
|
||||
dependencies:
|
||||
@@ -517,7 +517,7 @@ importers:
|
||||
version: 9.0.0(eslint@8.51.0)
|
||||
eslint-config-turbo:
|
||||
specifier: latest
|
||||
version: 1.8.8(eslint@8.51.0)
|
||||
version: 1.10.15(eslint@8.51.0)
|
||||
eslint-plugin-react:
|
||||
specifier: 7.33.2
|
||||
version: 7.33.2(eslint@8.51.0)
|
||||
@@ -844,6 +844,9 @@ importers:
|
||||
lucide-react:
|
||||
specifier: ^0.287.0
|
||||
version: 0.287.0(react@18.2.0)
|
||||
mime:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
next:
|
||||
specifier: 13.5.5
|
||||
version: 13.5.5(react-dom@18.2.0)(react@18.2.0)
|
||||
@@ -12744,13 +12747,13 @@ packages:
|
||||
resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==}
|
||||
dev: true
|
||||
|
||||
/eslint-config-turbo@1.8.8(eslint@8.51.0):
|
||||
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
|
||||
/eslint-config-turbo@1.10.15(eslint@8.51.0):
|
||||
resolution: {integrity: sha512-76mpx2x818JZE26euen14utYcFDxOahZ9NaWA+6Xa4pY2ezVKVschuOxS96EQz3o3ZRSmcgBOapw/gHbN+EKxQ==}
|
||||
peerDependencies:
|
||||
eslint: '>6.6.0'
|
||||
dependencies:
|
||||
eslint: 8.51.0
|
||||
eslint-plugin-turbo: 1.8.8(eslint@8.51.0)
|
||||
eslint-plugin-turbo: 1.10.15(eslint@8.51.0)
|
||||
dev: true
|
||||
|
||||
/eslint-import-resolver-node@0.3.9:
|
||||
@@ -12949,11 +12952,12 @@ packages:
|
||||
- typescript
|
||||
dev: true
|
||||
|
||||
/eslint-plugin-turbo@1.8.8(eslint@8.51.0):
|
||||
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
|
||||
/eslint-plugin-turbo@1.10.15(eslint@8.51.0):
|
||||
resolution: {integrity: sha512-Tv4QSKV/U56qGcTqS/UgOvb9HcKFmWOQcVh3HEaj7of94lfaENgfrtK48E2CckQf7amhKs1i+imhCsNCKjkQyA==}
|
||||
peerDependencies:
|
||||
eslint: '>6.6.0'
|
||||
dependencies:
|
||||
dotenv: 16.0.3
|
||||
eslint: 8.51.0
|
||||
dev: true
|
||||
|
||||
@@ -22169,64 +22173,64 @@ packages:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
/turbo-darwin-64@1.10.13:
|
||||
resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==}
|
||||
/turbo-darwin-64@1.10.15:
|
||||
resolution: {integrity: sha512-Sik5uogjkRTe1XVP9TC2GryEMOJCaKE2pM/O9uLn4koQDnWKGcLQv+mDU+H+9DXvKLnJnKCD18OVRkwK5tdpoA==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-darwin-arm64@1.10.13:
|
||||
resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==}
|
||||
/turbo-darwin-arm64@1.10.15:
|
||||
resolution: {integrity: sha512-xwqyFDYUcl2xwXyGPmHkmgnNm4Cy0oNzMpMOBGRr5x64SErS7QQLR4VHb0ubiR+VAb8M+ECPklU6vD1Gm+wekg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-linux-64@1.10.13:
|
||||
resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==}
|
||||
/turbo-linux-64@1.10.15:
|
||||
resolution: {integrity: sha512-dM07SiO3RMAJ09Z+uB2LNUSkPp3I1IMF8goH5eLj+d8Kkwoxd/+qbUZOj9RvInyxU/IhlnO9w3PGd3Hp14m/nA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-linux-arm64@1.10.13:
|
||||
resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==}
|
||||
/turbo-linux-arm64@1.10.15:
|
||||
resolution: {integrity: sha512-MkzKLkKYKyrz4lwfjNXH8aTny5+Hmiu4SFBZbx+5C0vOlyp6fV5jZANDBvLXWiDDL4DSEAuCEK/2cmN6FVH1ow==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-windows-64@1.10.13:
|
||||
resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==}
|
||||
/turbo-windows-64@1.10.15:
|
||||
resolution: {integrity: sha512-3TdVU+WEH9ThvQGwV3ieX/XHebtYNHv9HARHauPwmVj3kakoALkpGxLclkHFBLdLKkqDvmHmXtcsfs6cXXRHJg==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo-windows-arm64@1.10.13:
|
||||
resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==}
|
||||
/turbo-windows-arm64@1.10.15:
|
||||
resolution: {integrity: sha512-l+7UOBCbfadvPMYsX08hyLD+UIoAkg6ojfH+E8aud3gcA1padpjCJTh9gMpm3QdMbKwZteT5uUM+wyi6Rbbyww==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/turbo@1.10.13:
|
||||
resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==}
|
||||
/turbo@1.10.15:
|
||||
resolution: {integrity: sha512-mKKkqsuDAQy1wCCIjCdG+jOCwUflhckDMSRoeBPcIL/CnCl7c5yRDFe7SyaXloUUkt4tUR0rvNIhVCcT7YeQpg==}
|
||||
hasBin: true
|
||||
optionalDependencies:
|
||||
turbo-darwin-64: 1.10.13
|
||||
turbo-darwin-arm64: 1.10.13
|
||||
turbo-linux-64: 1.10.13
|
||||
turbo-linux-arm64: 1.10.13
|
||||
turbo-windows-64: 1.10.13
|
||||
turbo-windows-arm64: 1.10.13
|
||||
turbo-darwin-64: 1.10.15
|
||||
turbo-darwin-arm64: 1.10.15
|
||||
turbo-linux-64: 1.10.15
|
||||
turbo-linux-arm64: 1.10.15
|
||||
turbo-windows-64: 1.10.15
|
||||
turbo-windows-arm64: 1.10.15
|
||||
dev: true
|
||||
|
||||
/tw-to-css@0.0.11:
|
||||
|
||||
Reference in New Issue
Block a user