feat: Better Embedding of Link Surveys

feat: Better Embedding of Link Surveys
This commit is contained in:
Johannes
2023-10-04 23:35:30 +05:45
committed by GitHub
18 changed files with 2490 additions and 470 deletions
@@ -13,6 +13,7 @@ import { useEffect, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { TTag } from "@formbricks/types/v1/tags";
import { TProfile } from "@formbricks/types/v1/profile";
interface ResponsePageProps {
environment: TEnvironment;
@@ -21,6 +22,7 @@ interface ResponsePageProps {
responses: TResponse[];
surveyBaseUrl: string;
product: TProduct;
profile: TProfile;
environmentTags: TTag[];
}
@@ -31,6 +33,7 @@ const ResponsePage = ({
responses,
surveyBaseUrl,
product,
profile,
environmentTags,
}: ResponsePageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
@@ -55,6 +58,7 @@ const ResponsePage = ({
surveyId={surveyId}
surveyBaseUrl={surveyBaseUrl}
product={product}
profile={profile}
/>
<CustomFilter
environmentTags={environmentTags}
@@ -9,6 +9,7 @@ import ResponsesLimitReachedBanner from "@/app/(app)/environments/[environmentId
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getProfile } from "@formbricks/lib/services/profile";
export default async function Page({ params }) {
const session = await getServerSession(authOptions);
@@ -26,6 +27,11 @@ export default async function Page({ params }) {
if (!product) {
throw new Error("Product not found");
}
const profile = await getProfile(session.user.id);
if (!profile) {
throw new Error("Profile not found");
}
const tags = await getTagsByEnvironmentId(params.environmentId);
return (
@@ -39,6 +45,7 @@ export default async function Page({ params }) {
surveyBaseUrl={SURVEY_BASE_URL}
product={product}
environmentTags={tags}
profile={profile}
/>
</>
);
@@ -0,0 +1,21 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
import { AuthenticationError } from "@formbricks/types/v1/errors";
type TSendEmailActionArgs = {
to: string;
subject: string;
html: string;
};
export const sendEmailAction = async ({ html, subject, to }: TSendEmailActionArgs) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
return await sendEmbedSurveyPreviewEmail(to, subject, html);
};
@@ -1,17 +1,21 @@
"use client";
import LinkSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSurveyModal";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { ShareIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import clsx from "clsx";
import { TProduct } from "@formbricks/types/v1/product";
import ShareEmbedSurvey from "./ShareEmbedSurvey";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
import { TProfile } from "@formbricks/types/v1/profile";
interface LinkSurveyShareButtonProps {
survey: TSurvey;
className?: string;
surveyBaseUrl: string;
product: TProduct;
profile: TProfile;
singleUseIds?: string[];
}
@@ -19,6 +23,8 @@ export default function LinkSurveyShareButton({
survey,
className,
surveyBaseUrl,
product,
profile,
singleUseIds,
}: LinkSurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
@@ -43,11 +49,13 @@ export default function LinkSurveyShareButton({
singleUseIds={singleUseIds}
/>
) : (
<LinkSurveyModal
<ShareEmbedSurvey
survey={survey}
open={showLinkModal}
setOpen={setShowLinkModal}
product={product}
surveyBaseUrl={surveyBaseUrl}
profile={profile}
/>
)}
</>
@@ -1,119 +0,0 @@
"use client";
import CodeBlock from "@/components/shared/CodeBlock";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button, Dialog, DialogContent } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/outline";
import { CodeBracketIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
import { useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
interface LinkSurveyModalProps {
survey: TSurvey;
open: boolean;
setOpen: (open: boolean) => void;
surveyBaseUrl: string;
}
export default function LinkSurveyModal({ survey, open, setOpen, surveyBaseUrl }: LinkSurveyModalProps) {
const linkTextRef = useRef(null);
const [showEmbed, setShowEmbed] = useState(false);
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey.id, surveyBaseUrl]);
const iframeCode = `<div style="position: relative; height:100vh; max-height:100vh;
overflow:auto;">
<iframe
src="${surveyUrl}"
frameborder="0" style="position: absolute; left:0;
top:0; width:100%; height:100%; border:0;">
</iframe></div>`;
const handleTextSelection = () => {
if (linkTextRef.current) {
const range = document.createRange();
range.selectNodeContents(linkTextRef.current);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="bottom-0 max-w-sm bg-white p-4 sm:bottom-auto sm:max-w-xl sm:p-6">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:mt-5">
<h3 className="text-lg font-semibold leading-6 text-gray-900">Your survey is ready!</h3>
{showEmbed ? (
<div className="mt-4">
<p className="text-sm text-gray-500">Embed survey on your website:</p>
<CodeBlock
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
language="html">
{iframeCode}
</CodeBlock>
</div>
) : (
<div className="mt-4">
<p className="text-sm text-gray-500">Share this link to let people answer your survey:</p>
<div
ref={linkTextRef}
className="relative mt-3 max-w-full overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
onClick={() => handleTextSelection()}>
<span
style={{
wordBreak: "break-all",
}}>
{surveyUrl}
</span>
</div>
</div>
)}
<div className="mt-4 flex flex-col justify-center gap-2 sm:flex-row sm:justify-end">
<Button
variant="secondary"
title="Embed survey in your website"
aria-label="Embed survey in your website"
className="flex justify-center"
onClick={() => {
setShowEmbed(true);
navigator.clipboard.writeText(iframeCode);
toast.success("iframe code copied to clipboard!");
}}
EndIcon={CodeBracketIcon}>
Embed
</Button>
<Button
variant="secondary"
onClick={() => {
setShowEmbed(false);
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
}}
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="flex justify-center"
EndIcon={DocumentDuplicateIcon}>
Copy URL
</Button>
<Button
variant="darkCTA"
title="Preview survey"
aria-label="Preview survey"
className="flex justify-center"
href={`${surveyUrl}?preview=true`}
target="_blank"
EndIcon={EyeIcon}>
Preview
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,107 @@
"use client";
import LinkTab from "./shareEmbedTabs/LinkTab";
import EmailTab from "./shareEmbedTabs/EmailTab";
import WebpageTab from "./shareEmbedTabs/WebpageTab";
import { useMemo, useState } from "react";
import { TProduct } from "@formbricks/types/v1/product";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { cn } from "@formbricks/lib/cn";
import { DialogContent, Button, Dialog } from "@formbricks/ui";
import { LinkIcon, EnvelopeIcon, CodeBracketIcon } from "@heroicons/react/24/outline";
import { TProfile } from "@formbricks/types/v1/profile";
interface ShareEmbedSurveyProps {
survey: TSurvey;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
surveyBaseUrl: string;
product: TProduct;
profile: TProfile;
}
export default function ShareEmbedSurvey({
survey,
open,
setOpen,
surveyBaseUrl,
product,
profile,
}: ShareEmbedSurveyProps) {
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey]);
const { email } = profile;
const { brandColor } = product;
const [activeId, setActiveId] = useState(tabs[0].id);
const componentMap = {
link: <LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={brandColor} />,
email: <EmailTab survey={survey} surveyUrl={surveyUrl} email={email} brandColor={brandColor} />,
webpage: <WebpageTab surveyUrl={surveyUrl} />,
};
return (
<Dialog
open={open}
onOpenChange={(open) => {
setActiveId(tabs[0].id);
setOpen(open);
}}>
<DialogContent className="bottom-0 flex h-[95%] w-full flex-col gap-0 overflow-hidden rounded-2xl bg-white p-0 sm:max-w-none lg:bottom-auto lg:h-auto lg:w-[960px]">
<div className="border-b border-gray-200 px-4 py-3 lg:px-6 lg:py-4 ">Share or embed your survey</div>
<div className="flex grow overflow-x-hidden overflow-y-scroll">
<div className="hidden basis-[326px] border-r border-gray-200 px-6 py-8 lg:block lg:shrink-0">
<div className="flex w-max flex-col gap-3">
{tabs.map((tab) => (
<Button
StartIcon={tab.icon}
startIconClassName={cn("h-4 w-4")}
variant="minimal"
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
"rounded-[4px] px-4 py-[6px] text-slate-600",
// "focus:ring-0 focus:ring-offset-0", // enable these classes to remove the focus rings on buttons
tab.id === activeId
? " border border-gray-200 bg-slate-100 font-semibold text-slate-900"
: "border-transparent text-slate-500 hover:text-slate-700"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
{tab.label}
</Button>
))}
</div>
</div>
<div className="flex w-full grow flex-col gap-6 bg-gray-50 px-4 py-6 lg:p-6">
<div className="flex h-full overflow-y-scroll lg:h-[590px] lg:overflow-y-visible">
{componentMap[activeId]}
</div>
<div className="mx-auto flex max-w-max rounded-md bg-slate-100 p-1 lg:hidden">
{tabs.slice(0, 2).map((tab) => (
<Button
variant="minimal"
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
"rounded-sm px-3 py-[6px]",
tab.id === activeId
? "bg-white text-slate-900"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
{tab.label}
</Button>
))}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
const tabs = [
{ id: "link", label: "Share the Link", icon: LinkIcon },
{ id: "email", label: "Embed in an Email", icon: EnvelopeIcon },
{ id: "webpage", label: "Embed in a Web Page", icon: CodeBracketIcon },
];
@@ -5,14 +5,18 @@ import { Confetti } from "@formbricks/ui";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import LinkSurveyModal from "./LinkSurveyModal";
import ShareEmbedSurvey from "./ShareEmbedSurvey";
import { TProduct } from "@formbricks/types/v1/product";
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProfile } from "@formbricks/types/v1/profile";
interface SummaryMetadataProps {
environment: TEnvironment;
survey: TSurvey;
surveyBaseUrl: string;
product: TProduct;
profile: TProfile;
singleUseIds?: string[];
}
@@ -20,9 +24,12 @@ export default function SuccessMessage({
environment,
survey,
surveyBaseUrl,
product,
profile,
singleUseIds,
}: SummaryMetadataProps) {
const isSingleUse = survey.singleUse?.enabled ?? false;
const searchParams = useSearchParams();
const [showLinkModal, setShowLinkModal] = useState(false);
const [confetti, setConfetti] = useState(false);
@@ -61,11 +68,13 @@ export default function SuccessMessage({
singleUseIds={singleUseIds}
/>
) : (
<LinkSurveyModal
<ShareEmbedSurvey
survey={survey}
open={showLinkModal}
setOpen={setShowLinkModal}
surveyBaseUrl={surveyBaseUrl}
product={product}
profile={profile}
/>
)}
{confetti && <Confetti />}
@@ -15,6 +15,7 @@ import { useEffect, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { TTag } from "@formbricks/types/v1/tags";
import { TProfile } from "@formbricks/types/v1/profile";
interface SummaryPageProps {
environment: TEnvironment;
@@ -24,6 +25,7 @@ interface SummaryPageProps {
surveyBaseUrl: string;
singleUseIds?: string[];
product: TProduct;
profile: TProfile;
environmentTags: TTag[];
}
@@ -35,6 +37,7 @@ const SummaryPage = ({
surveyBaseUrl,
singleUseIds,
product,
profile,
environmentTags,
}: SummaryPageProps) => {
const { selectedFilter, dateRange, resetState } = useResponseFilter();
@@ -60,6 +63,7 @@ const SummaryPage = ({
surveyBaseUrl={surveyBaseUrl}
singleUseIds={singleUseIds}
product={product}
profile={profile}
/>
<CustomFilter
environmentTags={environmentTags}
@@ -0,0 +1,387 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { Button, Input } from "@formbricks/ui";
import { QuestionType } from "@formbricks/types/questions";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { AuthenticationError } from "@formbricks/types/v1/errors";
import { sendEmailAction } from "../../actions";
import CodeBlock from "@/components/shared/CodeBlock";
import { CodeBracketIcon, DocumentDuplicateIcon, EnvelopeIcon } from "@heroicons/react/24/solid";
import {
Column,
Container,
Button as EmailButton,
Link,
Row,
Section,
Tailwind,
Text,
render,
} from "@react-email/components";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
interface EmailTabProps {
survey: TSurvey;
surveyUrl: string;
email: string;
brandColor: string;
}
export default function EmailTab({ survey, surveyUrl, email, brandColor }: EmailTabProps) {
const [showEmbed, setShowEmbed] = useState(false);
const subject = "Formbricks Email Survey Preview";
const emailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: false });
}, []);
const previewEmailValues = useMemo(() => {
return getEmailValues({ brandColor, survey, surveyUrl, preview: true });
}, []);
const sendPreviewEmail = async () => {
try {
await sendEmailAction({ html: previewEmailValues.html, subject, to: email });
toast.success("Email sent!");
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error("You are not authenticated to perform this action.");
return;
}
toast.error("Something went wrong. Please try again later.");
}
};
return (
<div className="flex h-full grow flex-col gap-5">
<div className="flex items-center gap-4">
<Input type="email" placeholder="user@mail.com" className="h-11 grow bg-white" value={email} />
{showEmbed ? (
<Button
variant="darkCTA"
title="Embed survey in your website"
aria-label="Embed survey in your website"
onClick={() => {
toast.success("Embed code copied to clipboard!");
navigator.clipboard.writeText(emailValues.html);
}}
className="shrink-0"
EndIcon={DocumentDuplicateIcon}>
Copy code
</Button>
) : (
<Button
variant="secondary"
title="send preview email"
aria-label="send preview email"
onClick={sendPreviewEmail}
EndIcon={EnvelopeIcon}
className="shrink-0">
Send Preview
</Button>
)}
<Button
variant="darkCTA"
title="view embed code for email"
aria-label="view embed code for email"
onClick={() => setShowEmbed(!showEmbed)}
EndIcon={CodeBracketIcon}
className="shrink-0">
{showEmbed ? "Hide Embed Code" : "View Embed Code"}
</Button>
</div>
<div className="grow overflow-y-scroll rounded-xl border border-gray-200 bg-white px-4 py-[18px]">
{showEmbed ? (
<CodeBlock
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
language="html"
showCopyToClipboard={false}>
{emailValues.html}
</CodeBlock>
) : (
<div className="">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="">
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
To : {email || "user@mail.com"}
</div>
<div className="border-b border-slate-200 pb-2 text-sm">Subject : {subject}</div>
<div className="p-4">{previewEmailValues.Component}</div>
</div>
</div>
)}
</div>
</div>
);
}
const getEmailValues = ({ survey, surveyUrl, brandColor, preview }) => {
const doctype =
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">';
const Template = getEmailTemplate(survey, surveyUrl, brandColor, preview);
const html = render(Template, { pretty: true });
const htmlWithoutDoctype = html.replace(doctype, "");
return { Component: Template, html: htmlWithoutDoctype };
};
const getEmailTemplate = (survey: TSurvey, surveyUrl: string, brandColor: string, preview: boolean) => {
const url = preview ? `${surveyUrl}?preview=true` : surveyUrl;
const urlWithPrefilling = preview ? `${surveyUrl}?preview=true&` : `${surveyUrl}?`;
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>
<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="bg-brand-color ml-2 inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium 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="mt-4 cursor-pointer appearance-none rounded-md bg-brand-color px-6 py-3 text-sm font-medium text-white">
{firstQuestion.buttonLabel || "Skip"}
</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="bg-brand-color inline-flex cursor-pointer appearance-none rounded-md px-6 py-3 text-sm font-medium 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="mt-4 cursor-pointer appearance-none rounded-md bg-brand-color px-6 py-3 text-sm font-medium text-white">
{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}`}>
{choice.label}
</Link>
))}
</Container>
<EmailFooter />
</EmailTemplateWrapper>
);
}
};
const EmailTemplateWrapper = ({ children, surveyUrl, brandColor }) => {
return (
<Tailwind
config={{
theme: {
extend: {
colors: {
"brand-color": brandColor,
},
},
},
}}>
<Link
href={surveyUrl}
target="_blank"
className="mx-0 my-2 block rounded-lg border border-solid border-slate-300 bg-white p-8 font-sans text-inherit">
{children}
</Link>
</Tailwind>
);
};
const EmailFooter = () => {
return (
<Container className="m-auto mt-8 text-center ">
<Link href="https://formbricks.com/" target="_blank" className="text-xs text-slate-400">
Powered by Formbricks
</Link>
</Container>
);
};
@@ -0,0 +1,81 @@
"use client";
import toast from "react-hot-toast";
import { SurveyInline } from "@/components/shared/Survey";
import { cn } from "@formbricks/lib/cn";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
import { ArrowUpRightIcon } from "lucide-react";
import { useRef } from "react";
interface EmailTabProps {
surveyUrl: string;
survey: TSurvey;
brandColor: string;
}
export default function LinkTab({ surveyUrl, survey, brandColor }: EmailTabProps) {
const linkTextRef = useRef(null);
const handleTextSelection = () => {
if (linkTextRef.current) {
const range = document.createRange();
range.selectNodeContents(linkTextRef.current);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
};
return (
<div className="flex h-full grow flex-col gap-5">
<div className="flex flex-wrap justify-between gap-2">
<div
ref={linkTextRef}
className="relative grow overflow-auto rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800"
onClick={() => handleTextSelection()}>
<span style={{ wordBreak: "break-all" }}>{surveyUrl}</span>
</div>
<Button
variant="darkCTA"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
}}
EndIcon={DocumentDuplicateIcon}>
Copy URL
</Button>
</div>
<div className="relative grow overflow-y-scroll rounded-xl border border-gray-200 bg-white px-4 py-[18px]">
<SurveyInline
brandColor={brandColor}
survey={survey}
formbricksSignature={false}
autoFocus={false}
isRedirectDisabled={false}
key={survey.id}
/>
<Button
variant="minimal"
className={cn(
"absolute bottom-8 left-1/2 -translate-x-1/2 transform rounded-lg border border-slate-200 bg-white"
)}
EndIcon={ArrowUpRightIcon}
title="Open survey in new tab"
aria-label="Open survey in new tab"
endIconClassName="h-4 w-4 "
href={`${surveyUrl}?preview=true`}
target="_blank">
Open in new tab
</Button>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import toast from "react-hot-toast";
import CodeBlock from "@/components/shared/CodeBlock";
import { Button } from "@formbricks/ui";
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
export default function WebpageTab({ surveyUrl }) {
const iframeCode = `<div style="position: relative; height:100vh; max-height:100vh; overflow:auto;">
<iframe
src="${surveyUrl}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
return (
<div className="flex h-full grow flex-col gap-5">
<div className="flex justify-between">
<div className=""></div>
<Button
variant="darkCTA"
title="Embed survey in your website"
aria-label="Embed survey in your website"
onClick={() => {
navigator.clipboard.writeText(iframeCode);
toast.success("Embed code copied to clipboard!");
}}
EndIcon={DocumentDuplicateIcon}>
Copy code
</Button>
</div>
<div className="grow overflow-y-scroll rounded-xl border border-gray-200 bg-white px-4 py-[18px]">
<CodeBlock
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
language="html"
showCopyToClipboard={false}>
{iframeCode}
</CodeBlock>
</div>
</div>
);
}
@@ -10,6 +10,7 @@ import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getServerSession } from "next-auth";
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
import { getProfile } from "@formbricks/lib/services/profile";
const generateSingleUseIds = (isEncrypted: boolean) => {
return Array(5)
@@ -44,6 +45,12 @@ export default async function Page({ params }) {
if (!product) {
throw new Error("Product not found");
}
const profile = await getProfile(session.user.id);
if (!profile) {
throw new Error("Profile not found");
}
const tags = await getTagsByEnvironmentId(params.environmentId);
return (
@@ -57,6 +64,7 @@ export default async function Page({ params }) {
surveyBaseUrl={SURVEY_BASE_URL}
singleUseIds={isSingleUseSurvey ? singleUseIds : undefined}
product={product}
profile={profile}
environmentTags={tags}
/>
</>
@@ -24,6 +24,7 @@ import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TProduct } from "@formbricks/types/v1/product";
import { updateSurveyAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import { TProfile } from "@formbricks/types/v1/profile";
interface SummaryHeaderProps {
surveyId: string;
@@ -31,6 +32,7 @@ interface SummaryHeaderProps {
survey: TSurvey;
surveyBaseUrl: string;
product: TProduct;
profile: TProfile;
singleUseIds?: string[];
}
const SummaryHeader = ({
@@ -39,6 +41,7 @@ const SummaryHeader = ({
survey,
surveyBaseUrl,
product,
profile,
singleUseIds,
}: SummaryHeaderProps) => {
const router = useRouter();
@@ -55,7 +58,13 @@ const SummaryHeader = ({
</div>
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && (
<LinkSurveyShareButton survey={survey} surveyBaseUrl={surveyBaseUrl} singleUseIds={singleUseIds} />
<LinkSurveyShareButton
survey={survey}
surveyBaseUrl={surveyBaseUrl}
singleUseIds={singleUseIds}
product={product}
profile={profile}
/>
)}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<SurveyStatusDropdown environment={environment} survey={survey} />
@@ -82,6 +91,8 @@ const SummaryHeader = ({
className="flex w-full justify-center p-1"
survey={survey}
surveyBaseUrl={surveyBaseUrl}
product={product}
profile={profile}
singleUseIds={singleUseIds}
/>
<DropdownMenuSeparator />
@@ -164,6 +175,8 @@ const SummaryHeader = ({
environment={environment}
survey={survey}
surveyBaseUrl={surveyBaseUrl}
product={product}
profile={profile}
singleUseIds={singleUseIds}
/>
</div>
+10
View File
@@ -17,6 +17,16 @@ export function getPrefillResponseData(
const answer = transformAnswer(question, firstQuestionPrefill || "");
const answerObj = { [firstQuestionId]: answer };
if (
question.type === QuestionType.CTA &&
question.buttonExternal &&
question.buttonUrl &&
answer === "clicked"
) {
window?.open(question.buttonUrl, "blank");
}
return answerObj;
}
} catch (error) {
+17 -9
View File
@@ -11,23 +11,31 @@ interface CodeBlockProps {
children: React.ReactNode;
language: string;
customCodeClass?: string;
showCopyToClipboard?: boolean;
}
const CodeBlock: React.FC<CodeBlockProps> = ({ children, language, customCodeClass = "" }) => {
const CodeBlock: React.FC<CodeBlockProps> = ({
children,
language,
customCodeClass = "",
showCopyToClipboard = true,
}) => {
useEffect(() => {
Prism.highlightAll();
}, [children]);
return (
<div className="group relative mt-4 rounded-md text-sm text-slate-200">
<DocumentDuplicateIcon
className="absolute right-4 top-4 z-20 h-5 w-5 cursor-pointer text-slate-600 opacity-0 transition-all duration-150 group-hover:opacity-60"
onClick={() => {
const childText = children?.toString() || "";
navigator.clipboard.writeText(childText);
toast.success("Copied to clipboard");
}}
/>
{showCopyToClipboard && (
<DocumentDuplicateIcon
className="absolute right-4 top-4 z-20 h-5 w-5 cursor-pointer text-slate-600 opacity-0 transition-all duration-150 group-hover:opacity-60"
onClick={() => {
const childText = children?.toString() || "";
navigator.clipboard.writeText(childText);
toast.success("Copied to clipboard");
}}
/>
)}
<pre>
<code className={cn(`language-${language} whitespace-pre-wrap`, customCodeClass)}>{children}</code>
</pre>
+2
View File
@@ -24,6 +24,7 @@
"@json2csv/node": "^7.0.3",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-collapsible": "^1.0.3",
"@react-email/components": "^0.0.7",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@sentry/nextjs": "^7.72.0",
"@t3-oss/env-nextjs": "^0.6.1",
@@ -42,6 +43,7 @@
"react": "18.2.0",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "18.2.0",
"react-email": "^1.9.4",
"react-hook-form": "^7.46.2",
"react-hot-toast": "^2.4.1",
"react-icons": "^4.11.0",
+15 -4
View File
@@ -1,9 +1,9 @@
import { getQuestionResponseMapping } from "../responses";
import { WEBAPP_URL } from "../constants";
import { withEmailTemplate } from "./email-template";
import { createInviteToken, createToken } from "../jwt";
import { Question } from "@formbricks/types/questions";
import { TResponse } from "@formbricks/types/v1/responses";
import { WEBAPP_URL } from "../constants";
import { createInviteToken, createToken } from "../jwt";
import { getQuestionResponseMapping } from "../responses";
import { withEmailTemplate } from "./email-template";
const nodemailer = require("nodemailer");
@@ -170,3 +170,14 @@ export const sendResponseFinishedEmail = async (
`),
});
};
export const sendEmbedSurveyPreviewEmail = async (to: string, subject: string, html: string) => {
await sendEmail({
to: to,
subject: subject,
html: withEmailTemplate(`
<h1>Preview Email Embed</h1>
<p>This is how the code snippet looks embedded into an email:</p>
${html}`),
});
};
+1750 -333
View File
File diff suppressed because it is too large Load Diff