feat: add image/color/animation as survey background (#1515)

Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Neil Chauhan <neilchauhan2@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anjy Gupta
2023-12-04 23:52:28 +05:30
committed by GitHub
parent 35a9685b71
commit 9271e375af
27 changed files with 625 additions and 82 deletions

View File

@@ -204,6 +204,7 @@ export async function copyToOtherEnvironmentAction(
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
styling: existingSurvey.styling ?? prismaClient.JsonNull,
},
});

View File

@@ -0,0 +1,107 @@
import { TSurvey } from "@formbricks/types/surveys";
import { useState } from "react";
interface AnimatedSurveyBgProps {
localSurvey?: TSurvey;
handleBgChange: (bg: string, bgType: string) => void;
}
export default function AnimatedSurveyBg({ localSurvey, handleBgChange }: AnimatedSurveyBgProps) {
const [color, setColor] = useState(localSurvey?.styling?.background?.bg || "#ffff");
const [hoveredVideo, setHoveredVideo] = useState<number | null>(null);
const animationFiles = {
"/animated-bgs/Thumbnails/1_Thumb.mp4": "/animated-bgs/4K/1_4k.mp4",
"/animated-bgs/Thumbnails/2_Thumb.mp4": "/animated-bgs/4K/2_4k.mp4",
"/animated-bgs/Thumbnails/3_Thumb.mp4": "/animated-bgs/4K/3_4k.mp4",
"/animated-bgs/Thumbnails/4_Thumb.mp4": "/animated-bgs/4K/4_4k.mp4",
"/animated-bgs/Thumbnails/5_Thumb.mp4": "/animated-bgs/4K/5_4k.mp4",
"/animated-bgs/Thumbnails/6_Thumb.mp4": "/animated-bgs/4K/6_4k.mp4",
"/animated-bgs/Thumbnails/7_Thumb.mp4": "/animated-bgs/4K/7_4k.mp4",
"/animated-bgs/Thumbnails/8_Thumb.mp4": "/animated-bgs/4K/8_4k.mp4",
"/animated-bgs/Thumbnails/9_Thumb.mp4": "/animated-bgs/4K/9_4k.mp4",
"/animated-bgs/Thumbnails/10_Thumb.mp4": "/animated-bgs/4K/10_4k.mp4",
"/animated-bgs/Thumbnails/11_Thumb.mp4": "/animated-bgs/4K/11_4k.mp4",
"/animated-bgs/Thumbnails/12_Thumb.mp4": "/animated-bgs/4K/12_4k.mp4",
"/animated-bgs/Thumbnails/13_Thumb.mp4": "/animated-bgs/4K/13_4k.mp4",
"/animated-bgs/Thumbnails/14_Thumb.mp4": "/animated-bgs/4K/14_4k.mp4",
"/animated-bgs/Thumbnails/15_Thumb.mp4": "/animated-bgs/4K/15_4k.mp4",
"/animated-bgs/Thumbnails/16_Thumb.mp4": "/animated-bgs/4K/16_4k.mp4",
"/animated-bgs/Thumbnails/17_Thumb.mp4": "/animated-bgs/4K/17_4k.mp4",
"/animated-bgs/Thumbnails/18_Thumb.mp4": "/animated-bgs/4K/18_4k.mp4",
"/animated-bgs/Thumbnails/19_Thumb.mp4": "/animated-bgs/4K/19_4k.mp4",
"/animated-bgs/Thumbnails/20_Thumb.mp4": "/animated-bgs/4K/20_4k.mp4",
"/animated-bgs/Thumbnails/21_Thumb.mp4": "/animated-bgs/4K/21_4k.mp4",
"/animated-bgs/Thumbnails/22_Thumb.mp4": "/animated-bgs/4K/22_4k.mp4",
"/animated-bgs/Thumbnails/23_Thumb.mp4": "/animated-bgs/4K/23_4k.mp4",
"/animated-bgs/Thumbnails/24_Thumb.mp4": "/animated-bgs/4K/24_4k.mp4",
"/animated-bgs/Thumbnails/25_Thumb.mp4": "/animated-bgs/4K/25_4k.mp4",
"/animated-bgs/Thumbnails/26_Thumb.mp4": "/animated-bgs/4K/26_4k.mp4",
"/animated-bgs/Thumbnails/27_Thumb.mp4": "/animated-bgs/4K/27_4k.mp4",
"/animated-bgs/Thumbnails/28_Thumb.mp4": "/animated-bgs/4K/28_4k.mp4",
"/animated-bgs/Thumbnails/29_Thumb.mp4": "/animated-bgs/4K/29_4k.mp4",
"/animated-bgs/Thumbnails/30_Thumb.mp4": "/animated-bgs/4K/30_4k.mp4",
};
const handleMouseEnter = (index: number) => {
setHoveredVideo(index);
playVideo(index);
};
const handleMouseLeave = (index: number) => {
setHoveredVideo(null);
pauseVideo(index);
};
// Function to play the video
const playVideo = (index: number) => {
const video = document.getElementById(`video-${index}`) as HTMLVideoElement;
if (video) {
video.play();
}
};
// Function to pause the video
const pauseVideo = (index: number) => {
const video = document.getElementById(`video-${index}`) as HTMLVideoElement;
if (video) {
video.pause();
}
};
const handleBg = (x: string) => {
setColor(x);
handleBgChange(x, "animation");
};
return (
<div>
<div className="mt-4 grid grid-cols-6 gap-4">
{Object.keys(animationFiles).map((key, index) => {
const value = animationFiles[key];
return (
<div
key={index}
onMouseEnter={() => handleMouseEnter(index)}
onMouseLeave={() => handleMouseLeave(index)}
onClick={() => handleBg(value)}
className="relative cursor-pointer overflow-hidden rounded-lg">
<video
disablePictureInPicture
id={`video-${index}`}
autoPlay={hoveredVideo === index}
className="h-46 w-96 origin-center scale-105 transform">
<source src={`${key}`} type="video/mp4" />
</video>
<input
className="absolute right-2 top-2 h-4 w-4 rounded-sm bg-white "
type="checkbox"
checked={color === value}
onChange={() => handleBg(value)}
/>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { TSurvey } from "@formbricks/types/surveys";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { useState } from "react";
interface ColorSurveyBgBgProps {
localSurvey?: TSurvey;
handleBgChange: (bg: string, bgType: string) => void;
colours: string[];
}
export default function ColorSurveyBg({ localSurvey, handleBgChange, colours }: ColorSurveyBgBgProps) {
const [color, setColor] = useState(localSurvey?.styling?.background?.bg || "#ffff");
const handleBg = (x: string) => {
setColor(x);
handleBgChange(x, "color");
};
return (
<div>
<div className="w-full max-w-xs py-2">
<ColorPicker color={color} onChange={handleBg} />
</div>
<div className="grid grid-cols-4 gap-4 md:grid-cols-5 xl:grid-cols-8 2xl:grid-cols-10">
{colours.map((x) => {
return (
<div
className={`h-16 w-16 cursor-pointer rounded-lg ${
color === x ? "border-4 border-slate-500" : ""
}`}
key={x}
style={{ backgroundColor: `${x}` }}
onClick={() => handleBg(x)}></div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import FileInput from "@formbricks/ui/FileInput";
import { TSurvey } from "@formbricks/types/surveys";
interface ImageSurveyBgBgProps {
localSurvey?: TSurvey;
handleBgChange: (url: string, bgType: string) => void;
}
export default function ImageSurveyBg({ localSurvey, handleBgChange }: ImageSurveyBgBgProps) {
const isUrl = (str: string) => {
try {
new URL(str);
return true;
} catch (error) {
return false;
}
};
const fileUrl = isUrl(localSurvey?.styling?.background?.bg ?? "")
? localSurvey?.styling?.background?.bg ?? ""
: "";
return (
<div className="mb-2 mt-4 w-full rounded-lg border bg-slate-50 p-4">
<div className="flex w-full items-center justify-center">
<FileInput
id="survey-bg-file-input"
allowedFileExtensions={["png", "jpeg", "jpg"]}
environmentId={localSurvey?.environmentId}
onFileUpload={(url: string[]) => {
if (url.length > 0) {
handleBgChange(url[0], "image");
} else {
handleBgChange("#ffff", "color");
}
}}
fileUrl={fileUrl}
/>
</div>
</div>
);
}

View File

@@ -18,6 +18,7 @@ interface SettingsViewProps {
attributeClasses: TAttributeClass[];
responseCount: number;
membershipRole?: TMembershipRole;
colours: string[];
}
export default function SettingsView({
@@ -28,6 +29,7 @@ export default function SettingsView({
attributeClasses,
responseCount,
membershipRole,
colours,
}: SettingsViewProps) {
return (
<div className="mt-12 space-y-3 p-5">
@@ -60,7 +62,7 @@ export default function SettingsView({
environmentId={environment.id}
/>
<StylingCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
<StylingCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} colours={colours} />
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { TPlacement } from "@formbricks/types/common";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurvey, TSurveyBackgroundBgType } from "@formbricks/types/surveys";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
@@ -9,18 +9,28 @@ import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
import Placement from "./Placement";
import SurveyBgSelectorTab from "./SurveyBgSelectorTab";
interface StylingCardProps {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
colours: string[];
}
export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCardProps) {
export default function StylingCard({ localSurvey, setLocalSurvey, colours }: StylingCardProps) {
const [open, setOpen] = useState(false);
const { type, productOverwrites } = localSurvey;
const { type, productOverwrites, styling } = localSurvey;
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
productOverwrites ?? {};
const { bg, bgType, brightness } = styling?.background ?? {};
const [inputValue, setInputValue] = useState(100);
const handleInputChange = (e) => {
setInputValue(e.target.value);
handleBrightnessChange(parseInt(e.target.value));
};
const togglePlacement = () => {
setLocalSurvey({
@@ -42,6 +52,34 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
});
};
const toggleBackgroundColor = () => {
setLocalSurvey({
...localSurvey,
styling: {
...localSurvey.styling,
background: {
...localSurvey.styling?.background,
bg: !!bg ? undefined : "#ffff",
bgType: !!bg ? undefined : "color",
},
},
});
};
const toggleBrightness = () => {
setLocalSurvey({
...localSurvey,
styling: {
...localSurvey.styling,
background: {
...localSurvey.styling?.background,
brightness: !!brightness ? undefined : 100,
},
},
});
setInputValue(100);
};
const toggleHighlightBorderColor = () => {
setLocalSurvey({
...localSurvey,
@@ -62,6 +100,35 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
});
};
const handleBgChange = (color: string, type: TSurveyBackgroundBgType) => {
setInputValue(100);
setLocalSurvey({
...localSurvey,
styling: {
...localSurvey.styling,
background: {
...localSurvey.styling?.background,
bg: color,
bgType: type,
brightness: undefined,
},
},
});
};
const handleBrightnessChange = (percent: number) => {
setLocalSurvey({
...localSurvey,
styling: {
...(localSurvey.styling || {}),
background: {
...localSurvey.styling?.background,
brightness: percent,
},
},
});
};
const handleBorderColorChange = (color: string) => {
setLocalSurvey({
...localSurvey,
@@ -143,6 +210,66 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
</div>
)}
</div>
{type == "link" && (
<>
{/* Background */}
<div className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch id="autoCompleteBg" checked={!!bg} onCheckedChange={toggleBackgroundColor} />
<Label htmlFor="autoCompleteBg" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Change Background</h3>
<p className="text-xs font-normal text-slate-500">
Pick a background from our library or upload your own.
</p>
</div>
</Label>
</div>
{bg && (
<SurveyBgSelectorTab
localSurvey={localSurvey}
handleBgChange={handleBgChange}
colours={colours}
bgType={bgType}
/>
)}
</div>
{/* Overlay */}
<div className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="autoCompleteOverlay"
checked={!!brightness}
onCheckedChange={toggleBrightness}
/>
<Label htmlFor="autoCompleteOverlay" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Background Overlay</h3>
<p className="text-xs font-normal text-slate-500">
Darken or lighten background of your choice.
</p>
</div>
</Label>
</div>
{brightness && (
<div>
<div className="mt-4 flex flex-col justify-center rounded-lg border bg-slate-50 p-4 px-8">
<h3 className="mb-4 text-sm font-semibold text-slate-700">Transparency</h3>
<input
id="small-range"
type="range"
min="1"
max="200"
value={inputValue}
onChange={handleInputChange}
className="range-sm mb-6 h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
/>
</div>
</div>
)}
</div>
</>
)}
{/* positioning */}
{type !== "link" && (
<div className="p-3 ">

View File

@@ -0,0 +1,61 @@
import { TSurvey } from "@formbricks/types/surveys";
import { useState } from "react";
import AnimatedSurveyBg from "./AnimatedSurveyBg";
import ColorSurveyBg from "./ColorSurveyBg";
import ImageSurveyBg from "./ImageSurveyBg";
interface SurveyBgSelectorTabProps {
localSurvey: TSurvey;
handleBgChange: (bg: string, bgType: string) => void;
colours: string[];
bgType: string | null | undefined;
}
const TabButton = ({ isActive, onClick, children }) => (
<button
className={`w-1/4 rounded-md p-2 text-sm font-medium leading-none text-slate-800 ${
isActive ? "bg-white shadow-sm" : ""
}`}
onClick={onClick}>
{children}
</button>
);
export default function SurveyBgSelectorTab({
localSurvey,
handleBgChange,
colours,
bgType,
}: SurveyBgSelectorTabProps) {
const [tab, setTab] = useState(bgType || "image");
const renderContent = () => {
switch (tab) {
case "image":
return <ImageSurveyBg localSurvey={localSurvey} handleBgChange={handleBgChange} />;
case "animation":
return <AnimatedSurveyBg localSurvey={localSurvey} handleBgChange={handleBgChange} />;
case "color":
return <ColorSurveyBg localSurvey={localSurvey} handleBgChange={handleBgChange} colours={colours} />;
default:
return null;
}
};
return (
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border bg-slate-50 p-4 px-8">
<div className="flex w-full items-center justify-between rounded-lg border border-slate-300 bg-slate-50 px-6 py-1.5">
<TabButton isActive={tab === "image"} onClick={() => setTab("image")}>
Image
</TabButton>
<TabButton isActive={tab === "animation"} onClick={() => setTab("animation")}>
Animation
</TabButton>
<TabButton isActive={tab === "color"} onClick={() => setTab("color")}>
Color
</TabButton>
</div>
{renderContent()}
</div>
);
}

View File

@@ -23,6 +23,7 @@ interface SurveyEditorProps {
attributeClasses: TAttributeClass[];
responseCount: number;
membershipRole?: TMembershipRole;
colours: string[];
}
export default function SurveyEditor({
@@ -33,6 +34,7 @@ export default function SurveyEditor({
attributeClasses,
responseCount,
membershipRole,
colours,
}: SurveyEditorProps): JSX.Element {
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
@@ -99,6 +101,7 @@ export default function SurveyEditor({
attributeClasses={attributeClasses}
responseCount={responseCount}
membershipRole={membershipRole}
colours={colours}
/>
)}
</main>

View File

@@ -12,6 +12,7 @@ import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { getServerSession } from "next-auth";
import { colours } from "@formbricks/lib/constants";
import SurveyEditor from "./components/SurveyEditor";
export const generateMetadata = async ({ params }) => {
@@ -67,6 +68,7 @@ export default async function SurveysEditPage({ params }) {
attributeClasses={attributeClasses}
responseCount={responseCount}
membershipRole={currentUserMembership?.role}
colours={colours}
/>
</>
);

View File

@@ -56,7 +56,7 @@ export default function Modal({
ref={modalRef}
style={highlightBorderColorStyle}
className={cn(
"pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
"pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out ",
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full ",
slidingAnimationClass
)}>

View File

@@ -2,7 +2,7 @@
import Modal from "@/app/(app)/environments/[environmentId]/surveys/components/Modal";
import TabOption from "@/app/(app)/environments/[environmentId]/surveys/components/TabOption";
import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
import type { TEnvironment } from "@formbricks/types/environment";
import type { TProduct } from "@formbricks/types/product";
import { TUploadFileConfig } from "@formbricks/types/storage";
@@ -155,6 +155,20 @@ export default function PreviewSurvey({
setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id);
}
function animationTrigger() {
let storePreviewMode = previewMode;
setPreviewMode("null");
setTimeout(() => {
setPreviewMode(storePreviewMode);
}, 10);
}
useEffect(() => {
if (survey.styling?.background?.bgType === "animation") {
animationTrigger();
}
}, [survey.styling?.background?.bg]);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
@@ -194,7 +208,7 @@ export default function PreviewSurvey({
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</div>
<div className="relative h-[90%] max-h-[40rem] w-80 overflow-hidden rounded-[3rem] border-8 border-slate-500 bg-slate-400">
<MediaBackground survey={survey} ContentRef={ContentRef} isMobilePreview>
{/* below element is use to create notch for the mobile device mockup */}
<div className="absolute left-1/2 right-1/2 top-0 z-20 h-4 w-1/2 -translate-x-1/2 transform rounded-b-md bg-slate-500"></div>
{previewType === "modal" ? (
@@ -214,25 +228,19 @@ export default function PreviewSurvey({
/>
</Modal>
) : (
<div
className="absolute top-0 z-10 flex h-full w-full flex-grow flex-col overflow-y-auto"
ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md px-4">
<SurveyInline
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
onFileUpload={onFileUpload}
responseCount={42}
/>
</div>
</div>
<div className="relative z-10 w-full max-w-md px-4">
<SurveyInline
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
onFileUpload={onFileUpload}
responseCount={42}
/>
</div>
)}
</div>
</MediaBackground>
</>
)}
{previewMode === "desktop" && (
@@ -287,22 +295,20 @@ export default function PreviewSurvey({
/>
</Modal>
) : (
<div className="flex flex-grow flex-col overflow-y-auto rounded-b-lg" ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white p-4 py-6">
<div className="w-full max-w-md">
<SurveyInline
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
responseCount={42}
/>
</div>
<MediaBackground survey={survey} ContentRef={ContentRef} isEditorView>
<div className="z-0 w-full max-w-md rounded-lg p-4">
<SurveyInline
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
responseCount={42}
/>
</div>
</div>
</MediaBackground>
)}
</div>
)}

View File

@@ -2526,4 +2526,5 @@ export const minimalSurvey: TSurvey = {
},
productOverwrites: null,
singleUse: null,
styling: null,
};

View File

@@ -1,19 +1,28 @@
import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
import Link from "next/link";
export default function LegalFooter() {
interface LegalFooterProps {
bgColor?: string | null;
}
export default function LegalFooter({ bgColor }: LegalFooterProps) {
if (!IMPRINT_URL && !PRIVACY_URL) return null;
return (
<div className="h-10 w-full border-t border-slate-200">
<div className="mx-auto max-w-lg p-3 text-center text-sm text-slate-400">
<div
className={`fixed bottom-0 h-12 w-full`}
style={{
backgroundColor: `${bgColor}`,
}}>
<div className="mx-auto max-w-lg p-3 text-center text-xs text-slate-400">
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank">
<Link href={IMPRINT_URL} target="_blank" className="hover:underline">
Imprint
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && <span> | </span>}
{IMPRINT_URL && PRIVACY_URL && <span className="px-2">|</span>}
{PRIVACY_URL && (
<Link href={PRIVACY_URL} target="_blank">
<Link href={PRIVACY_URL} target="_blank" className="hover:underline">
Privacy Policy
</Link>
)}

View File

@@ -1,20 +1,20 @@
"use client";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
import { SurveyInline } from "@formbricks/ui/Survey";
import SurveyLinkUsed from "@/app/s/[surveyId]/components/SurveyLinkUsed";
import VerifyEmail from "@/app/s/[surveyId]/components/VerifyEmail";
import { getPrefillResponseData } from "@/app/s/[surveyId]/lib/prefilling";
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { TProduct } from "@formbricks/types/product";
import { TResponse, TResponseData, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys";
import ContentWrapper from "@formbricks/ui/ContentWrapper";
import { SurveyInline } from "@formbricks/ui/Survey";
import { ArrowPathIcon } from "@heroicons/react/24/solid";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { FormbricksAPI } from "@formbricks/api";
import { TUploadFileConfig } from "@formbricks/types/storage";
interface LinkSurveyProps {
survey: TSurvey;
@@ -119,7 +119,7 @@ export default function LinkSurvey({
return (
<>
<ContentWrapper className="h-full w-full p-0 md:max-w-lg">
<ContentWrapper className="h-full w-full p-0 md:max-w-md">
{isPreview && (
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />

View File

@@ -0,0 +1,87 @@
"use client";
import { TSurvey } from "@formbricks/types/surveys";
import React from "react";
interface MediaBackgroundProps {
children: React.ReactNode;
survey: TSurvey;
isEditorView?: boolean;
isMobilePreview?: boolean;
ContentRef?: React.RefObject<HTMLDivElement>;
}
export const MediaBackground: React.FC<MediaBackgroundProps> = ({
children,
survey,
isEditorView = false,
isMobilePreview = false,
ContentRef,
}) => {
const getFilterStyle = () => {
return survey.styling?.background?.brightness
? `brightness-${survey.styling?.background?.brightness}`
: "";
};
const renderBackground = () => {
const filterStyle = getFilterStyle();
const baseClasses = "absolute inset-0 h-full w-full";
switch (survey.styling?.background?.bgType) {
case "color":
return (
<div
className={`${baseClasses} ${filterStyle}`}
style={{ backgroundColor: survey.styling?.background?.bg || "#ffff" }}
/>
);
case "animation":
return (
<video muted loop autoPlay className={`${baseClasses} object-cover ${filterStyle}`}>
<source src={survey.styling?.background?.bg || ""} type="video/mp4" />
</video>
);
case "image":
return (
<div
className={`${baseClasses} bg-cover bg-center ${filterStyle}`}
style={{ backgroundImage: `url(${survey.styling?.background?.bg})` }}
/>
);
default:
return <div className={`${baseClasses} bg-white`} />;
}
};
const renderContent = () => (
<div className="absolute flex h-full w-full items-center justify-center overflow-y-auto">{children}</div>
);
if (isMobilePreview) {
return (
<div
ref={ContentRef}
className={`relative h-[90%] max-h-[40rem] w-80 overflow-hidden rounded-3xl border-8 border-slate-500 ${getFilterStyle()}`}>
{renderBackground()}
{renderContent()}
</div>
);
} else if (isEditorView) {
return (
<div ref={ContentRef} className="flex flex-grow flex-col overflow-y-auto rounded-b-lg">
<div className="relative flex w-full flex-grow flex-col items-center justify-center p-4 py-6">
{renderBackground()}
<div className="flex h-full w-full items-center justify-center">{children}</div>
</div>
</div>
);
} else {
return (
<div className="flex min-h-screen flex-col items-center justify-center px-2">
{renderBackground()}
<div className="relative w-full">{children}</div>
</div>
);
}
};

View File

@@ -1,15 +1,15 @@
"use client";
import type { NextPage } from "next";
import { validateSurveyPinAction } from "@/app/s/[surveyId]/actions";
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types";
import { cn } from "@formbricks/lib/cn";
import { TProduct } from "@formbricks/types/product";
import { TResponse } from "@formbricks/types/responses";
import { OTPInput } from "@formbricks/ui/OTPInput";
import { useCallback, useEffect, useState } from "react";
import { validateSurveyPinAction } from "@/app/s/[surveyId]/actions";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types";
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import { cn } from "@formbricks/lib/cn";
import { OTPInput } from "@formbricks/ui/OTPInput";
import type { NextPage } from "next";
import { useCallback, useEffect, useState } from "react";
interface LinkSurveyPinScreenProps {
surveyId: string;

View File

@@ -1,10 +1,3 @@
import LegalFooter from "@/app/s/[surveyId]/components/LegalFooter";
export default async function SurveyLayout({ children }) {
return (
<div className="flex h-full flex-col justify-between bg-white">
<div className="h-full overflow-y-auto">{children}</div>
<LegalFooter />
</div>
);
return <div>{children}</div>;
}

View File

@@ -1,7 +1,9 @@
export const revalidate = REVALIDATION_INTERVAL;
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
import LegalFooter from "@/app/s/[surveyId]/components/LegalFooter";
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
@@ -12,6 +14,7 @@ import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { TResponse } from "@formbricks/types/responses";
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getEmailVerificationStatus } from "./lib/helpers";
import { ZId } from "@formbricks/types/environment";
@@ -183,17 +186,22 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
);
}
return (
<LinkSurvey
survey={survey}
product={product}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
/>
);
return survey ? (
<div>
<MediaBackground survey={survey}>
<LinkSurvey
survey={survey}
product={product}
userId={userId}
emailVerificationStatus={emailVerificationStatus}
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
webAppUrl={WEBAPP_URL}
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
/>
</MediaBackground>
<LegalFooter bgColor={survey.styling?.background?.bg || "#ffff"} />
</div>
) : null;
}

View File

@@ -6,6 +6,7 @@ import {
TSurveyClosedMessage,
TSurveyHiddenFields,
TSurveyProductOverwrites,
TSurveyStyling,
TSurveyQuestions,
TSurveySingleUse,
TSurveyThankYouCard,
@@ -27,6 +28,7 @@ declare global {
export type SurveyThankYouCard = TSurveyThankYouCard;
export type SurveyHiddenFields = TSurveyHiddenFields;
export type SurveyProductOverwrites = TSurveyProductOverwrites;
export type SurveyStyling = TSurveyStyling;
export type SurveyClosedMessage = TSurveyClosedMessage;
export type SurveySingleUse = TSurveySingleUse;
export type SurveyVerifyEmail = TSurveyVerifyEmail;

View File

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

View File

@@ -289,6 +289,9 @@ model Survey {
/// @zod.custom(imports.ZSurveyProductOverwrites)
/// [SurveyProductOverwrites]
productOverwrites Json?
/// @zod.custom(imports.ZSurveyStyling)
/// [SurveyStyling]
styling Json?
/// @zod.custom(imports.ZSurveySingleUse)
/// [SurveySingleUse]
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")

View File

@@ -18,6 +18,7 @@ export {
ZSurveyHiddenFields,
ZSurveyClosedMessage,
ZSurveyProductOverwrites,
ZSurveyStyling,
ZSurveyVerifyEmail,
ZSurveySingleUse,
} from "@formbricks/types/surveys";

View File

@@ -71,6 +71,34 @@ export const IS_S3_CONFIGURED: boolean =
export const PRICING_USERTARGETING_FREE_MTU = 2500;
export const PRICING_APPSURVEYS_FREE_RESPONSES = 250;
// Colors for Survey Bg
export const colours = [
"#FFF2D8",
"#EAD7BB",
"#BCA37F",
"#113946",
"#04364A",
"#176B87",
"#64CCC5",
"#DAFFFB",
"#132043",
"#1F4172",
"#F1B4BB",
"#FDF0F0",
"#001524",
"#445D48",
"#D6CC99",
"#FDE5D4",
"#BEADFA",
"#D0BFFF",
"#DFCCFB",
"#FFF8C9",
"#FF8080",
"#FFCF96",
"#F6FDC3",
"#CDFAD5",
];
// Rate Limiting
export const SIGNUP_RATE_LIMIT = {
interval: 60 * 60 * 1000, // 60 minutes

View File

@@ -45,6 +45,7 @@ export const selectSurvey = {
verifyEmail: true,
redirectUrl: true,
productOverwrites: true,
styling: true,
surveyClosedMessage: true,
singleUse: true,
pin: true,
@@ -587,6 +588,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
productOverwrites: existingSurvey.productOverwrites
? JSON.parse(JSON.stringify(existingSurvey.productOverwrites))
: Prisma.JsonNull,
styling: existingSurvey.styling ? JSON.parse(JSON.stringify(existingSurvey.styling)) : Prisma.JsonNull,
verifyEmail: existingSurvey.verifyEmail
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
: Prisma.JsonNull,

View File

@@ -13,11 +13,10 @@ export default function Headline({
}: HeadlineProps) {
return (
<label htmlFor={questionId} className="text-heading mb-1.5 block text-base font-semibold leading-6">
<div
className={`flex items-center ${alignTextCenter ? "justify-center" : "mr-[3ch] justify-between"}`}>
<div className={`flex items-center ${alignTextCenter ? "justify-center" : "justify-between"}`}>
{headline}
{!required && (
<span className="text-info-text self-start text-sm font-normal leading-7" tabIndex={-1}>
<span className="text-info-text ml-2 self-start text-sm font-normal leading-7" tabIndex={-1}>
Optional
</span>
)}

View File

@@ -174,7 +174,7 @@ export function Survey({
return (
<>
<AutoCloseWrapper survey={survey} onClose={onClose}>
<div className="flex h-full w-full flex-col justify-between bg-[--fb-survey-background-color] px-6 pb-3 pt-6">
<div className="flex h-full w-full flex-col justify-between rounded-lg bg-[--fb-survey-background-color] px-6 pb-3 pt-6">
<div ref={contentRef} className={cn(loadingElement ? "animate-pulse opacity-60" : "", "my-auto")}>
{survey.questions.length === 0 && !survey.welcomeCard.enabled && !survey.thankYouCard.enabled ? (
// Handle the case when there are no questions and both welcome and thank you cards are disabled

View File

@@ -45,6 +45,24 @@ export const ZSurveyProductOverwrites = z.object({
export type TSurveyProductOverwrites = z.infer<typeof ZSurveyProductOverwrites>;
export const ZSurveyBackgroundBgType = z.enum(["animation", "color", "image"]);
export type TSurveyBackgroundBgType = z.infer<typeof ZSurveyBackgroundBgType>;
export const ZSurveyStylingBackground = z.object({
bg: z.string().nullish(),
bgType: z.enum(["animation", "color", "image"]).nullish(),
brightness: z.number().nullish(),
});
export type TSurveyStylingBackground = z.infer<typeof ZSurveyStylingBackground>;
export const ZSurveyStyling = z.object({
background: ZSurveyStylingBackground.nullish(),
});
export type TSurveyStyling = z.infer<typeof ZSurveyStyling>;
export const ZSurveyClosedMessage = z
.object({
enabled: z.boolean().optional(),
@@ -379,6 +397,7 @@ export const ZSurvey = z.object({
autoComplete: z.number().nullable(),
closeOnDate: z.date().nullable(),
productOverwrites: ZSurveyProductOverwrites.nullable(),
styling: ZSurveyStyling.nullable(),
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
singleUse: ZSurveySingleUse.nullable(),
verifyEmail: ZSurveyVerifyEmail.nullable(),