feat: one response per email (#3088)

Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Dhruwang Jariwala
2024-09-04 18:13:03 +05:30
committed by GitHub
parent 0ce7703ab8
commit 7d0cbad326
20 changed files with 228 additions and 90 deletions
@@ -30,7 +30,10 @@ export const ResponseOptionsCard = ({
const [closeOnDateToggle, setCloseOnDateToggle] = useState(false);
useState;
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
localSurvey.isSingleResponsePerEmailEnabled
);
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
heading: "Survey Completed",
@@ -114,6 +117,14 @@ export const ResponseOptionsCard = ({
setLocalSurvey({ ...localSurvey, isVerifyEmailEnabled: !localSurvey.isVerifyEmailEnabled });
};
const handleSingleResponsePerEmailToggle = () => {
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
setLocalSurvey({
...localSurvey,
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
});
};
const handleRunOnDateChange = (date: Date) => {
const equivalentDate = date?.getDate();
date?.setUTCHours(0, 0, 0, 0);
@@ -213,10 +224,6 @@ export const ResponseOptionsCard = ({
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
}
if (localSurvey.isVerifyEmailEnabled) {
setVerifyEmailToggle(true);
}
if (localSurvey.runOnDate) {
setRunOnDate(localSurvey.runOnDate);
setRunOnDateToggle(true);
@@ -450,8 +457,17 @@ export const ResponseOptionsCard = ({
onToggle={handleVerifyEmailToogle}
title="Verify email before submission"
description="Only let people with a real email respond."
childBorder={true}
/>
childBorder={true}>
<div className="m-1">
<AdvancedOptionToggle
htmlId="preventDoubleSubmission"
isChecked={isSingleResponsePerEmailEnabledToggle}
onToggle={handleSingleResponsePerEmailToggle}
title="Prevent double submission"
description={"Only allow 1 response per email address"}
/>
</div>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="protectSurveyWithPin"
isChecked={isPinProtectionEnabled}
@@ -37,5 +37,6 @@ export const minimalSurvey: TSurvey = {
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
variables: [],
};
@@ -3,7 +3,7 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { ShareIcon, SquarePenIcon } from "lucide-react";
import { ArrowUpRightFromSquareIcon, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
@@ -64,23 +64,25 @@ export const SurveyAnalysisCTA = ({
<SurveyStatusDropdown environment={environment} survey={survey} />
) : null}
{survey.type === "link" && (
<Button
variant="secondary"
size="sm"
onClick={() => {
setOpenShareSurveyModal(true);
}}>
Share
<ShareIcon className="ml-1 h-4" />
</Button>
<>
<Button
variant="secondary"
size="sm"
onClick={() => {
setOpenShareSurveyModal(true);
}}
EndIcon={ArrowUpRightFromSquareIcon}>
Preview
</Button>
</>
)}
{!isViewer && (
<Button
size="sm"
className="h-full"
href={`/environments/${environment.id}/surveys/${survey.id}/edit`}>
href={`/environments/${environment.id}/surveys/${survey.id}/edit`}
EndIcon={SquarePenIcon}>
Edit
<SquarePenIcon className="ml-1 h-4" />
</Button>
)}
{showShareSurveyModal && user && (
+12
View File
@@ -5,6 +5,7 @@ import { z } from "zod";
import { sendLinkSurveyToVerifiedEmail } from "@formbricks/email";
import { actionClient } from "@formbricks/lib/actionClient";
import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt";
import { getIfResponseWithSurveyIdAndEmailExist } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { ZId } from "@formbricks/types/common";
import { ZLinkSurveyEmailData } from "@formbricks/types/email";
@@ -42,3 +43,14 @@ export const validateSurveyPinAction = actionClient
return { survey };
});
const ZGetIfResponseWithSurveyIdAndEmailExistAction = z.object({
surveyId: ZId,
email: z.string(),
});
export const getIfResponseWithSurveyIdAndEmailExistAction = actionClient
.schema(ZGetIfResponseWithSurveyIdAndEmailExistAction)
.action(async ({ parsedInput }) => {
return await getIfResponseWithSurveyIdAndEmailExist(parsedInput.surveyId, parsedInput.email);
});
@@ -1,17 +1,22 @@
"use client";
import { sendLinkSurveyEmailAction } from "@/app/s/[surveyId]/actions";
import { MailIcon } from "lucide-react";
import { ArrowLeft } from "lucide-react";
import {
getIfResponseWithSurveyIdAndEmailExistAction,
sendLinkSurveyEmailAction,
} from "@/app/s/[surveyId]/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, MailIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { Toaster, toast } from "react-hot-toast";
import { z } from "zod";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { isValidEmail } from "@formbricks/lib/utils/email";
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
import { TAttributeClass } from "@formbricks/types/attribute-classes";
import { TProductStyling } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormError, FormField, FormItem } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { StackedCardsContainer } from "@formbricks/ui/StackedCardsContainer";
@@ -24,6 +29,11 @@ interface VerifyEmailProps {
attributeClasses: TAttributeClass[];
}
const ZVerifyEmailInput = z.object({
email: z.string().email(),
});
type TVerifyEmailInput = z.infer<typeof ZVerifyEmailInput>;
export const VerifyEmail = ({
survey,
isErrorComponent,
@@ -32,21 +42,34 @@ export const VerifyEmail = ({
styling,
attributeClasses,
}: VerifyEmailProps) => {
const form = useForm<TVerifyEmailInput>({
defaultValues: {
email: "",
},
resolver: zodResolver(ZVerifyEmailInput),
});
survey = useMemo(() => {
return replaceHeadlineRecall(survey, "default", attributeClasses);
}, [survey, attributeClasses]);
const { isSubmitting } = form.formState;
const [showPreviewQuestions, setShowPreviewQuestions] = useState(false);
const [email, setEmail] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const submitEmail = async (email) => {
setIsLoading(true);
if (!isValidEmail(email)) {
toast.error("Please enter a valid email");
setIsLoading(false);
return;
const submitEmail = async (emailInput: TVerifyEmailInput) => {
const email = emailInput.email.toLowerCase();
if (survey.isSingleResponsePerEmailEnabled) {
const actionResult = await getIfResponseWithSurveyIdAndEmailExistAction({
surveyId: survey.id,
email,
});
if (actionResult?.data) {
form.setError("email", {
type: "custom",
message: "We already received a response for this email address.",
});
return;
}
}
const data = {
surveyId: survey.id,
@@ -60,7 +83,6 @@ export const VerifyEmail = ({
} catch (error) {
toast.error(error.message);
}
setIsLoading(false);
};
const handlePreviewClick = () => {
@@ -72,12 +94,6 @@ export const VerifyEmail = ({
setEmailSent(false);
};
const handleKeyPress = (e) => {
if (e.key === "Enter") {
submitEmail(email);
}
};
if (isErrorComponent) {
return (
<div className="flex h-[100vh] w-[100vw] flex-col items-center justify-center bg-slate-50">
@@ -97,33 +113,49 @@ export const VerifyEmail = ({
cardArrangement={
survey.styling?.cardArrangement?.linkSurveys ?? styling.cardArrangement?.linkSurveys ?? "straight"
}>
{!emailSent && !showPreviewQuestions && (
<div className="flex flex-col">
<div className="mx-auto rounded-full border bg-slate-200 p-6">
<MailIcon className="mx-auto h-12 w-12 text-white" />
</div>
<p className="mt-8 text-2xl font-bold lg:text-4xl">Verify your email to respond.</p>
<p className="mt-4 text-sm text-slate-500 lg:text-base">
To respond to this survey, please verify your email.
</p>
<div className="mt-6 flex w-full space-x-2">
<Input
type="string"
placeholder="user@gmail.com"
className="h-12"
value={email || ""}
onChange={(e) => setEmail(e.target.value)}
onKeyPress={handleKeyPress}
/>
<Button onClick={() => submitEmail(email)} loading={isLoading}>
Verify
</Button>
</div>
<p className="mt-6 cursor-pointer text-xs text-slate-400" onClick={handlePreviewClick}>
Just curious? <span className="underline">Preview survey questions.</span>
</p>
</div>
)}
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(submitEmail)}>
{!emailSent && !showPreviewQuestions && (
<div className="flex flex-col">
<div className="mx-auto rounded-full border bg-slate-200 p-6">
<MailIcon strokeWidth={1.5} className="mx-auto h-12 w-12 text-white" />
</div>
<p className="mt-8 text-2xl font-bold lg:text-4xl">Verify your email to respond</p>
<p className="mt-4 text-sm text-slate-500 lg:text-base">
To respond to this survey, please enter your email address below:
</p>
<FormField
control={form.control}
name="email"
render={({ field, fieldState: { error } }) => (
<FormItem className="my-4 w-full space-y-4">
<FormControl>
<div>
<div className="flex space-x-2">
<Input
value={field.value}
onChange={(email) => field.onChange(email)}
type="email"
placeholder="engineering@acme.com"
className="h-10 bg-white"
/>
<Button type="submit" size="sm" loading={isSubmitting}>
Verify
</Button>
</div>
{error?.message && <FormError className="mt-2">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<p className="mt-6 cursor-pointer text-xs text-slate-400" onClick={handlePreviewClick}>
Just curious? <span className="underline">Preview survey questions.</span>
</p>
</div>
)}
</form>
</FormProvider>
{!emailSent && showPreviewQuestions && (
<div>
<p className="text-4xl font-bold">Question Preview</p>
@@ -141,12 +173,16 @@ export const VerifyEmail = ({
)}
{emailSent && (
<div>
<h1 className="mt-8 text-2xl font-bold lg:text-4xl">Check your email.</h1>
<p className="mt-4 text-center text-sm text-slate-400 lg:text-base">
We sent an email to <span className="font-semibold italic">{email}</span>. Please click the link
in the email to take your survey.
<h1 className="mt-8 text-2xl font-bold lg:text-4xl">Survey sent to {form.getValues().email}</h1>
<p className="mt-4 text-center text-sm text-slate-500 lg:text-base">
Please also check your spam folder if you don&apos;t see the email in your inbox.
</p>
<Button variant="secondary" className="mt-6" onClick={handleGoBackClick} StartIcon={ArrowLeft}>
<Button
variant="secondary"
className="mt-6"
size="sm"
onClick={handleGoBackClick}
StartIcon={ArrowLeft}>
Back
</Button>
</div>