mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 06:41:45 -05:00
feat: one response per email (#3088)
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
committed by
GitHub
parent
0ce7703ab8
commit
7d0cbad326
+23
-7
@@ -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}
|
||||
|
||||
+1
@@ -37,5 +37,6 @@ export const minimalSurvey: TSurvey = {
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
variables: [],
|
||||
};
|
||||
|
||||
+14
-12
@@ -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 && (
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
Reference in New Issue
Block a user