feat: add option to overwrite color and position for single surveys (#645)

* feat: styles ui

* feat: handling brand color and position

* schema update

* survey type update

* feat: local styles preview

* prisma migration

* feat: overriding global settings

* fix: update wording

* fix: do not show positioning in link mode

* update wording

* feat: new component for placement ui

* fix: destructuring localsurvey

* fix: overwritePosition optional

* fix: brandcolor zod validation

* feat: overwrite highlight border setting

* update wording

* merge conflit resolved

* fix: minor review suggestions

* fix: renamed localsurvey to survey

* fix: db schema

* fix comment in .env.example

* fix build errors

* fix: placement

* fix getFirstEnvironmentByUserId function

* fix build errors

---------

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
Joe
2023-10-04 22:39:48 +05:30
committed by GitHub
parent de40dc36e6
commit 59752f3ebe
26 changed files with 428 additions and 40 deletions
@@ -79,6 +79,9 @@ export async function duplicateSurveyAction(environmentId: string, surveyId: str
singleUse: existingSurvey.singleUse
? JSON.parse(JSON.stringify(existingSurvey.singleUse))
: prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites
? JSON.parse(JSON.stringify(existingSurvey.productOverwrites))
: prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
: prismaClient.JsonNull,
@@ -228,6 +231,7 @@ export async function copyToOtherEnvironmentAction(
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});
@@ -2,11 +2,11 @@
import { cn } from "@formbricks/lib/cn";
import { Button, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
import { getPlacementStyle } from "@/lib/preview";
import { PlacementType } from "@formbricks/types/js";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { useState } from "react";
import toast from "react-hot-toast";
import { updateProductAction } from "./actions";
const placements = [
@@ -35,7 +35,9 @@ export function EditPlacement({ product }: EditPlacementProps) {
darkOverlay: overlay === "darkOverlay",
clickOutsideClose: clickOutside === "allow",
};
await updateProductAction(product.id, inputProduct);
toast.success("Placement updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
@@ -15,6 +15,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
if (!product) {
throw new Error("Product not found");
}
return (
<div>
<SettingsTitle title="Look & Feel" />
@@ -11,20 +11,23 @@ import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
import { ComputerDesktopIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/solid";
import { useEffect, useRef, useState } from "react";
type TPreviewType = "modal" | "fullwidth" | "email";
interface PreviewSurveyProps {
survey: TSurvey | Survey;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
previewType?: "modal" | "fullwidth" | "email";
previewType?: TPreviewType;
product: TProduct;
environment: TEnvironment;
}
let surveyNameTemp;
export default function PreviewSurvey({
survey,
setActiveQuestionId,
activeQuestionId,
survey,
previewType,
product,
environment,
@@ -34,6 +37,18 @@ export default function PreviewSurvey({
const [previewMode, setPreviewMode] = useState("desktop");
const ContentRef = useRef<HTMLDivElement | null>(null);
const { productOverwrites } = survey || {};
const {
brandColor: surveyBrandColor,
highlightBorderColor: surveyHighlightBorderColor,
placement: surveyPlacement,
} = productOverwrites || {};
const brandColor = surveyBrandColor || product.brandColor;
const placement = surveyPlacement || product.placement;
const highlightBorderColor = surveyHighlightBorderColor || product.highlightBorderColor;
useEffect(() => {
// close modal if there are no questions left
if (survey.type === "web" && !survey.thankYouCard.enabled) {
@@ -52,6 +67,8 @@ export default function PreviewSurvey({
resetQuestionProgress();
surveyNameTemp = survey.name;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey]);
function resetQuestionProgress() {
@@ -94,12 +111,12 @@ export default function PreviewSurvey({
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
placement={placement}
highlightBorderColor={highlightBorderColor}
previewMode="mobile">
<SurveyInline
survey={survey}
brandColor={product.brandColor}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
@@ -114,7 +131,7 @@ export default function PreviewSurvey({
<div className="w-full max-w-md px-4">
<SurveyInline
survey={survey}
brandColor={product.brandColor}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
@@ -143,12 +160,12 @@ export default function PreviewSurvey({
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
placement={placement}
highlightBorderColor={highlightBorderColor}
previewMode="desktop">
<SurveyInline
survey={survey}
brandColor={product.brandColor}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
@@ -161,7 +178,7 @@ export default function PreviewSurvey({
<div className="w-full max-w-md">
<SurveyInline
survey={survey}
brandColor={product.brandColor}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
formbricksSignature={product.formbricksSignature}
onActiveQuestionChange={setActiveQuestionId}
@@ -0,0 +1,101 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { getPlacementStyle } from "@/lib/preview";
import { PlacementType } from "@formbricks/types/js";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
{ name: "Top Left", value: "topLeft", disabled: false },
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
{ name: "Centered Modal", value: "center", disabled: false },
];
type TPlacementProps = {
currentPlacement: PlacementType;
setCurrentPlacement: (placement: PlacementType) => void;
setOverlay: (overlay: string) => void;
overlay: string;
setClickOutside: (clickOutside: boolean) => void;
clickOutside: boolean;
};
export default function Placement({
setCurrentPlacement,
currentPlacement,
setOverlay,
overlay,
setClickOutside,
clickOutside,
}: TPlacementProps) {
return (
<>
<div className="flex">
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
<Label
htmlFor={placement.value}
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
{placement.name}
</Label>
</div>
))}
</RadioGroup>
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
<div
className={cn(
"absolute h-16 w-16 rounded bg-slate-700",
getPlacementStyle(currentPlacement)
)}></div>
</div>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Centered modal overlay color</Label>
<RadioGroup
onValueChange={(overlay) => setOverlay(overlay)}
value={overlay}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="light" />
<Label htmlFor="lightOverlay" className="text-slate-900">
Light Overlay
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="dark" />
<Label htmlFor="darkOverlay" className="text-slate-900">
Dark Overlay
</Label>
</div>
</RadioGroup>
</div>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
<RadioGroup
onValueChange={(value) => setClickOutside(value === "allow")}
value={clickOutside ? "allow" : "disallow"}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
Don&apos;t Allow
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
Allow
</Label>
</div>
</RadioGroup>
</div>
</>
)}
</>
);
}
@@ -3,6 +3,7 @@ import RecontactOptionsCard from "./RecontactOptionsCard";
import ResponseOptionsCard from "./ResponseOptionsCard";
import WhenToSendCard from "./WhenToSendCard";
import WhoToSendCard from "./WhoToSendCard";
import StylingCard from "./StylingCard";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { TEnvironment } from "@formbricks/types/v1/environment";
import { TActionClass } from "@formbricks/types/v1/actionClasses";
@@ -54,6 +55,8 @@ export default function SettingsView({
setLocalSurvey={setLocalSurvey}
environmentId={environment.id}
/>
<StylingCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import Placement from "./Placement";
import { PlacementType } from "@formbricks/types/js";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { ColorPicker, Label, Switch } from "@formbricks/ui";
import { CheckCircleIcon } from "@heroicons/react/24/solid";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useState } from "react";
interface StylingCardProps {
localSurvey: TSurveyWithAnalytics;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurveyWithAnalytics>>;
}
export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCardProps) {
const [open, setOpen] = useState(false);
const { type, productOverwrites } = localSurvey;
const { brandColor, clickOutside, darkOverlay, placement, highlightBorderColor } = productOverwrites ?? {};
const togglePlacement = () => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
placement: !!placement ? null : "bottomRight",
},
});
};
const toggleBrandColor = () => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
brandColor: !!brandColor ? null : "#64748b",
},
});
};
const toggleHighlightBorderColor = () => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
highlightBorderColor: !!highlightBorderColor ? null : "#64748b",
},
});
};
const handleColorChange = (color: string) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
brandColor: color,
},
});
};
const handleBorderColorChange = (color: string) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
highlightBorderColor: color,
},
});
};
const handlePlacementChange = (placement: PlacementType) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
placement,
},
});
};
const handleOverlay = (overlayType: string) => {
const darkOverlay = overlayType === "dark";
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
darkOverlay,
},
});
};
const handleClickOutside = (clickOutside: boolean) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
clickOutside,
},
});
};
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckCircleIcon className="h-8 w-8 text-green-400" />
</div>
<div>
<p className="font-semibold text-slate-800">Styling</p>
<p className="mt-1 truncate text-sm text-slate-500">Overwrite global styling settings</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="p-3">
{/* Brand Color */}
<div className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch id="autoComplete" checked={!!brandColor} onCheckedChange={toggleBrandColor} />
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Overwrite Brand Color</h3>
<p className="text-xs font-normal text-slate-500">Change the main color for this survey.</p>
</div>
</Label>
</div>
{brandColor && (
<div className="ml-2 mt-4 rounded-lg border bg-slate-50 p-4">
<div className="w-full max-w-xs">
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={brandColor} onChange={handleColorChange} />
</div>
</div>
)}
</div>
{/* positioning */}
{type !== "link" && (
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch id="surveyDeadline" checked={!!placement} onCheckedChange={togglePlacement} />
<Label htmlFor="surveyDeadline" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Overwrite Placement</h3>
<p className="text-xs font-normal text-slate-500">Change the placement of this survey.</p>
</div>
</Label>
</div>
{placement && (
<div className="ml-2 mt-4 flex items-center space-x-1 pb-4">
<div className="flex w-full cursor-pointer items-center rounded-lg border bg-slate-50 p-4">
<div className="w-full items-center">
<Placement
currentPlacement={placement}
setCurrentPlacement={handlePlacementChange}
setOverlay={handleOverlay}
overlay={darkOverlay ? "dark" : "light"}
setClickOutside={handleClickOutside}
clickOutside={!!clickOutside}
/>
</div>
</div>
</div>
)}
</div>
)}
{/* Highlight border */}
{type !== "link" && (
<div className="p-3 ">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="autoComplete"
checked={!!highlightBorderColor}
onCheckedChange={toggleHighlightBorderColor}
/>
<Label htmlFor="autoComplete" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Overwrite border highlight</h3>
<p className="text-xs font-normal text-slate-500">
Change the border highlight for this survey.
</p>
</div>
</Label>
</div>
{!!highlightBorderColor && (
<div className="ml-2 mt-4 rounded-lg border bg-slate-50 p-4">
<div className="flex items-center space-x-2">
<Switch
id="highlightBorder"
checked={!!highlightBorderColor}
onCheckedChange={toggleHighlightBorderColor}
/>
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
</div>
{!!highlightBorderColor && (
<div className="mt-6 w-full max-w-xs">
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={highlightBorderColor || ""} onChange={handleBorderColorChange} />
</div>
)}
</div>
)}
</div>
)}
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}
@@ -51,6 +51,8 @@ export default function SurveyEditor({
if (localSurvey?.questions?.length && localSurvey.questions.length > 0) {
setActiveQuestionId(localSurvey.questions[0].id);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type]);
if (!localSurvey) {
@@ -2062,5 +2062,6 @@ export const minimalSurvey: TSurvey = {
surveyClosedMessage: {
enabled: false,
},
productOverwrites: null,
singleUse: null,
};
+8 -2
View File
@@ -13,8 +13,14 @@ export default async function OnboardingPage() {
if (!session) {
throw new Error("No session found");
}
const environment = await getFirstEnvironmentByUserId(session?.user.id);
const profile = await getProfile(session?.user.id!);
const userId = session?.user.id;
const environment = await getFirstEnvironmentByUserId(userId);
if (!environment) {
throw new Error("No environment found for user");
}
const profile = await getProfile(userId);
const product = await getProductByEnvironmentId(environment?.id!);
if (!environment || !profile || !product) {
+3 -1
View File
@@ -46,6 +46,8 @@ export default function LinkSurvey({
? getPrefillResponseData(survey.questions[0], survey, prefillAnswer)
: undefined;
const brandColor = survey.productOverwrites?.brandColor || product.brandColor;
const responseQueue = useMemo(
() =>
new ResponseQueue(
@@ -110,7 +112,7 @@ export default function LinkSurvey({
)}
<SurveyInline
survey={survey}
brandColor={product.brandColor}
brandColor={brandColor}
formbricksSignature={product.formbricksSignature}
onDisplay={async () => {
if (!isPreview) {
@@ -147,6 +147,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});
@@ -67,6 +67,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB