Survey Completed message shown to the enduser can now be customized (#464)

* feat: added surveyClosedMessage field to database and also added it's types

* feat: added the custom closed message to the frontend

* fix: fixes build issue

* fix: refactored the code to toggle surveyClosedMessage and redirectUrl

* pnpm format

* recreate prisma migration

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Pradumn Kumar
2023-07-11 16:42:45 +05:30
committed by GitHub
parent 269a504780
commit 57a64d7940
13 changed files with 192 additions and 37 deletions

View File

@@ -18,20 +18,20 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
heading: "Survey Completed",
subheading: "This free & open-source survey has been closed",
});
const [closeOnDate, setCloseOnDate] = useState<Date>();
const handleRedirectCheckMark = () => {
setRedirectToggle((prev) => !prev);
if (redirectToggle && localSurvey.redirectUrl) {
setRedirectToggle(false);
setRedirectUrl(null);
setLocalSurvey({ ...localSurvey, redirectUrl: null });
return;
}
if (redirectToggle) {
setRedirectToggle(false);
return;
}
setRedirectToggle(true);
};
const handleSurveyCloseOnDateToggle = () => {
@@ -54,6 +54,14 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setLocalSurvey({ ...localSurvey, redirectUrl: link });
};
const handleCloseSurveyMessageToggle = () => {
setSurveyClosedMessageToggle((prev) => !prev);
if (surveyClosedMessageToggle && localSurvey.surveyClosedMessage) {
setLocalSurvey({ ...localSurvey, surveyClosedMessage: null });
}
};
const handleCloseOnDateChange = (date: Date) => {
const equivalentDate = date?.getDate();
date?.setUTCHours(0, 0, 0, 0);
@@ -63,11 +71,36 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
setLocalSurvey({ ...localSurvey, closeOnDate: date ?? null });
};
const handleClosedSurveyMessageChange = ({
heading,
subheading,
}: {
heading?: string;
subheading?: string;
}) => {
const message = {
heading: heading ?? surveyClosedMessage.heading,
subheading: subheading ?? surveyClosedMessage.subheading,
};
setSurveyClosedMessage(message);
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
};
useEffect(() => {
if (localSurvey.redirectUrl) {
setRedirectUrl(localSurvey.redirectUrl);
setRedirectToggle(true);
}
if (!!localSurvey.surveyClosedMessage) {
setSurveyClosedMessage({
heading: localSurvey.surveyClosedMessage.heading ?? surveyClosedMessage.heading,
subheading: localSurvey.surveyClosedMessage.subheading ?? surveyClosedMessage.subheading,
});
setSurveyClosedMessageToggle(true);
}
if (localSurvey.closeOnDate) {
setCloseOnDate(localSurvey.closeOnDate);
setSurveyCloseOnDateToggle(true);
@@ -144,6 +177,75 @@ export default function ResponseOptionsCard({ localSurvey, setLocalSurvey }: Res
</label>
</div>
)}
{localSurvey.type === "link" && (
<>
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Redirect on completion</h3>
<p className="text-xs font-normal text-slate-500">
Redirect user to specified link on survey completion
</p>
</div>
</Label>
</div>
{redirectToggle && (
<div className="ml-2 space-x-1 px-4 pb-4">
<Input
type="url"
placeholder="https://www.example.com"
value={redirectUrl ? redirectUrl : ""}
onChange={(e) => handleRedirectUrlChange(e.target.value)}
/>
</div>
)}
<div className="ml-2 flex items-center space-x-1 p-4">
<Switch
id="redirectUrl"
checked={surveyClosedMessageToggle}
onCheckedChange={handleCloseSurveyMessageToggle}
/>
<Label htmlFor="redirectUrl" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">
{"Adjust 'Survey Closed' Message"}
</h3>
<p className="text-xs font-normal text-slate-500">
Change the message visitors see when the survey is closed.
</p>
</div>
</Label>
</div>
{surveyClosedMessageToggle && (
<div className="ml-2 space-x-1 px-4 pb-4">
<div>
<Label htmlFor="headline">Heading</Label>
<div className="mt-2">
<Input
autoFocus
id="heading"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
/>
</div>
</div>
<div className="mt-3">
<Label htmlFor="headline">Subheading</Label>
<div className="mt-2">
<Input
id="subheading"
name="subheading"
defaultValue={surveyClosedMessage.subheading}
onChange={(e) => handleClosedSurveyMessageChange({ subheading: e.target.value })}
/>
</div>
</div>
</div>
)}
</>
)}
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch id="redirectUrl" checked={redirectToggle} onCheckedChange={handleRedirectCheckMark} />

View File

@@ -5,7 +5,13 @@ import Image from "next/image";
import Link from "next/link";
import { Button } from "@formbricks/ui";
const SurveyInactive = ({ status }) => {
const SurveyInactive = ({
status,
surveyClosedMessage,
}: {
status: string;
surveyClosedMessage?: { heading: string; subheading: string };
}) => {
const icons = {
"not found": <QuestionMarkCircleIcon className="h-20 w-20" />,
paused: <PauseCircleIcon className="h-20 w-20" />,
@@ -23,11 +29,20 @@ const SurveyInactive = ({ status }) => {
<div></div>
<div className="flex flex-col items-center space-y-3 text-slate-300">
{icons[status]}
<h1 className="text-4xl font-bold text-slate-800">Survey {status}.</h1>
<p className="text-lg leading-10 text-gray-500">{descriptions[status]}</p>
<Button variant="darkCTA" className="mt-2" href="https://formbricks.com">
Create your own
</Button>
<h1 className="text-4xl font-bold text-slate-800">
{status === "completed" && surveyClosedMessage ? surveyClosedMessage.heading : `Survey ${status}.`}
</h1>
<p className="text-lg leading-10 text-gray-500">
{" "}
{status === "completed" && surveyClosedMessage
? surveyClosedMessage.subheading
: descriptions[status]}
</p>
{!(status === "completed" && surveyClosedMessage) && (
<Button variant="darkCTA" className="mt-2" href="https://formbricks.com">
Create your own
</Button>
)}
</div>
<div>
<Link href="https://formbricks.com">

View File

@@ -26,7 +26,12 @@ export default function SurveyPage({ surveyId }: SurveyPageProps) {
}
if (isErrorSurvey && isErrorSurvey.status === 403) {
return <SurveyInactive status={isErrorSurvey.info.reason} />;
return (
<SurveyInactive
status={isErrorSurvey.info.reason}
surveyClosedMessage={isErrorSurvey.info?.surveyClosedMessage}
/>
);
}
return (

View File

@@ -28,6 +28,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
environmentId: true,
status: true,
redirectUrl: true,
surveyClosedMessage: true,
},
});
@@ -57,6 +58,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
reason: survey.status,
brandColor: product?.brandColor,
formbricksSignature: product?.formbricksSignature,
surveyClosedMessage: survey?.surveyClosedMessage,
});
}

View File

@@ -1,5 +1,6 @@
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { Prisma as prismaClient } from "@prisma/client/";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
@@ -144,6 +145,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
id: targetEnvironmentId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
},
});

View File

@@ -1,5 +1,6 @@
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { Prisma as prismaClient } from "@prisma/client/";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
@@ -64,6 +65,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
id: environmentId,
},
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
},
});

View File

@@ -1,6 +1,7 @@
import type { AttributeFilter } from "@formbricks/types/surveys";
import { hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { Prisma as prismaClient } from "@prisma/client/";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
@@ -87,6 +88,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
if (body.type === "link") {
delete body.triggers;
delete body.recontactDays;
// converts JSON field with null value to JsonNull as JSON fields can't be set to null since prisma 3.0
if (!body.surveyClosedMessage) {
body.surveyClosedMessage = prismaClient.JsonNull;
}
}
if (body.triggers) {

View File

@@ -1,6 +1,6 @@
import { TEventClassNoCodeConfig } from "@formbricks/types/v1/eventClasses";
import { TResponsePersonAttributes, TResponseData } from "@formbricks/types/v1/responses";
import { TSurveyQuestions, TSurveyThankYouCard } from "@formbricks/types/v1/surveys";
import { TSurveyClosedMessage, TSurveyQuestions, TSurveyThankYouCard } from "@formbricks/types/v1/surveys";
import { TUserNotificationSettings } from "@formbricks/types/v1/users";
declare global {
@@ -12,6 +12,7 @@ declare global {
export type ResponsePersonAttributes = TResponsePersonAttributes;
export type SurveyQuestions = TSurveyQuestions;
export type SurveyThankYouCard = TSurveyThankYouCard;
export type SurveyClosedMessage = TSurveyClosedMessage;
export type UserNotificationSettings = TUserNotificationSettings;
}
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "surveyClosedMessage" JSONB;

View File

@@ -210,33 +210,36 @@ enum displayOptions {
}
model Survey {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
redirectUrl String?
type SurveyType @default(web)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
status SurveyStatus @default(draft)
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
name String
redirectUrl String?
type SurveyType @default(web)
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
environmentId String
status SurveyStatus @default(draft)
/// @zod.custom(imports.ZSurveyQuestions)
/// @zod.custom(imports.ZSurveyQuestions)
/// [SurveyQuestions]
questions Json @default("[]")
questions Json @default("[]")
/// @zod.custom(imports.ZSurveyThankYouCard)
/// @zod.custom(imports.ZSurveyThankYouCard)
/// [SurveyThankYouCard]
thankYouCard Json @default("{\"enabled\": false}")
responses Response[]
displayOption displayOptions @default(displayOnce)
recontactDays Int?
triggers SurveyTrigger[]
attributeFilters SurveyAttributeFilter[]
displays Display[]
autoClose Int?
delay Int @default(0)
autoComplete Int?
closeOnDate DateTime?
thankYouCard Json @default("{\"enabled\": false}")
responses Response[]
displayOption displayOptions @default(displayOnce)
recontactDays Int?
triggers SurveyTrigger[]
attributeFilters SurveyAttributeFilter[]
displays Display[]
autoClose Int?
delay Int @default(0)
autoComplete Int?
closeOnDate DateTime?
/// @zod.custom(imports.ZSurveyClosedMessage)
/// [SurveyClosedMessage]
surveyClosedMessage Json?
}
model Event {

View File

@@ -6,6 +6,6 @@ export { ZEventClassNoCodeConfig } from "@formbricks/types/v1/eventClasses";
export { ZResponseData, ZResponsePersonAttributes } from "@formbricks/types/v1/responses";
export const ZResponseMeta = z.record(z.union([z.string(), z.number()]));
export { ZSurveyQuestions, ZSurveyThankYouCard } from "@formbricks/types/v1/surveys";
export { ZSurveyQuestions, ZSurveyThankYouCard, ZSurveyClosedMessage } from "@formbricks/types/v1/surveys";
export { ZUserNotificationSettings } from "@formbricks/types/v1/users";

View File

@@ -6,6 +6,11 @@ export interface ThankYouCard {
subheader?: string;
}
export interface SurveyClosedMessage {
heading?: string;
subheading?: string;
}
export interface Survey {
id: string;
createdAt: string;
@@ -26,6 +31,7 @@ export interface Survey {
autoClose: number | null;
delay: number;
autoComplete: number | null;
surveyClosedMessage: SurveyClosedMessage | null;
closeOnDate: Date | null;
}

View File

@@ -8,8 +8,17 @@ export const ZSurveyThankYouCard = z.object({
subheader: z.optional(z.string()),
});
export const ZSurveyClosedMessage = z
.object({
heading: z.optional(z.string()),
subheading: z.optional(z.string()),
})
.optional();
export type TSurveyThankYouCard = z.infer<typeof ZSurveyThankYouCard>;
export type TSurveyClosedMessage = z.infer<typeof ZSurveyThankYouCard>;
export const ZSurveyChoice = z.object({
id: z.string(),
label: z.string(),
@@ -215,6 +224,7 @@ export const ZSurveyWithAnalytics = ZSurvey.extend({
numDisplays: z.number(),
responseRate: z.number(),
}),
surveyClosedMessage: ZSurveyClosedMessage,
});
export type TSurveyWithAnalytics = z.infer<typeof ZSurveyWithAnalytics>;