feat: Advanced Custom Styling (#2182)

Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-03-22 15:20:16 +05:30
committed by GitHub
parent f5ef6b9c02
commit fa370c6900
92 changed files with 2809 additions and 910 deletions

View File

@@ -96,7 +96,7 @@ export default function AppPage({}) {
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
</strong>
<span className="relative ml-2 flex h-3 w-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
</div>

View File

@@ -54,7 +54,7 @@ export function APILayout({ method, url, description, headers, bodies, responses
className={clsx(
"mr-3 inline rounded-full p-1 px-3 font-semibold text-white",
method === "POST" && "bg-red-400 dark:bg-red-800",
method === "GET" && "bg-green-400 dark:bg-green-800"
method === "GET" && "bg-green-500 dark:bg-green-800"
)}>
{method}
</div>
@@ -174,7 +174,7 @@ function Response({ color, statusCode, description, example }: RespProps) {
<div
className={clsx(
"mr-3 inline h-3 w-3 rounded-full",
color === "green" && "bg-green-400",
color === "green" && "bg-green-500",
color === "brown" && "bg-amber-800"
)}>
&nbsp;

View File

@@ -8,6 +8,7 @@ export default function SettingsCard({
soon = false,
noPadding = false,
beta,
className,
}: {
title: string;
description: string;
@@ -15,9 +16,10 @@ export default function SettingsCard({
soon?: boolean;
noPadding?: boolean;
beta?: boolean;
className?: string;
}) {
return (
<div className="my-4 w-full bg-white shadow sm:rounded-lg">
<div className={cn("my-4 w-full max-w-4xl bg-white shadow sm:rounded-lg", className)}>
<div className="border-b border-slate-200 bg-slate-100 px-6 py-5">
<div className="flex">
<h3 className="text-lg font-medium leading-6 text-slate-900">{title}</h3>

View File

@@ -49,7 +49,7 @@ export default async function SettingsLayout({ children, params }) {
isMultiLanguageAllowed={isMultiLanguageAllowed}
/>
<div className="w-full md:ml-64">
<div className="max-w-4xl px-20 pb-6 pt-14 md:pt-6">
<div className="max-w-7xl px-20 pb-6 pt-14 md:pt-6">
<div>{children}</div>
</div>
</div>

View File

@@ -1,52 +0,0 @@
"use client";
import { useState } from "react";
import toast from "react-hot-toast";
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { Label } from "@formbricks/ui/Label";
import { updateProductAction } from "../actions";
interface EditBrandColorProps {
product: TProduct;
isBrandColorDisabled: boolean;
environmentId: string;
}
export function EditBrandColor({ product, isBrandColorDisabled }: EditBrandColorProps) {
const [color, setColor] = useState(product.brandColor);
const [updatingColor, setUpdatingColor] = useState(false);
const handleUpdateBrandColor = async () => {
try {
if (isBrandColorDisabled) {
throw new Error("Only Owners, Admins and Editors can perform this action.");
}
setUpdatingColor(true);
let inputProduct: Partial<TProductUpdateInput> = {
brandColor: color,
};
await updateProductAction(product.id, inputProduct);
toast.success("Brand color updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingColor(false);
}
};
return !isBrandColorDisabled ? (
<div className="w-full max-w-sm items-center">
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={color} onChange={setColor} />
<Button variant="darkCTA" className="mt-4" loading={updatingColor} onClick={handleUpdateBrandColor}>
Save
</Button>
</div>
) : (
<p className="text-sm text-red-700">Only Owners, Admins and Editors can perform this action.</p>
);
}

View File

@@ -1,100 +0,0 @@
"use client";
import { useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
import { TProduct } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
import { updateProductAction } from "../actions";
interface EditHighlightBorderProps {
product: TProduct;
defaultBrandColor: string;
environmentId: string;
}
export const EditHighlightBorder = ({ product, defaultBrandColor }: EditHighlightBorderProps) => {
const [showHighlightBorder, setShowHighlightBorder] = useState(product.highlightBorderColor ? true : false);
const [color, setColor] = useState<string | null>(product.highlightBorderColor || defaultBrandColor);
const [updatingBorder, setUpdatingBorder] = useState(false);
const handleUpdateHighlightBorder = async () => {
try {
setUpdatingBorder(true);
await updateProductAction(product.id, { highlightBorderColor: color });
toast.success("Border color updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingBorder(false);
}
};
const handleSwitch = (checked: boolean) => {
if (checked) {
if (!color) {
setColor(defaultBrandColor);
setShowHighlightBorder(true);
} else {
setShowHighlightBorder(true);
}
} else {
setShowHighlightBorder(false);
setColor(null);
}
};
return (
<div className="flex min-h-full w-full flex-col md:flex-row">
<div className="flex w-full flex-col px-6 py-5 md:w-1/2">
<div className="mb-6 flex items-center space-x-2">
<Switch id="highlightBorder" checked={showHighlightBorder} onCheckedChange={handleSwitch} />
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
</div>
{showHighlightBorder && color ? (
<>
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={color} onChange={setColor} />
</>
) : null}
<Button
variant="darkCTA"
className="mt-4 flex max-w-[80px] items-center justify-center"
loading={updatingBorder}
onClick={handleUpdateHighlightBorder}>
Save
</Button>
</div>
<div className="mt-4 flex w-full flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5 md:mt-0 md:w-1/2">
<h3 className="text-slate-500">Preview</h3>
<div
className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}
{...(showHighlightBorder &&
color && {
style: {
borderColor: color,
},
})}>
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
<div className="grid grid-cols-5 rounded-xl border border-slate-400">
{[1, 2, 3, 4, 5].map((num) => (
<div
key={num}
className="flex justify-center border-r border-slate-400 px-3 py-2 last:border-r-0 lg:px-6 lg:py-5">
<span className="text-sm font-medium">{num}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,259 @@
"use client";
import { ThemeStylingPreviewSurvey } from "@/app/(app)/environments/[environmentId]/settings/lookandfeel/components/ThemeStylingPreviewSurvey";
import BackgroundStylingCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard";
import CardStylingSettings from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings";
import FormStylingSettings from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { COLOR_DEFAULTS, PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import AlertDialog from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import { Switch } from "@formbricks/ui/Switch";
import { updateProductAction } from "../actions";
type ThemeStylingProps = {
product: TProduct;
environmentId: string;
colors: string[];
};
export const ThemeStyling = ({ product, environmentId, colors }: ThemeStylingProps) => {
const router = useRouter();
const [localProduct, setLocalProduct] = useState(product);
const [previewSurveyType, setPreviewSurveyType] = useState<"link" | "web">("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [styling, setStyling] = useState(product.styling);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
const [backgroundStylingOpen, setBackgroundStylingOpen] = useState(false);
const allowStyleOverwrite = localProduct.styling.allowStyleOverwrite ?? false;
const setAllowStyleOverwrite = (value: boolean) => {
setLocalProduct((prev) => ({
...prev,
styling: {
...prev.styling,
allowStyleOverwrite: value,
},
}));
};
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [styledPreviewSurvey, setStyledPreviewSurvey] = useState<TSurvey>(PREVIEW_SURVEY);
useEffect(() => {
setActiveQuestionId(PREVIEW_SURVEY.questions[0].id);
}, []);
useEffect(() => {
// sync the local product with the product prop
// TODO: this is not ideal, we should find a better way to do this.
setLocalProduct(product);
}, [product]);
const onSave = useCallback(async () => {
await updateProductAction(product.id, {
styling: localProduct.styling,
});
toast.success("Styling updated successfully.");
router.refresh();
}, [localProduct, product.id, router]);
const onReset = useCallback(async () => {
await updateProductAction(product.id, {
styling: {
allowStyleOverwrite: true,
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
highlightBorderColor: undefined,
isDarkModeEnabled: false,
roundness: 8,
cardArrangement: {
linkSurveys: "simple",
inAppSurveys: "simple",
},
},
});
setAllowStyleOverwrite(true);
setStyling({
allowStyleOverwrite: true,
brandColor: {
light: COLOR_DEFAULTS.brandColor,
},
questionColor: {
light: COLOR_DEFAULTS.questionColor,
},
inputColor: {
light: COLOR_DEFAULTS.inputColor,
},
inputBorderColor: {
light: COLOR_DEFAULTS.inputBorderColor,
},
cardBackgroundColor: {
light: COLOR_DEFAULTS.cardBackgroundColor,
},
cardBorderColor: {
light: COLOR_DEFAULTS.cardBorderColor,
},
highlightBorderColor: undefined,
isDarkModeEnabled: false,
roundness: 8,
cardArrangement: {
linkSurveys: "simple",
inAppSurveys: "simple",
},
});
// Update the background of the PREVIEW SURVEY
setStyledPreviewSurvey((currentSurvey) => ({
...currentSurvey,
styling: {
...currentSurvey.styling,
background: {
...(currentSurvey.styling?.background ?? {}),
bg: "#ffffff",
bgType: "color",
},
},
}));
toast.success("Styling updated successfully.");
router.refresh();
}, [product.id, router]);
useEffect(() => {
setLocalProduct((prev) => ({
...prev,
styling: {
...styling,
allowStyleOverwrite,
},
}));
}, [allowStyleOverwrite, styling]);
return (
<div className="flex">
{/* Styling settings */}
<div className="w-1/2 pr-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<Switch
checked={allowStyleOverwrite}
onCheckedChange={(value) => {
setAllowStyleOverwrite(value);
}}
/>
<div className="flex flex-col">
<h3 className="text-sm font-semibold text-slate-700">Enable custom styling</h3>
<p className="text-xs text-slate-500">
Allow users to override this theme in the survey editor.
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-3 rounded-lg bg-slate-50 p-4">
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
styling={styling}
setStyling={setStyling}
hideCheckmark
/>
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
styling={styling}
setStyling={setStyling}
hideCheckmark
/>
<BackgroundStylingCard
open={backgroundStylingOpen}
setOpen={setBackgroundStylingOpen}
styling={styling}
setStyling={setStyling}
environmentId={environmentId}
colors={colors}
key={styling.background?.bg}
hideCheckmark
/>
</div>
</div>
<div className="mt-8 flex items-center gap-2">
<Button variant="darkCTA" onClick={onSave}>
Save
</Button>
<Button
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to default
<RotateCcwIcon className="h-4 w-4" />
</Button>
</div>
</div>
{/* Survey Preview */}
<div className="relative w-1/2 rounded-lg bg-slate-100 pt-4">
<div className="sticky top-4 mb-4 h-full max-h-[600px]">
<ThemeStylingPreviewSurvey
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
survey={styledPreviewSurvey as TSurvey}
product={localProduct}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}
/>
</div>
</div>
{/* Confirm reset styling modal */}
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText="Reset styling"
mainText="Are you sure you want to reset the styling to default?"
confirmBtnLabel="Confirm"
onConfirm={() => {
onReset();
setConfirmResetStylingModalOpen(false);
}}
onDecline={() => setConfirmResetStylingModalOpen(false)}
/>
</div>
);
};

View File

@@ -0,0 +1,214 @@
"use client";
import Modal from "@/app/(app)/environments/[environmentId]/surveys/components/Modal";
import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
import { Variants, motion } from "framer-motion";
import { Repeat2 } from "lucide-react";
import { useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { SurveyInline } from "@formbricks/ui/Survey";
interface ThemeStylingPreviewSurveyProps {
survey: TSurvey;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
product: TProduct;
previewType: "link" | "web";
setPreviewType: (type: "link" | "web") => void;
}
const previewParentContainerVariant: Variants = {
expanded: {
position: "fixed",
height: "100%",
width: "100%",
backgroundColor: "rgba(0, 0, 0, 0.4)",
backdropFilter: "blur(15px)",
left: 0,
top: 0,
zIndex: 1040,
transition: {
ease: "easeIn",
duration: 0.001,
},
},
shrink: {
display: "none",
position: "fixed",
backgroundColor: "rgba(0, 0, 0, 0.0)",
backdropFilter: "blur(0px)",
transition: {
duration: 0,
},
zIndex: -1,
},
};
export const ThemeStylingPreviewSurvey = ({
setActiveQuestionId,
activeQuestionId,
survey,
product,
previewType,
setPreviewType,
}: ThemeStylingPreviewSurveyProps) => {
const [isFullScreenPreview] = useState(false);
const [previewPosition] = useState("relative");
const ContentRef = useRef<HTMLDivElement | null>(null);
const [shrink] = useState(false);
const { productOverwrites } = survey || {};
const previewScreenVariants: Variants = {
expanded: {
right: "5%",
bottom: "10%",
top: "12%",
width: "40%",
position: "fixed",
height: "80%",
zIndex: 1050,
boxShadow: "0px 4px 5px 4px rgba(169, 169, 169, 0.25)",
transition: {
ease: "easeInOut",
duration: shrink ? 0.3 : 0,
},
},
expanded_with_fixed_positioning: {
zIndex: 1050,
position: "fixed",
top: "5%",
right: "5%",
bottom: "10%",
width: "90%",
height: "90%",
transition: {
ease: "easeOut",
duration: 0.4,
},
},
shrink: {
display: "relative",
width: ["83.33%"],
height: ["95%"],
},
};
const { placement: surveyPlacement } = productOverwrites || {};
const placement = surveyPlacement || product.placement;
const highlightBorderColor = product.styling.highlightBorderColor?.light;
function resetQuestionProgress() {
setActiveQuestionId(survey?.questions[0]?.id);
}
const onFileUpload = async (file: File) => file.name;
return (
<div className="flex h-full w-full flex-col items-center justify-items-center">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
layout
variants={previewScreenVariants}
animate={
isFullScreenPreview
? previewPosition === "relative"
? "expanded"
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-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="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{previewType === "web" ? "Your web app" : "Preview"}</p>
<div className="flex items-center">
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</div>
</div>
</div>
{previewType === "web" ? (
<Modal
isOpen
placement={placement}
highlightBorderColor={highlightBorderColor}
previewMode="desktop"
background={product.styling.cardBackgroundColor?.light}
borderRadius={product.styling.roundness ?? 8}>
<SurveyInline
survey={survey}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
styling={product.styling}
isCardBorderVisible={!highlightBorderColor}
languageCode="default"
/>
</Modal>
) : (
<MediaBackground survey={survey} product={product} ContentRef={ContentRef} isEditorView>
<div className="z-0 w-full max-w-md rounded-lg p-4">
<SurveyInline
survey={survey}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
responseCount={42}
styling={product.styling}
languageCode="default"
/>
</div>
</MediaBackground>
)}
</div>
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<div
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("link")}>
Link survey
</div>
<div
className={`${previewType === "web" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("web")}>
In-App survey
</div>
</div>
</div>
);
};
const ResetProgressButton = ({ resetQuestionProgress }: { resetQuestionProgress: () => void }) => {
return (
<Button
variant="minimal"
className="py-0.2 mr-2 bg-white px-2 font-sans text-sm text-slate-500"
onClick={resetQuestionProgress}>
Restart
<Repeat2 className="ml-2 h-4 w-4" />
</Button>
);
};

View File

@@ -2,6 +2,7 @@ import SettingsCard from "@/app/(app)/environments/[environmentId]/settings/comp
import SettingsTitle from "@/app/(app)/environments/[environmentId]/settings/components/SettingsTitle";
import { cn } from "@formbricks/lib/cn";
import { Badge } from "@formbricks/ui/Badge";
import { Button } from "@formbricks/ui/Button";
import { Label } from "@formbricks/ui/Label";
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
@@ -19,21 +20,83 @@ export default function Loading() {
return (
<div>
<SettingsTitle title="Look & Feel" />
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<div className="w-full max-w-sm items-center">
<Label htmlFor="brandcolor">Color (HEX)</Label>
<div className="my-2">
<div className="flex w-full items-center justify-between space-x-1 rounded-md border border-slate-300 px-2 text-sm text-slate-400">
<div className="ml-2 mr-2 h-10 w-32 border-0 bg-transparent text-slate-500 outline-none focus:border-none"></div>
<SettingsCard
title="Theme"
className="max-w-7xl"
description="Create a style theme for all surveys. You can enable custom styling for each survey.">
<div className="flex animate-pulse">
<div className="w-1/2">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<Switch />
<div className="flex flex-col">
<h3 className="text-sm font-semibold text-slate-700">Enable custom styling</h3>
<p className="text-xs text-slate-500">
Allow users to override this theme in the editor.
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-4 bg-slate-50 p-4">
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<h2 className="text-base font-semibold text-slate-700">Form Styling</h2>
<p className="mt-1 text-sm text-slate-500">
Style the question texts, descriptions and input fields.
</p>
</div>
</div>
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<h2 className="text-base font-semibold text-slate-700">Card Styling</h2>
<p className="mt-1 text-sm text-slate-500">Style the survey card.</p>
</div>
</div>
<div className="w-full rounded-lg border border-slate-300 bg-white">
<div className="flex flex-col p-4">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-slate-700">Background Styling</h2>
<Badge text="Link Surveys" type="gray" size="normal" />
</div>
<p className="mt-1 text-sm text-slate-500">
Change the background to a color, image or animation.
</p>
</div>
</div>
</div>
</div>
</div>
<div className="w-1/2 bg-slate-100 px-6 pt-4">
<div className="relative flex h-[95] max-h-[95%] w-full items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-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="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>Preview</p>
<div className="flex items-center pr-6">Restart</div>
</div>
</div>
<div className="grid h-[500px] place-items-center bg-white">
<h1 className="text-xl font-semibold text-slate-700">Loading preview...</h1>
</div>
</div>
</div>
</div>
<Button
variant="darkCTA"
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
Loading
</Button>
</div>
</SettingsCard>
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
@@ -69,40 +132,7 @@ export default function Loading() {
</Button>
</div>
</SettingsCard>
<SettingsCard
noPadding
title="Highlight Border"
description="Make sure your users notice the survey you display">
<div className="flex min-h-full w-full">
<div className="flex w-1/2 flex-col px-6 py-5">
<div className="pointer-events-none mb-6 flex cursor-not-allowed select-none items-center space-x-2">
<Switch id="highlightBorder" checked={false} />
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
</div>
<Button
type="submit"
variant="darkCTA"
className="pointer-events-none mt-4 flex max-w-[100px] animate-pulse cursor-not-allowed select-none items-center justify-center">
Loading
</Button>
</div>
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
<h3 className="text-slate-500">Preview</h3>
<div className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}>
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
<div className="flex rounded-2xl border border-slate-400">
{[1, 2, 3, 4, 5].map((num) => (
<div key={num} className="border-r border-slate-400 px-6 py-5 last:border-r-0">
<span className="text-sm font-medium">{num}</span>
</div>
))}
</div>
</div>
</div>
</div>
</SettingsCard>
<SettingsCard
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">

View File

@@ -5,7 +5,7 @@ import {
getRemoveLinkBrandingPermission,
} from "@formbricks/ee/lib/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { SURVEY_BG_COLORS } from "@formbricks/lib/constants";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
@@ -14,10 +14,9 @@ import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import SettingsCard from "../components/SettingsCard";
import SettingsTitle from "../components/SettingsTitle";
import { EditBrandColor } from "./components/EditBrandColor";
import { EditFormbricksBranding } from "./components/EditBranding";
import { EditHighlightBorder } from "./components/EditHighlightBorder";
import { EditPlacement } from "./components/EditPlacement";
import { ThemeStyling } from "./components/ThemeStyling";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const [session, team, product] = await Promise.all([
@@ -40,8 +39,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
const canRemoveLinkBranding = getRemoveLinkBrandingPermission(team);
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
const { isDeveloper, isViewer } = getAccessFlags(currentUserMembership?.role);
const isBrandColorEditDisabled = isDeveloper ? true : isViewer;
const { isViewer } = getAccessFlags(currentUserMembership?.role);
if (isViewer) {
return <ErrorComponent />;
@@ -50,28 +48,17 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
return (
<div>
<SettingsTitle title="Look & Feel" />
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<EditBrandColor
product={product}
isBrandColorDisabled={isBrandColorEditDisabled}
environmentId={params.environmentId}
/>
<SettingsCard
title="Theme"
className="max-w-7xl"
description="Create a style theme for all surveys. You can enable custom styling for each survey.">
<ThemeStyling environmentId={params.environmentId} product={product} colors={SURVEY_BG_COLORS} />
</SettingsCard>
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<EditPlacement product={product} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
noPadding
title="Highlight Border"
description="Make sure your users notice the survey you display">
<EditHighlightBorder
product={product}
defaultBrandColor={DEFAULT_BRAND_COLOR}
environmentId={params.environmentId}
/>
</SettingsCard>
<SettingsCard
title="Formbricks Branding"
description="We love your support but understand if you toggle it off.">

View File

@@ -1,4 +1,3 @@
import { isLight } from "@/app/lib/utils";
import {
Column,
Container,
@@ -17,7 +16,9 @@ import { cn } from "@formbricks/lib/cn";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { getSurvey } from "@formbricks/lib/survey/service";
import { isLight } from "@formbricks/lib/utils";
import { TSurvey, TSurveyQuestionType } from "@formbricks/types/surveys";
import { RatingSmiley } from "@formbricks/ui/RatingSmiley";
@@ -36,7 +37,7 @@ export const getEmailTemplateHtml = async (surveyId) => {
if (!product) {
throw new Error("Product not found");
}
const brandColor = product.brandColor;
const brandColor = product.styling.brandColor?.light || COLOR_DEFAULTS.brandColor;
const surveyUrl = WEBAPP_URL + "/s/" + survey.id;
const html = render(<EmailTemplate survey={survey} surveyUrl={surveyUrl} brandColor={brandColor} />, {
pretty: true,

View File

@@ -0,0 +1,148 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import { cn } from "@formbricks/lib/cn";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyBackgroundBgType, TSurveyStyling } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { Slider } from "@formbricks/ui/Slider";
import SurveyBgSelectorTab from "./SurveyBgSelectorTab";
interface BackgroundStylingCardProps {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
styling: TSurveyStyling | TProductStyling | null;
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
colors: string[];
hideCheckmark?: boolean;
disabled?: boolean;
environmentId: string;
}
export default function BackgroundStylingCard({
open,
setOpen,
styling,
setStyling,
colors,
hideCheckmark,
disabled,
environmentId,
}: BackgroundStylingCardProps) {
const { bgType, brightness } = styling?.background ?? {};
const handleBgChange = (color: string, type: TSurveyBackgroundBgType) => {
const { background } = styling ?? {};
setStyling({
...styling,
background: {
...background,
bg: color,
bgType: type,
brightness: 100,
},
});
};
const handleBrightnessChange = (percent: number) => {
setStyling((prev) => ({
...prev,
background: {
...prev.background,
brightness: percent,
},
}));
};
return (
<Collapsible.Root
open={open}
onOpenChange={(openState) => {
if (disabled) return;
setOpen(openState);
}}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white "
)}>
<Collapsible.CollapsibleTrigger
asChild
disabled={disabled}
className={cn(
"h-full w-full cursor-pointer rounded-lg hover:bg-slate-50",
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
)}>
<div className="inline-flex px-4 py-4">
{!hideCheckmark && (
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p className="font-semibold text-slate-800">Background Styling</p>
{hideCheckmark && <Badge text="Link Surveys" type="gray" size="normal" />}
</div>
<p className="mt-1 truncate text-sm text-slate-500">
Change the background to a color, image or animation.
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="flex flex-col gap-3 p-3">
{/* Background */}
<div className="p-3">
<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>
<SurveyBgSelectorTab
styling={styling}
handleBgChange={handleBgChange}
colors={colors}
bgType={bgType}
environmentId={environmentId}
/>
</div>
{/* Overlay */}
<div className="flex flex-col gap-4 p-3">
<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>
<div>
<div className="ml-2 flex flex-col justify-center">
<div className="flex flex-col gap-4">
<div className="flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
<Slider
value={[brightness ?? 100]}
max={200}
onValueChange={(value) => {
handleBrightnessChange(value[0]);
}}
/>
</div>
</div>
</div>
</div>
</div>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import React, { useMemo } from "react";
import { cn } from "@formbricks/lib/cn";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { Label } from "@formbricks/ui/Label";
import { Slider } from "@formbricks/ui/Slider";
import { ColorSelectorWithLabel } from "@formbricks/ui/Styling";
import { Switch } from "@formbricks/ui/Switch";
type CardStylingSettingsProps = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
styling: TSurveyStyling | TProductStyling | null;
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
hideCheckmark?: boolean;
surveyType?: TSurveyType;
disabled?: boolean;
};
const CardStylingSettings = ({
setStyling,
styling,
hideCheckmark,
surveyType,
disabled,
open,
setOpen,
}: CardStylingSettingsProps) => {
const cardBgColor = styling?.cardBackgroundColor?.light || COLOR_DEFAULTS.cardBackgroundColor;
const setCardBgColor = (color: string) => {
setStyling((prev) => ({
...prev,
cardBackgroundColor: {
...(prev.cardBackgroundColor ?? {}),
light: color,
},
}));
};
const cardBorderColor = styling?.cardBorderColor?.light || COLOR_DEFAULTS.cardBorderColor;
const setCardBorderColor = (color: string) => {
setStyling((prev) => ({
...prev,
cardBorderColor: {
...(prev.cardBorderColor ?? {}),
light: color,
},
}));
};
const cardShadowColor = styling?.cardShadowColor?.light || COLOR_DEFAULTS.cardShadowColor;
const setCardShadowColor = (color: string) => {
setStyling((prev) => ({
...prev,
cardShadowColor: {
...(prev.cardShadowColor ?? {}),
light: color,
},
}));
};
const isHighlightBorderAllowed = !!styling?.highlightBorderColor;
const setIsHighlightBorderAllowed = (open: boolean) => {
if (!open) {
const { highlightBorderColor, ...rest } = styling ?? {};
setStyling({
...rest,
});
} else {
setStyling((prev) => ({
...prev,
highlightBorderColor: {
...(prev.highlightBorderColor ?? {}),
light: COLOR_DEFAULTS.highlightBorderColor,
},
}));
}
};
const highlightBorderColor = styling?.highlightBorderColor?.light || COLOR_DEFAULTS.highlightBorderColor;
const setHighlightBorderColor = (color: string) => {
setStyling((prev) => ({
...prev,
highlightBorderColor: {
...(prev.highlightBorderColor ?? {}),
light: color,
},
}));
};
const roundness = styling?.roundness ?? 8;
const setRoundness = (value: number) => {
setStyling((prev) => ({
...prev,
roundness: value,
}));
};
const toggleProgressBarVisibility = (hideProgressBar: boolean) => {
setStyling({
...styling,
hideProgressBar,
});
};
const hideProgressBar = useMemo(() => {
return styling?.hideProgressBar;
}, [styling]);
return (
<Collapsible.Root
open={open}
onOpenChange={(openState) => {
if (disabled) return;
setOpen(openState);
}}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
disabled={disabled}
className={cn(
"h-full w-full cursor-pointer rounded-lg hover:bg-slate-50",
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
)}>
<div className="inline-flex px-4 py-4">
{!hideCheckmark && (
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
)}
<div>
<p className="font-semibold text-slate-800">Card Styling</p>
<p className="mt-1 text-sm text-slate-500">Style the survey card.</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="flex flex-col gap-6 p-6 pt-2">
<div className="flex max-w-xs flex-col gap-4">
<div className="flex flex-col">
<h3 className="text-sm font-semibold text-slate-700">Roundness</h3>
<p className="text-xs text-slate-500">Change the border radius of the card and the inputs.</p>
</div>
<div className="flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
<Slider value={[roundness]} max={22} onValueChange={(value) => setRoundness(value[0])} />
</div>
</div>
<ColorSelectorWithLabel
label="Card background color"
color={cardBgColor}
setColor={setCardBgColor}
description="Change the background color of the card."
/>
<ColorSelectorWithLabel
label="Card border color"
color={cardBorderColor}
setColor={setCardBorderColor}
description="Change the border color of the card."
/>
<ColorSelectorWithLabel
label="Card shadow color"
color={cardShadowColor}
setColor={setCardShadowColor}
description="Change the shadow color of the card."
/>
<>
<div className="flex items-center space-x-1">
<Switch
id="hideProgressBar"
checked={!!hideProgressBar}
onCheckedChange={(checked) => toggleProgressBarVisibility(checked)}
/>
<Label htmlFor="hideProgressBar" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Hide Progress Bar</h3>
<p className="text-xs font-normal text-slate-500">
Disable the visibility of survey progress.
</p>
</div>
</Label>
</div>
{(!surveyType || surveyType === "web") && (
<div className="flex max-w-xs flex-col gap-4">
<div className="flex items-center gap-2">
<Switch checked={isHighlightBorderAllowed} onCheckedChange={setIsHighlightBorderAllowed} />
<div className="flex flex-col">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-700">Add highlight border</h3>
<Badge text="In-App Surveys" type="gray" size="normal" />
</div>
<p className="text-xs text-slate-500">Add an outer border to your survey card.</p>
</div>
</div>
{isHighlightBorderAllowed && (
<ColorPicker
color={highlightBorderColor}
onChange={setHighlightBorderColor}
containerClass="my-0"
/>
)}
</div>
)}
</>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
};
export default CardStylingSettings;

View File

@@ -4,11 +4,11 @@ import { ColorPicker } from "@formbricks/ui/ColorPicker";
interface ColorSurveyBgProps {
handleBgChange: (bg: string, bgType: string) => void;
colours: string[];
colors: string[];
background: string;
}
export const ColorSurveyBg = ({ handleBgChange, colours, background }: ColorSurveyBgProps) => {
export const ColorSurveyBg = ({ handleBgChange, colors, background }: ColorSurveyBgProps) => {
const [color, setColor] = useState(background || "#ffff");
const handleBg = (x: string) => {
@@ -20,8 +20,8 @@ export const ColorSurveyBg = ({ handleBgChange, colours, background }: ColorSurv
<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) => {
<div className="flex flex-wrap gap-4">
{colors.map((x) => {
return (
<div
className={`h-16 w-16 cursor-pointer rounded-lg ${

View File

@@ -0,0 +1,203 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon, SparklesIcon } from "lucide-react";
import React from "react";
import { cn } from "@formbricks/lib/cn";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { mixColor } from "@formbricks/lib/utils";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { ColorSelectorWithLabel } from "@formbricks/ui/Styling";
type FormStylingSettingsProps = {
styling: TSurveyStyling | TProductStyling | null;
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
hideCheckmark?: boolean;
disabled?: boolean;
};
const FormStylingSettings = ({
styling,
setStyling,
open,
hideCheckmark = false,
disabled = false,
setOpen,
}: FormStylingSettingsProps) => {
const brandColor = styling?.brandColor?.light || COLOR_DEFAULTS.brandColor;
const setBrandColor = (color: string) => {
setStyling((prev) => ({
...prev,
brandColor: {
...(prev.brandColor ?? {}),
light: color,
},
}));
};
const questionColor = styling?.questionColor?.light || COLOR_DEFAULTS.questionColor;
const setQuestionColor = (color: string) => {
setStyling((prev) => ({
...prev,
questionColor: {
...(prev.questionColor ?? {}),
light: color,
},
}));
};
const inputColor = styling?.inputColor?.light || COLOR_DEFAULTS.inputColor;
const setInputColor = (color: string) => {
setStyling((prev) => ({
...prev,
inputColor: {
...(prev.inputColor ?? {}),
light: color,
},
}));
};
const inputBorderColor = styling?.inputBorderColor?.light || COLOR_DEFAULTS.inputBorderColor;
const setInputBorderColor = (color: string) => {
setStyling((prev) => ({
...prev,
inputBorderColor: {
...(prev.inputBorderColor ?? {}),
light: color,
},
}));
};
const suggestColors = () => {
// mix the brand color with different weights of white and set the result as the other colors
setQuestionColor(mixColor(brandColor, "#000000", 0.35));
setInputColor(mixColor(brandColor, "#ffffff", 0.92));
setInputBorderColor(mixColor(brandColor, "#ffffff", 0.6));
// card background, border and shadow colors
setStyling((prev) => ({
...prev,
cardBackgroundColor: {
...(prev.cardBackgroundColor ?? {}),
light: mixColor(brandColor, "#ffffff", 0.97),
},
cardBorderColor: {
...(prev.cardBorderColor ?? {}),
light: mixColor(brandColor, "#ffffff", 0.8),
},
cardShadowColor: {
...(prev.cardShadowColor ?? {}),
light: brandColor,
},
}));
if (!styling?.background || styling?.background?.bgType === "color") {
setStyling((prev) => ({
...prev,
background: {
...(prev.background ?? {}),
bg: mixColor(brandColor, "#ffffff", 0.855),
bgType: "color",
},
}));
}
if (styling?.highlightBorderColor) {
setStyling((prev) => ({
...prev,
highlightBorderColor: {
...(prev.highlightBorderColor ?? {}),
light: mixColor(brandColor, "#ffffff", 0.25),
},
}));
}
};
return (
<Collapsible.Root
open={open}
onOpenChange={(openState) => {
if (disabled) return;
setOpen(openState);
}}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
disabled={disabled}
className={cn(
"h-full w-full cursor-pointer rounded-lg hover:bg-slate-50",
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
)}>
<div className="inline-flex px-4 py-4">
{!hideCheckmark && (
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
)}
<div>
<p className="font-semibold text-slate-800">Form Styling</p>
<p className="mt-1 text-sm text-slate-500">
Style the question texts, descriptions and input fields.
</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent>
<hr className="py-1 text-slate-600" />
<div className="flex flex-col gap-6 p-6 pt-2">
<div className="flex flex-col gap-2">
<ColorSelectorWithLabel
label="Brand color"
color={brandColor}
setColor={setBrandColor}
description="Change the brand color of the survey"
/>
<Button
variant="secondary"
size="sm"
EndIcon={SparklesIcon}
className="w-fit"
onClick={() => suggestColors()}>
Suggest colors
</Button>
</div>
<ColorSelectorWithLabel
label="Text color"
color={questionColor}
setColor={setQuestionColor}
description="Change the text color of the questions, descriptions and answer options."
/>
<ColorSelectorWithLabel
label="Input color"
color={inputColor}
setColor={setInputColor}
description="Change the background color of the input fields."
/>
<ColorSelectorWithLabel
label="Input border color"
color={inputBorderColor}
setColor={setInputBorderColor}
description="Change the border color of the input fields."
/>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
};
export default FormStylingSettings;

View File

@@ -84,7 +84,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border bg-green-400 p-1.5 text-white"
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>

View File

@@ -1,12 +1,14 @@
import { Rows3Icon, SettingsIcon } from "lucide-react";
import { PaintbrushIcon, Rows3Icon, SettingsIcon } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@formbricks/lib/cn";
import { TSurveyEditorTabs } from "@formbricks/types/surveys";
interface Tab {
id: "questions" | "settings";
type Tab = {
id: TSurveyEditorTabs;
label: string;
icon: JSX.Element;
}
};
const tabs: Tab[] = [
{
@@ -14,6 +16,11 @@ const tabs: Tab[] = [
label: "Questions",
icon: <Rows3Icon className="h-5 w-5" />,
},
{
id: "styling",
label: "Styling",
icon: <PaintbrushIcon />,
},
{
id: "settings",
label: "Settings",
@@ -22,15 +29,27 @@ const tabs: Tab[] = [
];
interface QuestionsAudienceTabsProps {
activeId: "questions" | "settings";
setActiveId: (id: "questions" | "settings") => void;
activeId: TSurveyEditorTabs;
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
isStylingTabVisible?: boolean;
}
export default function QuestionsAudienceTabs({ activeId, setActiveId }: QuestionsAudienceTabsProps) {
export default function QuestionsAudienceTabs({
activeId,
setActiveId,
isStylingTabVisible,
}: QuestionsAudienceTabsProps) {
const tabsComputed = useMemo(() => {
if (isStylingTabVisible) {
return tabs;
}
return tabs.filter((tab) => tab.id !== "styling");
}, [isStylingTabVisible]);
return (
<div className="fixed z-20 flex h-14 w-full items-center justify-center border bg-white md:w-1/2">
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
{tabs.map((tab) => (
{tabsComputed.map((tab) => (
<button
type="button"
key={tab.id}

View File

@@ -96,7 +96,7 @@ export default function RecontactOptionsCard({
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border bg-green-400 p-1.5 text-white"
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>

View File

@@ -24,7 +24,7 @@ export default function ResponseOptionsCard({
setLocalSurvey,
responseCount,
}: ResponseOptionsCardProps) {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(localSurvey.type === "link" ? true : false);
const autoComplete = localSurvey.autoComplete !== null;
const [redirectToggle, setRedirectToggle] = useState(false);
const [surveyCloseOnDateToggle, setSurveyCloseOnDateToggle] = useState(false);
@@ -297,7 +297,7 @@ export default function ResponseOptionsCard({
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border bg-green-400 p-1.5 text-white"
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>{" "}
</div>
<div>

View File

@@ -1,3 +1,5 @@
import SurveyPlacementCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyPlacementCard";
import { AdvancedTargetingCard } from "@formbricks/ee/advancedTargeting/components/AdvancedTargetingCard";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
@@ -9,7 +11,6 @@ import { TSurvey } from "@formbricks/types/surveys";
import HowToSendCard from "./HowToSendCard";
import RecontactOptionsCard from "./RecontactOptionsCard";
import ResponseOptionsCard from "./ResponseOptionsCard";
import StylingCard from "./StylingCard";
import TargetingCard from "./TargetingCard";
import WhenToSendCard from "./WhenToSendCard";
@@ -22,7 +23,6 @@ interface SettingsViewProps {
segments: TSegment[];
responseCount: number;
membershipRole?: TMembershipRole;
colours: string[];
isUserTargetingAllowed?: boolean;
isFormbricksCloud: boolean;
}
@@ -36,7 +36,6 @@ export default function SettingsView({
segments,
responseCount,
membershipRole,
colours,
isUserTargetingAllowed = false,
isFormbricksCloud,
}: SettingsViewProps) {
@@ -90,12 +89,13 @@ export default function SettingsView({
environmentId={environment.id}
/>
<StylingCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
colours={colours}
environmentId={environment.id}
/>
{localSurvey.type === "web" && (
<SurveyPlacementCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environment.id}
/>
)}
</div>
);
}

View File

@@ -1,358 +0,0 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TPlacement } from "@formbricks/types/common";
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";
import Placement from "./Placement";
import SurveyBgSelectorTab from "./SurveyBgSelectorTab";
interface StylingCardProps {
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
colours: string[];
environmentId: string;
}
export default function StylingCard({
localSurvey,
setLocalSurvey,
colours,
environmentId,
}: StylingCardProps) {
const [open, setOpen] = useState(localSurvey.type === "link" ? true : false);
const progressBarHidden = localSurvey.styling?.hideProgressBar ?? false;
const { type, productOverwrites, styling } = localSurvey;
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
productOverwrites ?? {};
const { bgType } = styling?.background ?? {};
const [inputValue, setInputValue] = useState(100);
const handleInputChange = (e) => {
setInputValue(e.target.value);
handleBrightnessChange(parseInt(e.target.value));
};
const togglePlacement = () => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
placement: !!placement ? null : "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
},
});
};
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 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,
productOverwrites: {
...localSurvey.productOverwrites,
highlightBorderColor: color,
},
});
};
const handlePlacementChange = (placement: TPlacement) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
placement,
},
});
};
const handleOverlay = (overlayType: string) => {
const darkOverlay = overlayType === "dark";
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
darkOverlay,
},
});
};
const handleClickOutsideClose = (clickOutsideClose: boolean) => {
setLocalSurvey({
...localSurvey,
productOverwrites: {
...localSurvey.productOverwrites,
clickOutsideClose,
},
});
};
const toggleProgressBarVisibility = () => {
setLocalSurvey({
...localSurvey,
styling: {
...localSurvey.styling,
hideProgressBar: !progressBarHidden,
},
});
};
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 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">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border bg-green-400 p-1.5 text-white"
/>
</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">
{type == "link" && (
<>
<>
{/* Background */}
<div className="p-3">
<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>
<SurveyBgSelectorTab
localSurvey={localSurvey}
handleBgChange={handleBgChange}
colours={colours}
bgType={bgType}
/>
</div>
{/* Overlay */}
<div className="my-3 p-3">
<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>
<div>
<div className="mt-4 flex flex-col justify-center rounded-lg border bg-slate-50 p-6">
<h3 className="mb-4 text-sm font-semibold text-slate-700">Brightness</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-slate-200 dark:bg-slate-700"
/>
</div>
</div>
</div>
</>
</>
)}
{/* 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"}
setClickOutsideClose={handleClickOutsideClose}
clickOutsideClose={!!clickOutsideClose}
/>
</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 Highlight Border</h3>
<p className="text-xs font-normal text-slate-500">
Change the highlight border 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 className="p-3">
<div className="ml-2 flex items-center space-x-1">
<Switch
id="hideProgressBar"
checked={progressBarHidden}
onCheckedChange={toggleProgressBarVisibility}
/>
<Label htmlFor="hideProgressBar" className="cursor-pointer">
<div className="ml-2">
<h3 className="text-sm font-semibold text-slate-700">Hide Progress Bar</h3>
<p className="text-xs font-normal text-slate-500">
Disable the visibility of survey progress
</p>
</div>
</Label>
</div>
</div>
<div className="mt-2 flex items-center space-x-3 rounded-lg px-4 py-2 text-slate-500">
<p className="text-xs">
To keep the styling over all surveys consistent, you can{" "}
<Link
href={`/environments/${environmentId}/settings/lookandfeel`}
className="underline hover:text-slate-900"
target="_blank">
set global styles in the Look & Feel settings.
</Link>{" "}
</p>
</div>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}

View File

@@ -0,0 +1,204 @@
import BackgroundStylingCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard";
import CardStylingSettings from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings";
import FormStylingSettings from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings";
import { RotateCcwIcon } from "lucide-react";
import Link from "next/link";
import React, { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys";
import AlertDialog from "@formbricks/ui/AlertDialog";
import { Button } from "@formbricks/ui/Button";
import { Switch } from "@formbricks/ui/Switch";
type StylingViewProps = {
environment: TEnvironment;
product: TProduct;
localSurvey: TSurvey;
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
colors: string[];
styling: TSurveyStyling | null;
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | null>>;
localStylingChanges: TSurveyStyling | null;
setLocalStylingChanges: React.Dispatch<React.SetStateAction<TSurveyStyling | null>>;
};
const StylingView = ({
colors,
environment,
product,
localSurvey,
setLocalSurvey,
setStyling,
styling,
localStylingChanges,
setLocalStylingChanges,
}: StylingViewProps) => {
const [overwriteThemeStyling, setOverwriteThemeStyling] = useState(
localSurvey?.styling?.overwriteThemeStyling ?? false
);
const [formStylingOpen, setFormStylingOpen] = useState(false);
const [cardStylingOpen, setCardStylingOpen] = useState(false);
const [stylingOpen, setStylingOpen] = useState(false);
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const onResetThemeStyling = () => {
const { styling: productStyling } = product;
const { allowStyleOverwrite, ...baseStyling } = productStyling ?? {};
setStyling({
...baseStyling,
overwriteThemeStyling: true,
});
setConfirmResetStylingModalOpen(false);
toast.success("Styling set to theme styles");
};
useEffect(() => {
if (!overwriteThemeStyling) {
setFormStylingOpen(false);
setCardStylingOpen(false);
setStylingOpen(false);
}
}, [overwriteThemeStyling]);
useEffect(() => {
if (styling) {
setLocalSurvey((prev) => ({
...prev,
styling,
}));
}
}, [setLocalSurvey, styling]);
const defaultProductStyling = useMemo(() => {
const { styling: productStyling } = product;
const { allowStyleOverwrite, ...baseStyling } = productStyling ?? {};
return baseStyling;
}, [product]);
const handleOverwriteToggle = (value: boolean) => {
// survey styling from the server is surveyStyling, it could either be set or not
// if its set and the toggle is turned off, we set the local styling to the server styling
setOverwriteThemeStyling(value);
// if the toggle is turned on, we set the local styling to the product styling
if (value) {
if (!styling) {
// copy the product styling to the survey styling
setStyling({
...defaultProductStyling,
overwriteThemeStyling: true,
});
return;
}
// if there are local styling changes, we set the styling to the local styling changes that were previously stored
if (localStylingChanges) {
setStyling(localStylingChanges);
}
// if there are no local styling changes, we set the styling to the product styling
else {
setStyling({
...defaultProductStyling,
overwriteThemeStyling: true,
});
}
}
// if the toggle is turned off, we store the local styling changes and set the styling to the product styling
else {
// copy the styling to localStylingChanges
setLocalStylingChanges(styling);
// copy the product styling to the survey styling
setStyling({
...defaultProductStyling,
overwriteThemeStyling: false,
});
}
};
return (
<div className="mt-12 space-y-3 p-5">
<div className="flex items-center gap-4 py-4">
<Switch checked={overwriteThemeStyling} onCheckedChange={handleOverwriteToggle} />
<div className="flex flex-col">
<h3 className="text-base font-semibold text-slate-900">Add custom styles</h3>
<p className="text-sm text-slate-800">Override the theme with individual styles for this survey.</p>
</div>
</div>
<FormStylingSettings
open={formStylingOpen}
setOpen={setFormStylingOpen}
styling={styling}
setStyling={setStyling}
disabled={!overwriteThemeStyling}
/>
<CardStylingSettings
open={cardStylingOpen}
setOpen={setCardStylingOpen}
styling={styling}
setStyling={setStyling}
surveyType={localSurvey.type}
disabled={!overwriteThemeStyling}
/>
{localSurvey.type === "link" && (
<BackgroundStylingCard
open={stylingOpen}
setOpen={setStylingOpen}
styling={styling}
setStyling={setStyling}
environmentId={environment.id}
colors={colors}
disabled={!overwriteThemeStyling}
/>
)}
<div className="mt-4 flex h-8 items-center justify-between">
<div>
{overwriteThemeStyling && (
<Button
variant="minimal"
className="flex items-center gap-2"
onClick={() => setConfirmResetStylingModalOpen(true)}>
Reset to theme styles
<RotateCcwIcon className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-sm text-slate-500">
Adjust the theme in the{" "}
<Link
href={`/environments/${environment.id}/settings/lookandfeel`}
target="_blank"
className="font-semibold underline">
Look & Feel
</Link>{" "}
settings
</p>
</div>
<AlertDialog
open={confirmResetStylingModalOpen}
setOpen={setConfirmResetStylingModalOpen}
headerText="Reset to theme styles"
mainText="Are you sure you want to reset the styling to the theme styles? This will remove all custom styling."
confirmBtnLabel="Confirm"
onDecline={() => setConfirmResetStylingModalOpen(false)}
onConfirm={onResetThemeStyling}
/>
</div>
);
};
export default StylingView;

View File

@@ -1,16 +1,18 @@
import { useEffect, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling } from "@formbricks/types/surveys";
import { AnimatedSurveyBg } from "./AnimatedSurveyBg";
import { ColorSurveyBg } from "./ColorSurveyBg";
import { ImageSurveyBg } from "./ImageSurveyBg";
interface SurveyBgSelectorTabProps {
localSurvey: TSurvey;
handleBgChange: (bg: string, bgType: string) => void;
colours: string[];
colors: string[];
bgType: string | null | undefined;
environmentId: string;
styling: TSurveyStyling | TProductStyling | null;
}
const TabButton = ({ isActive, onClick, children }) => (
@@ -25,12 +27,14 @@ const TabButton = ({ isActive, onClick, children }) => (
);
export default function SurveyBgSelectorTab({
localSurvey,
styling,
handleBgChange,
colours,
colors,
bgType,
environmentId,
}: SurveyBgSelectorTabProps) {
const background = localSurvey.styling?.background;
const { background } = styling ?? {};
const [backgrounds, setBackgrounds] = useState({
image: background?.bgType === "image" ? background.bg : "",
animation: background?.bgType === "animation" ? background.bg : "",
@@ -50,17 +54,12 @@ export default function SurveyBgSelectorTab({
const [tab, setTab] = useState(bgType || "color");
useEffect(() => {
handleBgChange(backgrounds[tab], tab);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
const renderContent = () => {
switch (tab) {
case "image":
return (
<ImageSurveyBg
environmentId={localSurvey.environmentId}
environmentId={environmentId}
handleBgChange={handleBgChange}
background={backgrounds.image ?? ""}
/>
@@ -71,7 +70,7 @@ export default function SurveyBgSelectorTab({
return (
<ColorSurveyBg
handleBgChange={handleBgChange}
colours={colours}
colors={colors}
background={backgrounds.color ?? ""}
/>
);

View File

@@ -2,6 +2,7 @@
import { refetchProduct } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
import { LoadingSkeleton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton";
import StylingView from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingView";
import { useCallback, useEffect, useRef, useState } from "react";
import { createSegmentAction } from "@formbricks/ee/advancedTargeting/lib/actions";
@@ -13,10 +14,10 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys";
import PreviewSurvey from "../../../components/PreviewSurvey";
import QuestionsAudienceTabs from "./QuestionsSettingsTabs";
import QuestionsAudienceTabs from "./QuestionsStylingSettingsTabs";
import QuestionsView from "./QuestionsView";
import SettingsView from "./SettingsView";
import SurveyMenuBar from "./SurveyMenuBar";
@@ -30,7 +31,7 @@ interface SurveyEditorProps {
segments: TSegment[];
responseCount: number;
membershipRole?: TMembershipRole;
colours: string[];
colors: string[];
isUserTargetingAllowed?: boolean;
isMultiLanguageAllowed?: boolean;
isFormbricksCloud: boolean;
@@ -45,12 +46,12 @@ export default function SurveyEditor({
segments,
responseCount,
membershipRole,
colors,
isMultiLanguageAllowed,
colours,
isUserTargetingAllowed = false,
isFormbricksCloud,
}: SurveyEditorProps): JSX.Element {
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
const [localSurvey, setLocalSurvey] = useState<TSurvey | null>(survey);
const [invalidQuestions, setInvalidQuestions] = useState<string[] | null>(null);
@@ -58,6 +59,11 @@ export default function SurveyEditor({
const surveyEditorRef = useRef(null);
const [localProduct, setLocalProduct] = useState<TProduct>(product);
const [styling, setStyling] = useState(localSurvey?.styling);
const [localStylingChanges, setLocalStylingChanges] = useState<TSurveyStyling | null>(null);
const createdSegmentRef = useRef(false);
const fetchLatestProduct = useCallback(async () => {
const latestProduct = await refetchProduct(localProduct.id);
if (latestProduct) {
@@ -69,12 +75,17 @@ export default function SurveyEditor({
useEffect(() => {
if (survey) {
if (localSurvey) return;
const surveyClone = structuredClone(survey);
setLocalSurvey(surveyClone);
if (survey.questions.length > 0) {
setActiveQuestionId(survey.questions[0].id);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey]);
useEffect(() => {
@@ -103,19 +114,12 @@ export default function SurveyEditor({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localSurvey?.type, survey?.questions]);
useEffect(() => {
// if the localSurvey object has not been populated yet, do nothing
if (!localSurvey) {
return;
}
// do nothing if its not an in-app survey
if (localSurvey.type !== "web") {
return;
}
const handleCreateSegment = async () => {
if (!localSurvey) return;
const createSegment = async () => {
try {
const createdSegment = await createSegmentAction({
title: survey.id,
title: localSurvey.id,
description: "",
environmentId: environment.id,
surveyId: localSurvey.id,
@@ -123,22 +127,25 @@ export default function SurveyEditor({
isPrivate: true,
});
setLocalSurvey({
...localSurvey,
segment: createdSegment,
});
};
const localSurveyClone = structuredClone(localSurvey);
localSurveyClone.segment = createdSegment;
setLocalSurvey(localSurveyClone);
} catch (err) {
// set the ref to false to retry during the next render
createdSegmentRef.current = false;
}
};
if (!localSurvey.segment?.id) {
try {
createSegment();
} catch (err) {
throw new Error("Error creating segment");
}
useEffect(() => {
if (!localSurvey || localSurvey.type !== "web" || !!localSurvey.segment || createdSegmentRef.current) {
return;
}
createdSegmentRef.current = true;
handleCreateSegment();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [environment.id, isUserTargetingAllowed, localSurvey?.type, survey.id]);
}, [localSurvey]);
useEffect(() => {
if (!localSurvey?.languages) return;
@@ -170,9 +177,13 @@ export default function SurveyEditor({
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none" ref={surveyEditorRef}>
<QuestionsAudienceTabs activeId={activeView} setActiveId={setActiveView} />
<QuestionsAudienceTabs
activeId={activeView}
setActiveId={setActiveView}
isStylingTabVisible={!!product.styling.allowStyleOverwrite}
/>
{activeView === "questions" ? (
{activeView === "questions" && (
<QuestionsView
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
@@ -186,7 +197,23 @@ export default function SurveyEditor({
isMultiLanguageAllowed={isMultiLanguageAllowed}
isFormbricksCloud={isFormbricksCloud}
/>
) : (
)}
{activeView === "styling" && product.styling.allowStyleOverwrite && (
<StylingView
colors={colors}
environment={environment}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
product={localProduct}
styling={styling ?? null}
setStyling={setStyling}
localStylingChanges={localStylingChanges}
setLocalStylingChanges={setLocalStylingChanges}
/>
)}
{activeView === "settings" && (
<SettingsView
environment={environment}
localSurvey={localSurvey}
@@ -196,7 +223,6 @@ export default function SurveyEditor({
segments={segments}
responseCount={responseCount}
membershipRole={membershipRole}
colours={colours}
isUserTargetingAllowed={isUserTargetingAllowed}
isFormbricksCloud={isFormbricksCloud}
/>

View File

@@ -13,6 +13,7 @@ import { TProduct } from "@formbricks/types/product";
import { ZSegmentFilters } from "@formbricks/types/segment";
import {
TSurvey,
TSurveyEditorTabs,
TSurveyQuestionType,
ZSurveyInlineTriggers,
surveyHasBothTriggers,
@@ -30,8 +31,8 @@ interface SurveyMenuBarProps {
survey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
environment: TEnvironment;
activeId: "questions" | "settings";
setActiveId: (id: "questions" | "settings") => void;
activeId: TSurveyEditorTabs;
setActiveId: React.Dispatch<React.SetStateAction<TSurveyEditorTabs>>;
setInvalidQuestions: (invalidQuestions: string[]) => void;
product: TProduct;
responseCount: number;
@@ -111,7 +112,10 @@ export default function SurveyMenuBar({
};
const handleBack = () => {
if (!isEqual(localSurvey, survey)) {
const { updatedAt, ...localSurveyRest } = localSurvey;
const { updatedAt: _, ...surveyRest } = survey;
if (!isEqual(localSurveyRest, surveyRest)) {
setConfirmDialogOpen(true);
} else {
router.back();
@@ -393,7 +397,6 @@ export default function SurveyMenuBar({
/>
</div>
<Button
// disabled={isSurveyPublishing || (localSurvey.status !== "draft" && containsEmptyTriggers())}
disabled={disableSave}
variant={localSurvey.status === "draft" ? "secondary" : "darkCTA"}
className="mr-3"

View File

@@ -0,0 +1,144 @@
"use client";
import Placement from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { TPlacement } from "@formbricks/types/common";
import { TSurvey, TSurveyProductOverwrites } from "@formbricks/types/surveys";
import { Label } from "@formbricks/ui/Label";
import { Switch } from "@formbricks/ui/Switch";
interface SurveyPlacementCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey) => void;
environmentId: string;
}
export default function SurveyPlacementCard({
localSurvey,
setLocalSurvey,
environmentId,
}: SurveyPlacementCardProps) {
const [open, setOpen] = useState(false);
const { productOverwrites } = localSurvey ?? {};
const { placement, clickOutsideClose, darkOverlay } = productOverwrites ?? {};
const setProductOverwrites = (productOverwrites: TSurveyProductOverwrites) => {
setLocalSurvey({ ...localSurvey, productOverwrites });
};
const togglePlacement = () => {
if (setProductOverwrites) {
setProductOverwrites({
...productOverwrites,
placement: !!placement ? null : "bottomRight",
clickOutsideClose: false,
darkOverlay: false,
});
}
};
const handlePlacementChange = (placement: TPlacement) => {
if (setProductOverwrites) {
setProductOverwrites({
...productOverwrites,
placement,
});
}
};
const handleOverlay = (overlayType: string) => {
const darkOverlay = overlayType === "dark";
if (setProductOverwrites) {
setProductOverwrites({
...productOverwrites,
darkOverlay,
});
}
};
const handleClickOutsideClose = (clickOutsideClose: boolean) => {
if (setProductOverwrites) {
setProductOverwrites({
...productOverwrites,
clickOutsideClose,
});
}
};
return (
<Collapsible.Root
open={open}
onOpenChange={(openState) => {
if (localSurvey.type !== "link") {
setOpen(openState);
}
}}
className="w-full rounded-lg border border-slate-300 bg-white">
<Collapsible.CollapsibleTrigger
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>
<p className="font-semibold text-slate-800">Survey Placement</p>
<p className="mt-1 text-sm text-slate-500">Overwrite the global placement of the survey</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className="pb-3">
<hr className="py-1 text-slate-600" />
<div className="p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center space-x-1">
<Switch id="surveyDeadline" checked={!!placement} onCheckedChange={togglePlacement} />
<Label htmlFor="surveyDeadline" className="cursor-pointer">
<div className="ml-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-700">Overwrite Placement</h3>
</div>
<p className="text-xs font-normal text-slate-500">Change the placement of this survey.</p>
</div>
</Label>
</div>
{placement && (
<div className="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"}
setClickOutsideClose={handleClickOutsideClose}
clickOutsideClose={!!clickOutsideClose}
/>
</div>
</div>
</div>
)}
<div>
<p className="text-xs text-slate-500">
To keep the placement over all surveys consistent, you can{" "}
<Link href={`/environments/${environmentId}/settings/lookandfeel`} target="_blank">
<span className="underline">set the global placement in the Look & Feel settings.</span>
</Link>
</p>
</div>
</div>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
}

View File

@@ -1,8 +1,7 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon } from "lucide-react";
import { AlertCircle } from "lucide-react";
import { AlertCircle, CheckIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
@@ -160,7 +159,7 @@ export default function TargetingCard({
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border bg-green-400 p-1.5 text-white"
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>

View File

@@ -215,11 +215,11 @@ export default function WhenToSendCard({
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
{containsEmptyTriggers ? (
<div className="h-8 w-8 rounded-full border border-amber-500 bg-amber-50" />
<div className="h-7 w-7 rounded-full border border-amber-500 bg-amber-50" />
) : (
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border bg-green-400 p-1.5 text-white"
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
)}
</div>

View File

@@ -4,7 +4,7 @@ import { getAdvancedTargetingPermission, getMultiLanguagePermission } from "@for
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { IS_FORMBRICKS_CLOUD, colours } from "@formbricks/lib/constants";
import { IS_FORMBRICKS_CLOUD, SURVEY_BG_COLORS } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
@@ -82,7 +82,7 @@ export default async function SurveysEditPage({ params }) {
attributeClasses={attributeClasses}
responseCount={responseCount}
membershipRole={currentUserMembership?.role}
colours={colours}
colors={SURVEY_BG_COLORS}
segments={segments}
isUserTargetingAllowed={isUserTargetingAllowed}
isMultiLanguageAllowed={isMultiLanguageAllowed}

View File

@@ -10,12 +10,16 @@ export default function Modal({
placement,
previewMode,
highlightBorderColor,
borderRadius,
background,
}: {
children: ReactNode;
isOpen: boolean;
placement: TPlacement;
previewMode: string;
highlightBorderColor: string | null | undefined;
borderRadius?: number;
background?: string;
}) {
const [show, setShow] = useState(false);
const modalRef = useRef<HTMLDivElement | null>(null);
@@ -102,9 +106,18 @@ export default function Modal({
<div aria-live="assertive" className="relative h-full w-full overflow-hidden bg-slate-300">
<div
ref={modalRef}
style={{ ...highlightBorderColorStyle, ...scalingClasses }}
style={{
...highlightBorderColorStyle,
...scalingClasses,
...(borderRadius && {
borderRadius: `${borderRadius}px`,
}),
...(background && {
background,
}),
}}
className={cn(
"no-scrollbar 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 ",
"no-scrollbar pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto bg-white shadow-lg transition-all duration-500 ease-in-out ",
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full",
slidingAnimationClass
)}>

View File

@@ -6,12 +6,13 @@ import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
import { Variants, motion } from "framer-motion";
import { ExpandIcon, MonitorIcon, ShrinkIcon, SmartphoneIcon } from "lucide-react";
import { RefreshCcwIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { TEnvironment } from "@formbricks/types/environment";
import type { TProduct } from "@formbricks/types/product";
import { TProductStyling } from "@formbricks/types/product";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { SurveyInline } from "@formbricks/ui/Survey";
@@ -56,6 +57,7 @@ const previewParentContainerVariant: Variants = {
zIndex: -1,
},
};
export default function PreviewSurvey({
setActiveQuestionId,
activeQuestionId,
@@ -110,15 +112,29 @@ export default function PreviewSurvey({
},
};
const {
brandColor: surveyBrandColor,
highlightBorderColor: surveyHighlightBorderColor,
placement: surveyPlacement,
} = productOverwrites || {};
const { placement: surveyPlacement } = productOverwrites || {};
const brandColor = surveyBrandColor || product.brandColor;
const placement = surveyPlacement || product.placement;
const highlightBorderColor = surveyHighlightBorderColor || product.highlightBorderColor;
const styling: TSurveyStyling | TProductStyling = useMemo(() => {
// allow style overwrite is disabled from the product
if (!product.styling.allowStyleOverwrite) {
return product.styling;
}
// allow style overwrite is enabled from the product
if (product.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return product.styling;
}
// survey style overwrite is enabled
return survey.styling;
}
return product.styling;
}, [product.styling, survey.styling]);
useEffect(() => {
// close modal if there are no questions left
@@ -201,22 +217,25 @@ export default function PreviewSurvey({
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</div>
<MediaBackground survey={survey} ContentRef={ContentRef} isMobilePreview>
<MediaBackground survey={survey} product={product} ContentRef={ContentRef} isMobilePreview>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={placement}
highlightBorderColor={highlightBorderColor}
previewMode="mobile">
highlightBorderColor={styling.highlightBorderColor?.light}
previewMode="mobile"
borderRadius={styling?.roundness ?? 8}
background={styling?.cardBackgroundColor?.light}>
<SurveyInline
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
languageCode={languageCode}
onFileUpload={onFileUpload}
styling={styling}
isCardBorderVisible={!styling.highlightBorderColor?.light}
onClose={handlePreviewModalClose}
/>
</Modal>
@@ -225,13 +244,13 @@ export default function PreviewSurvey({
<div className="no-scrollbar z-10 w-full max-w-md overflow-y-auto rounded-lg border border-transparent">
<SurveyInline
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
onFileUpload={onFileUpload}
languageCode={languageCode}
responseCount={42}
styling={styling}
/>
</div>
</div>
@@ -279,26 +298,28 @@ export default function PreviewSurvey({
<Modal
isOpen={isModalOpen}
placement={placement}
highlightBorderColor={highlightBorderColor}
previewMode="desktop">
highlightBorderColor={styling.highlightBorderColor?.light}
previewMode="desktop"
borderRadius={styling.roundness ?? 8}
background={styling.cardBackgroundColor?.light}>
<SurveyInline
survey={survey}
brandColor={brandColor}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
languageCode={languageCode}
onFileUpload={onFileUpload}
styling={styling}
isCardBorderVisible={!styling.highlightBorderColor?.light}
onClose={handlePreviewModalClose}
/>
</Modal>
) : (
<MediaBackground survey={survey} ContentRef={ContentRef} isEditorView>
<MediaBackground survey={survey} product={product} 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}
@@ -306,6 +327,7 @@ export default function PreviewSurvey({
onFileUpload={onFileUpload}
languageCode={languageCode}
responseCount={42}
styling={styling}
/>
</div>
</MediaBackground>

View File

@@ -9,6 +9,7 @@ import { getEnvironment } from "@formbricks/lib/environment/service";
import { reverseTranslateSurvey } from "@formbricks/lib/i18n/reverseTranslation";
import { getPerson } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { getSurveys, getSyncSurveys } from "@formbricks/lib/survey/service";
import {
getMonthlyActiveTeamPeopleCount,
@@ -18,6 +19,7 @@ import {
import { TEnvironment } from "@formbricks/types/environment";
import { TJsLegacyState, TSurveyWithTriggers } from "@formbricks/types/js";
import { TPerson } from "@formbricks/types/people";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
export const transformLegacySurveys = (surveys: TSurvey[]): TSurveyWithTriggers[] => {
@@ -114,13 +116,21 @@ export const getUpdatedState = async (environmentId: string, personId?: string):
throw new Error("Product not found");
}
const updatedProduct: TProduct = {
...product,
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(product.styling.highlightBorderColor?.light && {
highlightBorderColor: product.styling.highlightBorderColor.light,
}),
};
// return state
const state: TJsLegacyState = {
person,
session,
surveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
product: updatedProduct,
};
return state;

View File

@@ -12,6 +12,7 @@ import {
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { createPerson, getIsPersonMonthlyActive, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { getSyncSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/service";
import {
getMonthlyActiveTeamPeopleCount,
@@ -22,6 +23,7 @@ import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TEnvironment } from "@formbricks/types/environment";
import { TJsStateSync, ZJsPeopleUserIdInput } from "@formbricks/types/js";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
export async function OPTIONS(): Promise<Response> {
@@ -173,11 +175,20 @@ export async function GET(
);
}
const updatedProduct: TProduct = {
...product,
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(product.styling.highlightBorderColor?.light && {
highlightBorderColor: product.styling.highlightBorderColor.light,
}),
};
// return state
const state: TJsStateSync = {
person: personData,
surveys: !isInAppSurveyLimitReached ? transformedSurveys : [],
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
product: updatedProduct,
};
return responses.successResponse(

View File

@@ -12,11 +12,13 @@ import {
} from "@formbricks/lib/constants";
import { getEnvironment, updateEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { createSurvey, getSurveys, transformToLegacySurvey } from "@formbricks/lib/survey/service";
import { getMonthlyTeamResponseCount, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { isVersionGreaterThanOrEqualTo } from "@formbricks/lib/utils/version";
import { TLegacySurvey } from "@formbricks/types/LegacySurvey";
import { TJsStateSync, ZJsPublicSyncInput } from "@formbricks/types/js";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
export async function OPTIONS(): Promise<Response> {
@@ -117,11 +119,19 @@ export async function GET(
);
}
const updatedProduct: TProduct = {
...product,
brandColor: product.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
...(product.styling.highlightBorderColor?.light && {
highlightBorderColor: product.styling.highlightBorderColor.light,
}),
};
// Create the 'state' object with surveys, noCodeActionClasses, product, and person.
const state: TJsStateSync = {
surveys: isInAppSurveyLimitReached ? [] : transformedSurveys,
noCodeActionClasses: noCodeActionClasses.filter((actionClass) => actionClass.type === "noCode"),
product,
product: updatedProduct,
person: null,
};

View File

@@ -1,19 +1,5 @@
import { TInvite } from "@formbricks/types/invites";
export function isLight(color) {
let r, g, b;
if (color.length === 4) {
r = parseInt(color[1] + color[1], 16);
g = parseInt(color[2] + color[2], 16);
b = parseInt(color[3] + color[3], 16);
} else if (color.length === 7) {
r = parseInt(color[1] + color[2], 16);
g = parseInt(color[3] + color[4], 16);
b = parseInt(color[5] + color[6], 16);
}
return r * 0.299 + g * 0.587 + b * 0.114 > 128;
}
export const isInviteExpired = (invite: TInvite) => {
const now = new Date();
const expiresAt = new Date(invite.expiresAt);

View File

@@ -1,7 +1,6 @@
import Link from "next/link";
interface LegalFooterProps {
bgColor?: string | null;
IMPRINT_URL?: string;
PRIVACY_URL?: string;
IS_FORMBRICKS_CLOUD: boolean;
@@ -9,7 +8,6 @@ interface LegalFooterProps {
}
export default function LegalFooter({
bgColor,
IMPRINT_URL,
PRIVACY_URL,
IS_FORMBRICKS_CLOUD,
@@ -26,11 +24,7 @@ export default function LegalFooter({
};
return (
<div
className={`absolute bottom-0 h-12 w-full`}
style={{
backgroundColor: `${bgColor}`,
}}>
<div className={`absolute bottom-0 h-12 w-full`}>
<div className="mx-auto max-w-lg p-3 text-center text-xs text-slate-400">
{IMPRINT_URL && (
<Link href={IMPRINT_URL} target="_blank" className="hover:underline">

View File

@@ -83,8 +83,6 @@ export default function LinkSurvey({
? getPrefillResponseData(survey.questions[0], survey, prefillAnswer, languageCode)
: undefined;
const brandColor = survey.productOverwrites?.brandColor || product.brandColor;
const responseQueue = useMemo(
() =>
new ResponseQueue(
@@ -160,6 +158,26 @@ export default function LinkSurvey({
return <VerifyEmail singleUseId={suId ?? ""} survey={survey} languageCode={languageCode} />;
}
const getStyling = () => {
// allow style overwrite is disabled from the product
if (!product.styling.allowStyleOverwrite) {
return product.styling;
}
// allow style overwrite is enabled from the product
if (product.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return product.styling;
}
// survey style overwrite is enabled
return survey.styling;
}
return product.styling;
};
return (
<>
<ContentWrapper className="my-12 h-full w-full p-0 md:max-w-md">
@@ -179,7 +197,7 @@ export default function LinkSurvey({
)}
<SurveyInline
survey={survey}
brandColor={brandColor}
styling={getStyling()}
languageCode={languageCode}
isBrandingEnabled={product.linkSurveyBranding}
getSetIsError={(f: (value: boolean) => void) => {

View File

@@ -1,12 +1,14 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
interface MediaBackgroundProps {
children: React.ReactNode;
survey: TSurvey;
product: TProduct;
isEditorView?: boolean;
isMobilePreview?: boolean;
ContentRef?: React.RefObject<HTMLDivElement>;
@@ -14,6 +16,7 @@ interface MediaBackgroundProps {
export const MediaBackground: React.FC<MediaBackgroundProps> = ({
children,
product,
survey,
isEditorView = false,
isMobilePreview = false,
@@ -22,44 +25,63 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
const animatedBackgroundRef = useRef<HTMLVideoElement>(null);
const [backgroundLoaded, setBackgroundLoaded] = useState(false);
// get the background from either the survey or the product styling
const background = useMemo(() => {
// allow style overwrite is disabled from the product
if (!product.styling.allowStyleOverwrite) {
return product.styling.background;
}
// allow style overwrite is enabled from the product
if (product.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return product.styling.background;
}
// survey style overwrite is enabled
return survey.styling.background;
}
return product.styling.background;
}, [product.styling.allowStyleOverwrite, product.styling.background, survey.styling]);
useEffect(() => {
if (survey.styling?.background?.bgType === "animation" && animatedBackgroundRef.current) {
if (background?.bgType === "animation" && animatedBackgroundRef.current) {
const video = animatedBackgroundRef.current;
const onCanPlayThrough = () => setBackgroundLoaded(true);
video.addEventListener("canplaythrough", onCanPlayThrough);
video.src = survey.styling?.background?.bg || "";
video.src = background?.bg || "";
// Cleanup
return () => video.removeEventListener("canplaythrough", onCanPlayThrough);
} else if (survey.styling?.background?.bgType === "image" && survey.styling?.background?.bg) {
} else if (background?.bgType === "image" && background?.bg) {
// For images, we create a new Image object to listen for the 'load' event
const img = new Image();
img.onload = () => setBackgroundLoaded(true);
img.src = survey.styling?.background?.bg;
img.src = background?.bg;
} else {
// For colors or any other types, set to loaded immediately
setBackgroundLoaded(true);
}
}, [survey.styling?.background]);
}, [background?.bg, background?.bgType]);
const baseClasses = "absolute inset-0 h-full w-full transition-opacity duration-500";
const loadedClass = backgroundLoaded ? "opacity-100" : "opacity-0";
const getFilterStyle = () => {
return survey.styling?.background?.brightness
? `brightness(${survey.styling?.background?.brightness}%)`
: "brightness(100%)";
return `brightness(${background?.brightness ?? 100}%)`;
};
const renderBackground = () => {
const filterStyle = getFilterStyle();
switch (survey.styling?.background?.bgType) {
switch (background?.bgType) {
case "color":
return (
<div
className={`${baseClasses} ${loadedClass}`}
style={{ backgroundColor: survey.styling?.background?.bg || "#ffff", filter: `${filterStyle}` }}
style={{ backgroundColor: background?.bg || "#ffffff", filter: `${filterStyle}` }}
/>
);
case "animation":
@@ -72,14 +94,14 @@ export const MediaBackground: React.FC<MediaBackgroundProps> = ({
playsInline
className={`${baseClasses} ${loadedClass} object-cover`}
style={{ filter: `${filterStyle}` }}>
<source src={survey.styling?.background?.bg || ""} type="video/mp4" />
<source src={background?.bg || ""} type="video/mp4" />
</video>
);
case "image":
return (
<div
className={`${baseClasses} ${loadedClass} bg-cover bg-center`}
style={{ backgroundImage: `url(${survey.styling?.background?.bg})`, filter: `${filterStyle}` }}
style={{ backgroundImage: `url(${background?.bg})`, filter: `${filterStyle}` }}
/>
);
default:

View File

@@ -114,7 +114,7 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
return (
<div>
<MediaBackground survey={survey}>
<MediaBackground survey={survey} product={product}>
<LinkSurvey
survey={survey}
product={product}
@@ -129,7 +129,6 @@ const LinkSurveyPinScreen: NextPage<LinkSurveyPinScreenProps> = (props) => {
/>
</MediaBackground>
<LegalFooter
bgColor={survey.styling?.background?.bg || "#ffff"}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}

View File

@@ -13,6 +13,7 @@ import { IMPRINT_URL, IS_FORMBRICKS_CLOUD, PRIVACY_URL, WEBAPP_URL } from "@form
import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getResponseBySingleUseId, getResponseCountBySurveyId } from "@formbricks/lib/response/service";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { ZId } from "@formbricks/types/environment";
@@ -50,15 +51,16 @@ export async function generateMetadata({ params }: LinkSurveyPageProps): Promise
throw new Error("Product not found");
}
function getNameForURL(string) {
return string.replace(/ /g, "%20");
function getNameForURL(url: string) {
return url.replace(/ /g, "%20");
}
function getBrandColorForURL(string) {
return string.replace(/#/g, "%23");
function getBrandColorForURL(url: string) {
return url.replace(/#/g, "%23");
}
const brandColor = getBrandColorForURL(product.brandColor);
// const brandColor = getBrandColorForURL(product.brandColor);
const brandColor = getBrandColorForURL(survey.styling?.brandColor?.light || COLOR_DEFAULTS.brandColor);
const surveyName = getNameForURL(survey.name);
const ogImgURL = `/api/v1/og?brandColor=${brandColor}&name=${surveyName}`;
@@ -223,7 +225,7 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
return survey ? (
<div className="relative">
<MediaBackground survey={survey}>
<MediaBackground survey={survey} product={product}>
<LinkSurvey
survey={survey}
product={product}
@@ -239,7 +241,6 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
/>
</MediaBackground>
<LegalFooter
bgColor={survey.styling?.background?.bg || "#ffff"}
IMPRINT_URL={IMPRINT_URL}
PRIVACY_URL={PRIVACY_URL}
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}

View File

@@ -1,5 +1,6 @@
import { TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
import { TIntegrationConfig } from "@formbricks/types/integration";
import { TProductStyling } from "@formbricks/types/product";
import { TResponseData, TResponseMeta, TResponsePersonAttributes } from "@formbricks/types/responses";
import { TBaseFilters } from "@formbricks/types/segment";
import {
@@ -38,5 +39,6 @@ declare global {
export type UserNotificationSettings = TUserNotificationSettings;
export type SegmentFilter = TBaseFilters;
export type SurveyInlineTriggers = TSurveyInlineTriggers;
export type Styling = TProductStyling;
}
}

View File

@@ -0,0 +1,140 @@
import { PrismaClient } from "@prisma/client";
const DEFAULT_BRAND_COLOR = "#64748b";
const DEFAULT_STYLING = {
allowStyleOverwrite: true,
};
const prisma = new PrismaClient();
async function main() {
await prisma.$transaction(
async (tx) => {
// product table with brand color and the highlight border color (if available)
// styling object needs to be created for each product
const products = await tx.product.findMany({
include: { environments: { include: { surveys: true } } },
});
if (!products) {
// something went wrong, could not find any products
return;
}
if (products.length) {
for (const product of products) {
// no migration needed
// 1. product's brandColor is equal to the default one
// 2. product's styling object is equal the default one
// 3. product has no highlightBorderColor
if (
product.brandColor === DEFAULT_BRAND_COLOR &&
JSON.stringify(product.styling) === JSON.stringify(DEFAULT_STYLING) &&
!product.highlightBorderColor
) {
continue;
}
await tx.product.update({
where: {
id: product.id,
},
data: {
styling: {
...product.styling,
// only if the brand color is not null and not equal to the default one, we need to update the styling object. Otherwise, we'll just use the default value
...(product.brandColor &&
product.brandColor !== DEFAULT_BRAND_COLOR && {
brandColor: { light: product.brandColor },
}),
...(product.highlightBorderColor && {
highlightBorderColor: {
light: product.highlightBorderColor,
},
}),
},
brandColor: null,
highlightBorderColor: null,
},
});
// for each survey in the product, we need to update the stying object with the brand color and the highlight border color
for (const environment of product.environments) {
for (const survey of environment.surveys) {
const { styling } = product;
const { brandColor, highlightBorderColor } = styling;
if (!survey.styling) {
continue;
}
const { styling: surveyStyling } = survey;
const { hideProgressBar } = surveyStyling;
await tx.survey.update({
where: {
id: survey.id,
},
data: {
styling: {
...(survey.styling ?? {}),
...(brandColor &&
brandColor.light && {
brandColor: { light: brandColor.light },
}),
...(highlightBorderColor?.light && {
highlightBorderColor: {
light: highlightBorderColor.light,
},
}),
// if the previous survey had the hideProgressBar set to true, we need to update the styling object with overwriteThemeStyling set to true
...(hideProgressBar && {
overwriteThemeStyling: true,
}),
},
},
});
// if the survey has product overwrites, we need to update the styling object with the brand color and the highlight border color
if (survey.productOverwrites) {
const { brandColor, highlightBorderColor, ...rest } = survey.productOverwrites;
await tx.survey.update({
where: {
id: survey.id,
},
data: {
styling: {
...(survey.styling ?? {}),
...(brandColor && { brandColor: { light: brandColor } }),
...(highlightBorderColor && { highlightBorderColor: { light: highlightBorderColor } }),
...((brandColor ||
highlightBorderColor ||
Object.keys(survey.styling ?? {}).length > 0) && {
overwriteThemeStyling: true,
}),
},
productOverwrites: {
...rest,
},
},
});
}
}
}
}
}
},
{
timeout: 50000,
}
);
}
main()
.catch(async (e) => {
console.error(e);
process.exit(1);
})
.finally(async () => await prisma.$disconnect());

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "styling" JSONB NOT NULL DEFAULT '{"allowStyleOverwrite":true}',
ALTER COLUMN "brandColor" DROP NOT NULL,
ALTER COLUMN "brandColor" DROP DEFAULT;

View File

@@ -24,6 +24,8 @@
"post-install": "pnpm generate",
"predev": "pnpm generate",
"data-migration:v1.6": "ts-node ./migrations/20240207041922_advanced_targeting/data-migration.ts",
"data-migration:styling": "ts-node ./migrations/20240320090315_add_form_styling/data-migration.ts",
"data-migration:v1.7": "pnpm data-migration:mls && pnpm data-migration:styling",
"data-migration:mls": "ts-node ./migrations/20240318050527_add_languages_and_survey_languages/data-migration.ts",
"data-migration:mls-fix": "ts-node ./migrations/20240318050527_add_languages_and_survey_languages/data-migration-fix.ts",
"data-migration:mls-range-fix": "ts-node ./migrations/20240318050527_add_languages_and_survey_languages/data-migration-range-fix.ts"

View File

@@ -419,8 +419,11 @@ model Product {
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
teamId String
environments Environment[]
brandColor String @default("#64748b")
brandColor String?
highlightBorderColor String?
/// @zod.custom(imports.ZProductStyling)
/// [Styling]
styling Json @default("{\"allowStyleOverwrite\":true}")
recontactDays Int @default(7)
linkSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in link surveys
inAppSurveyBranding Boolean @default(true) // Determines if the survey branding should be displayed in in-app surveys

View File

@@ -1,5 +1,7 @@
import z from "zod";
export { ZProductStyling } from "@formbricks/types/styling";
export const ZActionProperties = z.record(z.string());
export { ZActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
export { ZIntegrationConfig } from "@formbricks/types/integration";

View File

@@ -177,7 +177,7 @@ export function AdvancedTargetingCard({
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border bg-green-400 p-1.5 text-white"
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
/>
</div>
<div>

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.7.1",
"version": "1.7.2",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {

View File

@@ -13,8 +13,8 @@ import { logoutPerson, resetPerson, setPersonAttribute, setPersonUserId } from "
declare global {
interface Window {
formbricksSurveys: {
renderSurveyInline: (props: SurveyInlineProps & { brandColor: string }) => void;
renderSurveyModal: (props: SurveyModalProps & { brandColor: string }) => void;
renderSurveyInline: (props: SurveyInlineProps) => void;
renderSurveyModal: (props: SurveyModalProps) => void;
};
}
}

View File

@@ -70,24 +70,41 @@ export const renderWidget = async (survey: TSurvey) => {
surveyState
);
const productOverwrites = survey.productOverwrites ?? {};
const brandColor = productOverwrites.brandColor ?? product.brandColor;
const highlightBorderColor = productOverwrites.highlightBorderColor ?? product.highlightBorderColor;
const clickOutside = productOverwrites.clickOutsideClose ?? product.clickOutsideClose;
const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay;
const placement = productOverwrites.placement ?? product.placement;
const isBrandingEnabled = product.inAppSurveyBranding;
const formbricksSurveys = await loadFormbricksSurveysExternally();
const getStyling = () => {
// allow style overwrite is disabled from the product
if (!product.styling.allowStyleOverwrite) {
return product.styling;
}
// allow style overwrite is enabled from the product
if (product.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return product.styling;
}
// survey style overwrite is enabled
return survey.styling;
}
return product.styling;
};
setTimeout(() => {
formbricksSurveys.renderSurveyModal({
survey: survey,
brandColor,
isBrandingEnabled: isBrandingEnabled,
clickOutside,
darkOverlay,
languageCode,
highlightBorderColor,
placement,
styling: getStyling(),
getSetIsError: (f: (value: boolean) => void) => {
setIsError = f;
},

View File

@@ -4,7 +4,7 @@ import { env } from "./env";
export const IS_FORMBRICKS_CLOUD = env.IS_FORMBRICKS_CLOUD === "1";
export const REVALIDATION_INTERVAL = 0; //TODO: find a good way to cache and revalidate data when it changes
export const SERVICES_REVALIDATION_INTERVAL = 60 * 60 * 3; // 3 hours
export const SERVICES_REVALIDATION_INTERVAL = 60 * 10; // 10 minutes
export const MAU_LIMIT = IS_FORMBRICKS_CLOUD ? 9000 : 1000000;
// URLs
@@ -117,7 +117,7 @@ export const PRICING_USERTARGETING_FREE_MTU = 2500;
export const PRICING_APPSURVEYS_FREE_RESPONSES = 250;
// Colors for Survey Bg
export const colours = [
export const SURVEY_BG_COLORS = [
"#FFF2D8",
"#EAD7BB",
"#BCA37F",

View File

@@ -25,9 +25,7 @@ const selectProduct = {
updatedAt: true,
name: true,
teamId: true,
brandColor: true,
languages: true,
highlightBorderColor: true,
recontactDays: true,
linkSurveyBranding: true,
inAppSurveyBranding: true,
@@ -35,6 +33,7 @@ const selectProduct = {
clickOutsideClose: true,
darkOverlay: true,
environments: true,
styling: true,
};
export const getProducts = async (teamId: string, page?: number): Promise<TProduct[]> => {

View File

@@ -0,0 +1,134 @@
// https://github.com/airbnb/javascript/#naming--uppercase
import { TSurvey } from "@formbricks/types/surveys";
export const COLOR_DEFAULTS = {
brandColor: "#64748b",
questionColor: "#2b2524",
inputColor: "#ffffff",
inputBorderColor: "#cbd5e1",
cardBackgroundColor: "#ffffff",
cardBorderColor: "#f8fafc",
cardShadowColor: "#000000",
highlightBorderColor: "#64748b",
} as const;
export const PREVIEW_SURVEY = {
id: "cltxxaa6x0000g8hacxdxejeu",
createdAt: new Date(),
updatedAt: new Date(),
name: "New Survey",
type: "link",
environmentId: "cltwumfcz0009echxg02fh7oa",
createdBy: "cltwumfbz0000echxysz6ptvq",
status: "inProgress",
welcomeCard: {
html: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,
headline: {
default: "Welcome!",
},
timeToFinish: false,
showResponseCount: false,
},
styling: null,
segment: null,
questions: [
{
id: "tunaz8ricd4regvkz1j0rbf6",
type: "openText",
headline: {
default: "This is a preview survey",
},
required: true,
inputType: "text",
subheader: {
default: "Click through it to check the look and feel of the surveying experience.",
},
placeholder: {
default: "Type your answer here...",
},
},
{
id: "lbdxozwikh838yc6a8vbwuju",
type: "rating",
range: 5,
scale: "star",
isDraft: true,
headline: {
default: "How would you rate My Product",
},
required: true,
subheader: {
default: "Don't worry, be honest.",
},
lowerLabel: {
default: "Not good",
},
upperLabel: {
default: "Very good",
},
},
{
id: "rjpu42ps6dzirsn9ds6eydgt",
type: "multipleChoiceSingle",
choices: [
{
id: "x6wty2s72v7vd538aadpurqx",
label: {
default: "Eat the cake 🍰",
},
},
{
id: "fbcj4530t2n357ymjp2h28d6",
label: {
default: "Have the cake 🎂",
},
},
],
isDraft: true,
headline: {
default: "What do you do?",
},
required: true,
subheader: {
default: "Can't do both.",
},
shuffleOption: "none",
},
],
thankYouCard: {
enabled: true,
headline: {
default: "Thank you!",
},
subheader: {
default: "We appreciate your feedback.",
},
},
hiddenFields: {
enabled: true,
fieldIds: [],
},
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: 50,
verifyEmail: null,
redirectUrl: null,
productOverwrites: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,
isEncrypted: true,
},
pin: null,
resultShareKey: null,
languages: [],
triggers: [],
inlineTriggers: null,
} as TSurvey;

View File

@@ -1,3 +1,85 @@
export const delay = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const hexToRGBA = (hex: string | undefined, opacity: number): string | undefined => {
// return undefined if hex is undefined, this is important for adding the default values to the CSS variables
// TODO: find a better way to handle this
if (!hex || hex === "") return undefined;
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (_, r, g, b) => r + r + g + g + b + b);
let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return "";
let r = parseInt(result[1], 16);
let g = parseInt(result[2], 16);
let b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
};
export const lightenDarkenColor = (hexColor: string, magnitude: number): string => {
hexColor = hexColor.replace(`#`, ``);
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
if (hexColor.length === 3) {
hexColor = hexColor
.split("")
.map((char) => char + char)
.join("");
}
if (hexColor.length === 6) {
let decimalColor = parseInt(hexColor, 16);
let r = (decimalColor >> 16) + magnitude;
r = Math.max(0, Math.min(255, r)); // Clamp value between 0 and 255
let g = ((decimalColor >> 8) & 0x00ff) + magnitude;
g = Math.max(0, Math.min(255, g)); // Clamp value between 0 and 255
let b = (decimalColor & 0x0000ff) + magnitude;
b = Math.max(0, Math.min(255, b)); // Clamp value between 0 and 255
// Convert back to hex and return
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
} else {
// Return the original color if it's neither 3 nor 6 characters
return hexColor;
}
};
export const mixColor = (hexColor: string, mixWithHex: string, weight: number): string => {
// Convert both colors to RGBA format
const color1 = hexToRGBA(hexColor, 1) || "";
const color2 = hexToRGBA(mixWithHex, 1) || "";
// Extract RGBA values
const [r1, g1, b1] = color1.match(/\d+/g)?.map(Number) || [0, 0, 0];
const [r2, g2, b2] = color2.match(/\d+/g)?.map(Number) || [0, 0, 0];
// Mix the colors
const r = Math.round(r1 * (1 - weight) + r2 * weight);
const g = Math.round(g1 * (1 - weight) + g2 * weight);
const b = Math.round(b1 * (1 - weight) + b2 * weight);
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
};
export function isLight(color: string) {
let r: number | undefined, g: number | undefined, b: number | undefined;
if (color.length === 4) {
r = parseInt(color[1] + color[1], 16);
g = parseInt(color[2] + color[2], 16);
b = parseInt(color[3] + color[3], 16);
} else if (color.length === 7) {
r = parseInt(color[1] + color[2], 16);
g = parseInt(color[3] + color[4], 16);
b = parseInt(color[5] + color[6], 16);
}
if (r === undefined || g === undefined || b === undefined) {
throw new Error("Invalid color");
}
return r * 0.299 + g * 0.587 + b * 0.114 > 128;
}

View File

@@ -1,4 +1,4 @@
export const isVersionGreaterThanOrEqualTo = (version, specificVersion) => {
export const isVersionGreaterThanOrEqualTo = (version: string, specificVersion: string) => {
// return true; // uncomment when testing in demo app
const parts1 = version.split(".").map(Number);
const parts2 = specificVersion.split(".").map(Number);

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/surveys",
"license": "MIT",
"version": "1.7.1",
"version": "1.7.2",
"description": "Formbricks-surveys is a helper library to embed surveys into your application",
"homepage": "https://formbricks.com",
"repository": {

View File

@@ -12,7 +12,7 @@ export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButto
tabIndex={tabIndex}
type={"button"}
className={cn(
"border-back-button-border text-heading focus:ring-focus flex items-center rounded-md border px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"
"border-back-button-border text-heading focus:ring-focus rounded-custom flex items-center border px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"
)}
onClick={onClick}>
{backButtonLabel || "Back"}

View File

@@ -34,7 +34,7 @@ function SubmitButton({
type={type}
tabIndex={tabIndex}
autoFocus={focus}
className="bg-brand border-submit-button-border text-on-brand focus:ring-focus flex items-center rounded-md border px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"
className="bg-brand border-submit-button-border text-on-brand focus:ring-focus rounded-custom flex items-center border px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"
onClick={onClick}>
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
</button>

View File

@@ -202,7 +202,7 @@ export default function FileInput({
}, [allowMultipleFiles, fileUrls, isUploading]);
return (
<div className="items-left relative mt-3 flex w-full cursor-pointer flex-col justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800">
<div className="items-left bg-input-bg hover:bg-input-bg-selected border-border relative mt-3 flex w-full flex-col justify-center rounded-lg border-2 border-dashed dark:border-slate-600 dark:bg-slate-700 dark:hover:border-slate-500 dark:hover:bg-slate-800">
<div className="max-h-[30vh] overflow-auto">
{fileUrls &&
fileUrls?.map((file, index) => {
@@ -252,7 +252,7 @@ export default function FileInput({
<div>
{isUploading && (
<div className="inset-0 flex animate-pulse items-center justify-center rounded-lg bg-slate-100 py-4">
<div className="inset-0 flex animate-pulse items-center justify-center rounded-lg py-4">
<label htmlFor="selectedFile" className="text-sm font-medium text-slate-500">
Uploading...
</label>
@@ -261,14 +261,14 @@ export default function FileInput({
<label htmlFor="selectedFile" onDragOver={(e) => handleDragOver(e)} onDrop={(e) => handleDrop(e)}>
{showUploader && (
<div className="flex flex-col items-center justify-center py-6">
<div className="flex flex-col items-center justify-center py-6 hover:cursor-pointer">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-6 text-slate-500">
className="text-placeholder h-6">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -276,7 +276,7 @@ export default function FileInput({
/>
</svg>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
<p className="text-placeholder mt-2 text-sm dark:text-slate-400">
<span className="font-medium">Click or drag to upload files.</span>
</p>
<input

View File

@@ -8,7 +8,7 @@ export default function FormbricksBranding() {
<p className="text-signature text-xs">
Powered by{" "}
<b>
<span className="text-info-text hover:text-heading">Formbricks</span>
<span className="text-branding-text hover:text-signature">Formbricks</span>
</b>
</p>
</a>

View File

@@ -19,6 +19,7 @@ import WelcomeCard from "./WelcomeCard";
export function Survey({
survey,
styling,
isBrandingEnabled,
activeQuestionId,
onDisplay = () => {},
@@ -34,6 +35,7 @@ export function Survey({
getSetIsResponseSendingFinished,
onFileUpload,
responseCount,
isCardBorderVisible = true,
}: SurveyBaseProps) {
const [questionId, setQuestionId] = useState(
activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
@@ -60,7 +62,7 @@ export function Survey({
}
}, [questionId, survey, history]);
const contentRef = useRef<HTMLDivElement | null>(null);
const showProgressBar = !survey.styling?.hideProgressBar;
const showProgressBar = !styling.hideProgressBar;
useEffect(() => {
if (activeQuestionId === "hidden" || activeQuestionId === "multiLanguage") return;
@@ -316,7 +318,12 @@ export function Survey({
return (
<>
<AutoCloseWrapper survey={survey} onClose={onClose}>
<div className="no-scrollbar flex h-full w-full flex-col justify-between rounded-lg bg-[--fb-survey-background-color] px-6 pb-3 pt-6">
<div
className={cn(
"no-scrollbar rounded-custom bg-survey-bg flex h-full w-full flex-col justify-between px-6 pb-3 pt-6",
isCardBorderVisible ? "border-survey-border border" : "",
survey.type === "link" ? "fb-survey-shadow" : ""
)}>
<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

@@ -13,7 +13,6 @@ export function SurveyModal({
placement,
clickOutside,
darkOverlay,
highlightBorderColor,
onDisplay,
getSetIsResponseSendingFinished,
onActiveQuestionChange,
@@ -25,6 +24,7 @@ export function SurveyModal({
isRedirectDisabled = false,
languageCode,
responseCount,
styling,
}: SurveyModalProps) {
const [isOpen, setIsOpen] = useState(true);
@@ -37,6 +37,8 @@ export function SurveyModal({
}, 1000); // wait for animation to finish}
};
const highlightBorderColor = styling?.highlightBorderColor?.light || null;
return (
<div id="fbjs" className="formbricks-form">
<Modal
@@ -69,6 +71,8 @@ export function SurveyModal({
onFileUpload={onFileUpload}
isRedirectDisabled={isRedirectDisabled}
responseCount={responseCount}
styling={styling}
isCardBorderVisible={!highlightBorderColor}
/>
</Modal>
</div>

View File

@@ -67,7 +67,7 @@ export default function ThankYouCard({
/>
</svg>
</div>
<span className="bg-shadow mb-[10px] inline-block h-1 w-16 rounded-[100%]"></span>
<span className="bg-brand mb-[10px] inline-block h-1 w-16 rounded-[100%]"></span>
</div>
)}

View File

@@ -62,7 +62,7 @@ export default function ConsentQuestion({
onChange({ [question.id]: "accepted" });
}
}}
className="border-border bg-survey-bg text-heading hover:bg-accent-bg focus:bg-accent-bg focus:ring-border-highlight relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border p-4 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2">
className="border-border bg-input-bg text-heading hover:bg-input-bg-selected focus:bg-input-bg-selected focus:ring-brand rounded-custom relative z-10 mt-4 flex w-full cursor-pointer items-center border p-4 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2">
<input
type="checkbox"
id={question.id}

View File

@@ -140,7 +140,7 @@ export default function DateQuestion({
<div className={cn("my-4", errorMessage && "rounded-lg border-2 border-red-500")} id="date-picker-root">
{loading && (
<div className="relative flex h-12 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border border-slate-300 bg-white text-left text-base font-normal text-slate-900 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
<div className="bg-survey-bg border-border text-placeholder relative flex h-12 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border text-left text-base font-normal focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
<span
className="h-6 w-6 animate-spin rounded-full border-b-2 border-neutral-900"
style={{ borderTopColor: "transparent" }}></span>

View File

@@ -145,7 +145,7 @@ export default function MultipleChoiceMultiQuestion({
<fieldset>
<legend className="sr-only">Options</legend>
<div
className="bg-survey-bg relative max-h-[33vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2"
className="bg-survey-bg relative max-h-[33vh] space-y-2 overflow-y-auto py-0.5 pr-2"
ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => (
<label
@@ -161,10 +161,8 @@ export default function MultipleChoiceMultiQuestion({
}
}}
className={cn(
value === choice.label
? "border-border-highlight bg-accent-selected-bg z-10"
: "border-border",
"text-heading focus-within:border-border-highlight hover:bg-accent-bg focus:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
value === choice.label ? "border-border bg-input-selected-bg z-10" : "border-border",
"text-heading bg-input-bg focus-within:border-brand hover:bg-input-bg-selected focus:bg-input-bg-selected rounded-custom relative flex cursor-pointer flex-col border p-4 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
@@ -200,9 +198,9 @@ export default function MultipleChoiceMultiQuestion({
tabIndex={questionChoices.length + 1}
className={cn(
value === getLocalizedValue(otherOption.label, languageCode)
? "border-border-highlight bg-accent-selected-bg z-10"
? "border-border bg-input-selected-bg z-10"
: "border-border",
"text-heading focus-within:border-border-highlight focus-within:bg-accent-bg hover:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
"text-heading focus-within:border-border focus-within:bg-input-bg-selected hover:bg-input-bg-selected rounded-custom relative flex cursor-pointer flex-col border p-4 focus:outline-none"
)}
onKeyDown={(e) => {
if (e.key == "Enter") {
@@ -253,10 +251,10 @@ export default function MultipleChoiceMultiQuestion({
}, 100);
}
}}
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus rounded-custom mt-3 flex h-10 w-full border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode) ?? "Please specify"
}
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus mt-3 flex h-10 w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
/>

View File

@@ -98,7 +98,7 @@ export default function MultipleChoiceSingleQuestion({
<legend className="sr-only">Options</legend>
<div
className="bg-survey-bg relative max-h-[33vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2"
className="bg-survey-bg relative max-h-[33vh] space-y-2 overflow-y-auto py-0.5 pr-2"
role="radiogroup"
ref={choicesContainerRef}>
{questionChoices.map((choice, idx) => (
@@ -116,10 +116,8 @@ export default function MultipleChoiceSingleQuestion({
}
}}
className={cn(
value === choice.label
? "border-border-highlight bg-accent-selected-bg z-10"
: "border-border",
"text-heading focus-within:border-border-highlight focus-within:bg-accent-bg hover:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
value === choice.label ? "border-brand z-10" : "border-border",
"text-heading bg-input-bg focus-within:border-brand focus-within:bg-input-bg-selected hover:bg-input-bg-selected rounded-custom relative flex cursor-pointer flex-col border p-4 focus:outline-none"
)}>
<span className="flex items-center text-sm">
<input
@@ -148,9 +146,9 @@ export default function MultipleChoiceSingleQuestion({
tabIndex={questionChoices.length + 1}
className={cn(
value === getLocalizedValue(otherOption.label, languageCode)
? "border-border-highlight bg-accent-selected-bg z-10"
? "border-border bg-input-bg-selected z-10"
: "border-border",
"text-heading focus-within:border-border-highlight focus-within:bg-accent-bg hover:bg-accent-bg relative flex cursor-pointer flex-col rounded-md border p-4 focus:outline-none"
"text-heading focus-within:border-brand bg-input-bg focus-within:bg-input-bg-selected hover:bg-input-bg-selected rounded-custom relative flex cursor-pointer flex-col border p-4 focus:outline-none"
)}
onKeyDown={(e) => {
if (e.key == "Enter") {
@@ -196,10 +194,10 @@ export default function MultipleChoiceSingleQuestion({
}, 100);
}
}}
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus rounded-custom mt-3 flex h-10 w-full border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={
getLocalizedValue(question.otherOptionPlaceholder, languageCode) ?? "Please specify"
}
className="placeholder:text-placeholder border-border bg-survey-bg text-heading focus:ring-focus mt-3 flex h-10 w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required={question.required}
aria-labelledby={`${otherOption.id}-label`}
/>

View File

@@ -8,8 +8,7 @@ import { cn } from "@/lib/utils";
import { useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TResponseData } from "@formbricks/types/responses";
import { TResponseTtc } from "@formbricks/types/responses";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import type { TSurveyNPSQuestion } from "@formbricks/types/surveys";
interface NPSQuestionProps {
@@ -65,48 +64,50 @@ export default function NPSQuestion({
<fieldset>
<legend className="sr-only">Options</legend>
<div className="flex">
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => (
<label
key={number}
tabIndex={idx + 1}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(-1)}
onKeyDown={(e) => {
if (e.key == "Enter") {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit({ [question.id]: number }, updatedTtcObj);
}
}}
className={cn(
value === number ? "border-border-highlight bg-accent-selected-bg z-10" : "border-border",
"bg-survey-bg text-heading relative h-10 flex-1 cursor-pointer border text-center text-sm leading-10 first:rounded-l-md last:rounded-r-md focus:outline-none",
hoveredNumber === number ? "bg-accent-bg" : ""
)}>
<input
type="radio"
name="nps"
value={number}
checked={value === number}
className="absolute h-full w-full cursor-pointer opacity-0"
onClick={() => {
if (question.required) {
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => {
return (
<label
key={number}
tabIndex={idx + 1}
onMouseOver={() => setHoveredNumber(number)}
onMouseLeave={() => setHoveredNumber(-1)}
onKeyDown={(e) => {
if (e.key == "Enter") {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit(
{
[question.id]: number,
},
updatedTtcObj
);
onSubmit({ [question.id]: number }, updatedTtcObj);
}
onChange({ [question.id]: number });
}}
required={question.required}
/>
{number}
</label>
))}
className={cn(
value === number ? "border-border-highlight bg-accent-selected-bg z-10" : "border-border",
"text-heading first:rounded-l-custom last:rounded-r-custom relative h-10 flex-1 cursor-pointer border-b border-l border-t text-center text-sm leading-10 last:border-r focus:outline-none",
hoveredNumber === number ? "bg-accent-bg" : ""
)}>
<input
type="radio"
name="nps"
value={number}
checked={value === number}
className="absolute h-full w-full cursor-pointer opacity-0"
onClick={() => {
if (question.required) {
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedTtcObj);
onSubmit(
{
[question.id]: number,
},
updatedTtcObj
);
}
onChange({ [question.id]: number });
}}
required={question.required}
/>
{number}
</label>
);
})}
</div>
<div className="text-info-text flex justify-between px-1.5 text-xs leading-6">
<p>{getLocalizedValue(question.lowerLabel, languageCode)}</p>

View File

@@ -44,8 +44,6 @@ export default function OpenTextQuestion({
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
const handleInputChange = (inputValue: string) => {
// const isValidInput = validateInput(inputValue, question.inputType, question.required);
// setIsValid(isValidInput);
onChange({ [question.id]: inputValue });
};
@@ -72,11 +70,9 @@ export default function OpenTextQuestion({
key={question.id}
onSubmit={(e) => {
e.preventDefault();
// if ( validateInput(value as string, question.inputType, question.required)) {
const updatedttc = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
setTtc(updatedttc);
onSubmit({ [question.id]: value, inputType: question.inputType }, updatedttc);
// }
}}
className="w-full">
{question.imageUrl && <QuestionImage imgUrl={question.imageUrl} />}
@@ -103,7 +99,7 @@ export default function OpenTextQuestion({
type={question.inputType}
onInput={(e) => handleInputChange(e.currentTarget.value)}
autoFocus={autoFocus}
className="border-border bg-survey-bg focus:border-border-highlight block w-full rounded-md border p-2 shadow-sm focus:outline-none focus:ring-0 sm:text-sm"
className="border-border placeholder:text-placeholder text-subheading focus:border-border-highlight bg-input-bg block w-full rounded-md border p-2 shadow-sm focus:outline-none focus:ring-0 sm:text-sm"
pattern={question.inputType === "phone" ? "[0-9+ ]+" : ".*"}
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
/>
@@ -123,7 +119,7 @@ export default function OpenTextQuestion({
handleInputResize(e);
}}
autoFocus={autoFocus}
className="border-border bg-survey-bg text-subheading focus:border-border-highlight block w-full rounded-md border p-2 shadow-sm focus:ring-0 sm:text-sm"
className="border-border placeholder:text-placeholder bg-input-bg text-subheading focus:border-border-highlight rounded-custom block w-full border p-2 shadow-sm focus:ring-0 sm:text-sm"
pattern={question.inputType === "phone" ? "[+][0-9 ]+" : ".*"}
title={question.inputType === "phone" ? "Please enter a valid phone number" : undefined}
/>

View File

@@ -128,7 +128,7 @@ export default function PictureSelectionQuestion({
Array.isArray(value) && value.includes(choice.id)
? `border-brand text-brand z-10 border-4 shadow-xl focus:border-4`
: "",
"border-border focus:border-border-highlight focus:bg-accent-selected-bg group/image relative box-border inline-block h-28 w-full cursor-pointer overflow-hidden rounded-xl border focus:outline-none"
"border-border focus:bg-accent-selected-bg relative box-border inline-block h-28 w-full overflow-hidden rounded-xl border focus:outline-none"
)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img

View File

@@ -123,11 +123,13 @@ export default function RatingQuestion({
}
}}
className={cn(
value === number ? "bg-accent-selected-bg border-border-highlight z-10" : "",
a.length === number ? "rounded-r-md" : "",
value === number
? "bg-accent-selected-bg border-border-highlight z-10"
: "border-border",
a.length === number ? "rounded-r-md border-r" : "",
number === 1 ? "rounded-l-md" : "",
hoveredNumber === number ? "bg-accent-bg " : "",
"text-heading focus:bg-accent-bg relative flex min-h-[41px] w-full cursor-pointer items-center justify-center border focus:outline-none"
"text-heading focus:bg-accent-bg relative flex min-h-[41px] w-full cursor-pointer items-center justify-center border-b border-l border-t focus:outline-none"
)}>
<HiddenRadioInput number={number} />
{number}
@@ -144,7 +146,7 @@ export default function RatingQuestion({
"relative flex max-h-16 min-h-9 cursor-pointer justify-center focus:outline-none",
number <= hoveredNumber || number <= (value as number)
? "text-amber-400"
: "text-slate-300",
: "text-input-bg-selected",
hoveredNumber === number ? "text-amber-400 " : ""
)}
onFocus={() => setHoveredNumber(number)}

View File

@@ -75,7 +75,7 @@ export default function Modal({
};
return {
borderRadius: "8px",
borderRadius: "var(--fb-border-radius)",
border: "2px solid",
borderColor: highlightBorderColor,
};
@@ -104,7 +104,7 @@ export default function Modal({
className={cn(
getPlacementStyle(placement),
show ? "opacity-100" : "opacity-0",
"border-border pointer-events-auto absolute bottom-0 h-fit w-full overflow-visible rounded-lg border bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
"rounded-custom pointer-events-auto absolute bottom-0 h-fit w-full overflow-visible bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
)}>
{!isCenter && (
<div class="absolute right-0 top-0 block pr-2 pt-2">

View File

@@ -8,15 +8,15 @@ import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbrick
declare global {
interface Window {
formbricksSurveys: {
renderSurveyInline: (props: SurveyInlineProps & { brandColor: string }) => void;
renderSurveyModal: (props: SurveyModalProps & { brandColor: string }) => void;
renderSurveyInline: (props: SurveyInlineProps) => void;
renderSurveyModal: (props: SurveyModalProps) => void;
};
}
}
export const renderSurveyInline = (props: SurveyInlineProps & { brandColor: string }) => {
export const renderSurveyInline = (props: SurveyInlineProps) => {
addStylesToDom();
addCustomThemeToDom({ brandColor: props.brandColor });
addCustomThemeToDom({ styling: props.styling });
const element = document.getElementById(props.containerId);
if (!element) {
@@ -25,9 +25,9 @@ export const renderSurveyInline = (props: SurveyInlineProps & { brandColor: stri
render(h(SurveyInline, props), element);
};
export const renderSurveyModal = (props: SurveyModalProps & { brandColor: string }) => {
export const renderSurveyModal = (props: SurveyModalProps) => {
addStylesToDom();
addCustomThemeToDom({ brandColor: props.brandColor });
addCustomThemeToDom({ styling: props.styling });
// add container element to DOM
const element = document.createElement("div");

View File

@@ -1,7 +1,10 @@
import { isLight } from "@/lib/utils";
import global from "@/styles/global.css?inline";
import preflight from "@/styles/preflight.css?inline";
import { isLight, mixColor } from "@formbricks/lib/utils";
import { TProductStyling } from "@formbricks/types/product";
import { TSurveyStyling } from "@formbricks/types/surveys";
import editorCss from "../../../ui/Editor/stylesEditorFrontend.css?inline";
export const addStylesToDom = () => {
@@ -13,16 +16,108 @@ export const addStylesToDom = () => {
}
};
export const addCustomThemeToDom = ({ brandColor }: { brandColor: string }) => {
if (document.getElementById("formbricks__css") === null) return;
export const addCustomThemeToDom = ({ styling }: { styling: TProductStyling | TSurveyStyling }) => {
// Check if the style element already exists
let styleElement = document.getElementById("formbricks__css__custom");
const styleElement = document.createElement("style");
styleElement.id = "formbricks__css__custom";
styleElement.innerHTML = `
:root {
--fb-brand-color: ${brandColor};
${isLight(brandColor) ? "--fb-brand-text-color: black;" : "--fb-brand-text-color: white;"}
// If the style element doesn't exist, create it and append to the head
if (!styleElement) {
styleElement = document.createElement("style");
styleElement.id = "formbricks__css__custom";
document.head.appendChild(styleElement);
}
// Start the innerHTML string with :root
let cssVariables = ":root {\n";
// Helper function to append the variable if it's not undefined
const appendCssVariable = (variableName: string, value: string | undefined) => {
if (value !== undefined) {
cssVariables += `--fb-${variableName}: ${value};\n`;
}
`;
document.head.appendChild(styleElement);
};
// if roundness is defined, even if it's 0, set the border-radius
const roundness = styling.roundness ?? 8;
// Use the helper function to append CSS variables
appendCssVariable("brand-color", styling.brandColor?.light);
appendCssVariable("focus-color", styling.brandColor?.light);
if (!!styling.brandColor?.light) {
// If the brand color is defined, set the text color based on the lightness of the brand color
appendCssVariable("brand-text-color", isLight(styling.brandColor?.light) ? "black" : "white");
} else {
// If the brand color is undefined, default to white
appendCssVariable("brand-text-color", "#ffffff");
}
if (styling.cardShadowColor?.light) {
// mix the shadow color with white to get a lighter shadow
appendCssVariable("survey-shadow-color", mixColor(styling.cardShadowColor.light, "#ffffff", 0.4));
}
appendCssVariable("heading-color", styling.questionColor?.light);
appendCssVariable("subheading-color", styling.questionColor?.light);
if (styling.questionColor?.light) {
appendCssVariable("placeholder-color", mixColor(styling.questionColor?.light, "#ffffff", 0.3));
}
appendCssVariable("border-color", styling.inputBorderColor?.light);
if (styling.inputBorderColor?.light) {
appendCssVariable("border-color-highlight", mixColor(styling.inputBorderColor?.light, "#000000", 0.1));
}
appendCssVariable("survey-background-color", styling.cardBackgroundColor?.light);
appendCssVariable("survey-border-color", styling.cardBorderColor?.light);
appendCssVariable("border-radius", `${roundness}px`);
appendCssVariable("input-background-color", styling.inputColor?.light);
if (styling.questionColor?.light) {
let signatureColor = "";
let brandingColor = "";
if (isLight(styling.questionColor?.light)) {
signatureColor = mixColor(styling.questionColor?.light, "#000000", 0.2);
brandingColor = mixColor(styling.questionColor?.light, "#000000", 0.3);
} else {
signatureColor = mixColor(styling.questionColor?.light, "#ffffff", 0.2);
brandingColor = mixColor(styling.questionColor?.light, "#ffffff", 0.3);
}
appendCssVariable("signature-text-color", signatureColor);
appendCssVariable("branding-text-color", brandingColor);
}
if (!!styling.inputColor?.light) {
if (
styling.inputColor.light === "#fff" ||
styling.inputColor.light === "#ffffff" ||
styling.inputColor.light === "white"
) {
appendCssVariable("input-background-color-selected", "var(--slate-50)");
} else {
appendCssVariable(
"input-background-color-selected",
mixColor(styling.inputColor?.light, "#000000", 0.025)
);
}
}
if (styling.brandColor?.light) {
const brandColor = styling.brandColor.light;
const accentColor = mixColor(brandColor, "#ffffff", 0.8);
const accentColorSelected = mixColor(brandColor, "#ffffff", 0.7);
appendCssVariable("accent-background-color", accentColor);
appendCssVariable("accent-background-color-selected", accentColorSelected);
}
// Close the :root block
cssVariables += "}";
// Set the innerHTML of the style element
styleElement.innerHTML = cssVariables;
};

View File

@@ -4,23 +4,6 @@ export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
export function isLight(color: string) {
let r, g, b;
if (color.length === 4) {
r = parseInt(color[1] + color[1], 16);
g = parseInt(color[2] + color[2], 16);
b = parseInt(color[3] + color[3], 16);
} else if (color.length === 7) {
r = parseInt(color[1] + color[2], 16);
g = parseInt(color[3] + color[4], 16);
b = parseInt(color[5] + color[6], 16);
}
if (r === undefined || g === undefined || b === undefined) {
throw new Error("Invalid color");
}
return r * 0.299 + g * 0.587 + b * 0.114 > 128;
}
const shuffle = (array: any[]) => {
for (let i = 0; i < array.length; i++) {
const j = Math.floor(Math.random() * (i + 1));

View File

@@ -2,13 +2,47 @@ import { useEffect, useMemo, useState } from "preact/hooks";
import DatePicker from "react-date-picker";
const CalendarIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
<path d="M12.75 12.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM7.5 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM8.25 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM9.75 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM10.5 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM12.75 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM14.25 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM15 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM16.5 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM15 12.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM16.5 13.5a.75.75 0 100-1.5.75.75 0 000 1.5z" />
<path
fill-rule="evenodd"
d="M6.75 2.25A.75.75 0 017.5 3v1.5h9V3A.75.75 0 0118 3v1.5h.75a3 3 0 013 3v11.25a3 3 0 01-3 3H5.25a3 3 0 01-3-3V7.5a3 3 0 013-3H6V3a.75.75 0 01.75-.75zm13.5 9a1.5 1.5 0 00-1.5-1.5H5.25a1.5 1.5 0 00-1.5 1.5v7.5a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5v-7.5z"
clip-rule="evenodd"
/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-calendar-days">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
<path d="M8 14h.01" />
<path d="M12 14h.01" />
<path d="M16 14h.01" />
<path d="M8 18h.01" />
<path d="M12 18h.01" />
<path d="M16 18h.01" />
</svg>
);
const CalendarCheckIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-calendar-check">
<path d="M8 2v4" />
<path d="M16 2v4" />
<rect width="18" height="18" x="3" y="4" rx="2" />
<path d="M3 10h18" />
<path d="m9 16 2 2 4-4" />
</svg>
);
@@ -54,10 +88,17 @@ export default function Question({ defaultDate, format }: { defaultDate?: Date;
{!datePickerOpen && (
<div
onClick={() => setDatePickerOpen(true)}
className="relative flex h-40 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border border-slate-300 bg-slate-50 text-left text-base font-normal text-slate-900 hover:bg-slate-100 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
className="bg-input-bg hover:bg-input-bg-selected border-border text-placeholder relative flex h-40 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border text-left text-base font-normal focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
<div className="flex items-center gap-2">
<CalendarIcon />
<span>{selectedDate ? formattedDate : "Select a date"}</span>
{selectedDate ? (
<div className="flex items-center gap-2">
<CalendarCheckIcon /> <span>{formattedDate}</span>
</div>
) : (
<div className="flex items-center gap-2">
<CalendarIcon /> <span>Select a date</span>
</div>
)}
</div>
</div>
)}
@@ -78,7 +119,7 @@ export default function Question({ defaultDate, format }: { defaultDate?: Date;
monthPlaceholder="MM"
yearPlaceholder="YYYY"
format={format ?? "M-d-y"}
className={`dp-input-root rounded-lg ${!datePickerOpen ? "wrapper-hide" : ""}
className={`dp-input-root rounded-custom ${!datePickerOpen ? "wrapper-hide" : ""}
${hideInvalid ? "hide-invalid" : ""}
`}
calendarClassName="calendar-root w-80 rounded-lg border border-[#e5e7eb] p-3 shadow-md"

View File

@@ -6,6 +6,7 @@
height: 160px;
display: flex;
background: rgb(248 250 252);
background: var(--fb-survey-background-color);
flex-direction: row-reverse;
gap: 8px;
justify-content: center;
@@ -54,6 +55,7 @@
.calendar-root {
position: absolute !important;
top: 0 !important;
background: var(--fb-survey-background-color) !important;
}
.calendar-root [class$="navigation"] {

View File

@@ -42,12 +42,17 @@ p.fb-editor-paragraph {
color: var(--fb-subheading-color) !important;
}
.fb-survey-shadow {
box-shadow: 0px 0px 90px -40px var(--fb-survey-shadow-color);
}
/* theming */
:root {
--slate-50: rgb(248 250 252);
--brand-default: #64748b;
--slate-50: rgb(248, 250, 252);
--slate-100: rgb(241 245 249);
--slate-200: rgb(226 232 240);
--slate-300: rgb(203 213 225);
--slate-300: rgb(203, 213, 225);
--slate-400: rgb(148 163 184);
--slate-500: rgb(100 116 139);
--slate-600: rgb(71 85 105);
@@ -60,18 +65,24 @@ p.fb-editor-paragraph {
--yellow-500: rgb(234 179 8);
/* Default Light Theme, you can override everything by changing these values */
--fb-brand-color: rgb(255, 255, 255);
--fb-brand-color: var(--brand-default);
--fb-brand-text-color: black;
--fb-border-color: var(--slate-300);
--fb-border-color-highlight: var(--slate-500);
--fb-focus-color: var(--slate-500);
--fb-heading-color: var(--slate-900);
--fb-subheading-color: var(--slate-700);
--fb-placeholder-color: var(--slate-300);
--fb-info-text-color: var(--slate-500);
--fb-signature-text-color: var(--slate-400);
--fb-branding-text-color: var(--slate-500);
--fb-survey-background-color: white;
--fb-survey-border-color: var(--slate-50);
--fb-survey-shadow-color: rgba(0, 0, 0, 0.4);
--fb-accent-background-color: var(--slate-200);
--fb-accent-background-color-selected: var(--slate-100);
--fb-input-background-color: var(--slate-50);
--fb-input-background-color-selected: var(--slate-100);
--fb-placeholder-color: var(--slate-400);
--fb-shadow-color: var(--slate-300);
--fb-rating-fill: var(--yellow-300);
@@ -81,6 +92,8 @@ p.fb-editor-paragraph {
--fb-rating-selected: black;
--fb-close-btn-color: var(--slate-500);
--fb-close-btn-color-hover: var(--slate-700);
--fb-border-radius: 8px;
}
@keyframes shrink-width-to-zero {
@@ -90,4 +103,4 @@ p.fb-editor-paragraph {
to {
width: 0%;
}
}
}

View File

@@ -16,11 +16,17 @@ module.exports = {
focus: "var(--fb-focus-color)",
heading: "var(--fb-heading-color)",
subheading: "var(--fb-subheading-color)",
placeholder: "var(--fb-placeholder-color)",
"info-text": "var(--fb-info-text-color)",
signature: "var(--fb-signature-text-color)",
"branding-text": "var(--fb-branding-text-color)",
"survey-bg": "var(--fb-survey-background-color)",
"survey-border": "var(--fb-survey-border-color)",
"survey-shadow": "var(--fb-survey-shadow-color)",
"accent-bg": "var(--fb-accent-background-color)",
"accent-selected-bg": "var(--fb-accent-background-color-selected)",
"input-bg": "var(--fb-input-background-color)",
"input-bg-selected": "var(--fb-input-background-color-selected)",
placeholder: "var(--fb-placeholder-color)",
shadow: "var(--fb-shadow-color)",
"rating-fill": "var(--fb-rating-fill)",
@@ -31,6 +37,9 @@ module.exports = {
"close-button": "var(--fb-close-btn-color)",
"close-button-focus": "var(--fb-close-btn-hover-color)",
},
borderRadius: {
custom: "var(--fb-border-radius)",
},
zIndex: {
999999: "999999",
},

View File

@@ -1,9 +1,11 @@
import { TProductStyling } from "./product";
import { TResponseData, TResponseUpdate } from "./responses";
import { TUploadFileConfig } from "./storage";
import { TSurvey } from "./surveys";
import { TSurvey, TSurveyStyling } from "./surveys";
export interface SurveyBaseProps {
survey: TSurvey;
styling: TSurveyStyling | TProductStyling;
isBrandingEnabled: boolean;
activeQuestionId?: string;
getSetIsError?: (getSetError: (value: boolean) => void) => void;
@@ -20,6 +22,7 @@ export interface SurveyBaseProps {
languageCode: string;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
responseCount?: number;
isCardBorderVisible?: boolean;
}
export interface SurveyInlineProps extends SurveyBaseProps {
@@ -29,6 +32,5 @@ export interface SurveyInlineProps extends SurveyBaseProps {
export interface SurveyModalProps extends SurveyBaseProps {
clickOutside: boolean;
darkOverlay: boolean;
highlightBorderColor: string | null;
placement: "bottomLeft" | "bottomRight" | "topLeft" | "topRight" | "center";
}

View File

@@ -2,6 +2,13 @@ import { z } from "zod";
import { ZColor, ZPlacement } from "./common";
import { ZEnvironment } from "./environment";
import { ZBaseStyling } from "./styling";
export const ZProductStyling = ZBaseStyling.extend({
allowStyleOverwrite: z.boolean(),
});
export type TProductStyling = z.infer<typeof ZProductStyling>;
export const ZLanguage = z.object({
id: z.string().cuid2(),
@@ -29,8 +36,7 @@ export const ZProduct = z.object({
updatedAt: z.date(),
name: z.string(),
teamId: z.string(),
brandColor: ZColor,
highlightBorderColor: ZColor.nullable(),
styling: ZProductStyling,
recontactDays: z.number().int(),
inAppSurveyBranding: z.boolean(),
linkSurveyBranding: z.boolean(),
@@ -38,6 +44,8 @@ export const ZProduct = z.object({
clickOutsideClose: z.boolean(),
darkOverlay: z.boolean(),
environments: z.array(ZEnvironment),
brandColor: ZColor.nullish(),
highlightBorderColor: ZColor.nullish(),
languages: z.array(ZLanguage),
});
@@ -55,6 +63,7 @@ export const ZProductUpdateInput = z.object({
clickOutsideClose: z.boolean().optional(),
darkOverlay: z.boolean().optional(),
environments: z.array(ZEnvironment).optional(),
styling: ZProductStyling.optional(),
});
export type TProductUpdateInput = z.infer<typeof ZProductUpdateInput>;

41
packages/types/styling.ts Normal file
View File

@@ -0,0 +1,41 @@
import { z } from "zod";
import { ZColor } from "./common";
export const ZStylingColor = z.object({
light: ZColor,
dark: ZColor.nullish(),
});
export type TStylingColor = z.infer<typeof ZStylingColor>;
export const ZCardArrangementOptions = z.enum(["casual", "straight", "simple"]);
export type TCardArrangementOptions = z.infer<typeof ZCardArrangementOptions>;
export const ZCardArrangement = z.object({
linkSurveys: ZCardArrangementOptions,
inAppSurveys: ZCardArrangementOptions,
});
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 ZBaseStyling = z.object({
brandColor: ZStylingColor.nullish(),
questionColor: ZStylingColor.nullish(),
inputColor: ZStylingColor.nullish(),
inputBorderColor: ZStylingColor.nullish(),
cardBackgroundColor: ZStylingColor.nullish(),
cardBorderColor: ZStylingColor.nullish(),
cardShadowColor: ZStylingColor.nullish(),
highlightBorderColor: ZStylingColor.nullish(),
isDarkModeEnabled: z.boolean().nullish(),
roundness: z.number().nullish(),
cardArrangement: ZCardArrangement.nullish(),
background: ZSurveyStylingBackground.nullish(),
hideProgressBar: z.boolean().nullish(),
});

View File

@@ -5,6 +5,7 @@ import { ZAllowedFileExtension, ZColor, ZPlacement } from "./common";
import { TPerson } from "./people";
import { ZLanguage } from "./product";
import { ZSegment } from "./segment";
import { ZBaseStyling } from "./styling";
export const ZI18nString = z.record(z.string(), z.string());
@@ -35,7 +36,7 @@ export enum TSurveyQuestionType {
export const ZSurveyWelcomeCard = z.object({
enabled: z.boolean(),
headline: ZI18nString.optional(),
headline: ZI18nString,
html: ZI18nString.optional(),
fileUrl: z.string().optional(),
buttonLabel: ZI18nString.optional(),
@@ -62,17 +63,8 @@ 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(),
hideProgressBar: z.boolean().nullish(),
export const ZSurveyStyling = ZBaseStyling.extend({
overwriteThemeStyling: z.boolean().nullish(),
});
export type TSurveyStyling = z.infer<typeof ZSurveyStyling>;
@@ -552,3 +544,5 @@ export interface TSurveyQuestionSummary<T> {
person: TPerson | null;
}[];
}
export type TSurveyEditorTabs = "questions" | "settings" | "styling";

View File

@@ -34,7 +34,7 @@ export const Card: React.FC<CardProps> = ({
<div className="absolute right-4 top-4 flex items-center rounded bg-slate-200 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{connected === true ? (
<span className="relative mr-1 flex h-2 w-2">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
) : (

View File

@@ -1,14 +1,24 @@
"use client";
/* import { persistForm, useForm } from "@/app/lib/forms"; */
import { useCallback, useRef, useState } from "react";
import { HexColorInput, HexColorPicker } from "react-colorful";
import { cn } from "@formbricks/lib/cn";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
export const ColorPicker = ({ color, onChange }: { color: string; onChange: (v: string) => void }) => {
export const ColorPicker = ({
color,
onChange,
containerClass,
disabled = false,
}: {
color: string;
onChange: (v: string) => void;
containerClass?: string;
disabled?: boolean;
}) => {
return (
<div className="my-2">
<div className={cn("my-2", containerClass)}>
<div className="flex w-full items-center justify-between space-x-1 rounded-md border border-slate-300 bg-white px-2 text-sm text-slate-400">
<div className="flex w-full items-center">
#
@@ -18,15 +28,24 @@ export const ColorPicker = ({ color, onChange }: { color: string; onChange: (v:
onChange={onChange}
id="color"
aria-label="Primary color"
disabled={disabled}
/>
</div>
<PopoverPicker color={color} onChange={onChange} />
<PopoverPicker color={color} onChange={onChange} disabled={disabled} />
</div>
</div>
);
};
export const PopoverPicker = ({ color, onChange }: { color: string; onChange: (v: string) => void }) => {
export const PopoverPicker = ({
color,
onChange,
disabled = false,
}: {
color: string;
onChange: (v: string) => void;
disabled?: boolean;
}) => {
const popover = useRef(null);
const [isOpen, toggle] = useState(false);
@@ -37,9 +56,13 @@ export const PopoverPicker = ({ color, onChange }: { color: string; onChange: (v
<div className="picker relative">
<div
id="color-picker"
className="h-6 w-10 cursor-pointer rounded"
style={{ backgroundColor: color }}
onClick={() => toggle(!isOpen)}
className="h-6 w-10 cursor-pointer rounded border border-slate-200"
style={{ backgroundColor: color, opacity: disabled ? 0.5 : 1 }}
onClick={() => {
if (!disabled) {
toggle(!isOpen);
}
}}
/>
{isOpen && (

View File

@@ -0,0 +1,22 @@
"use client";
import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
import { cn } from "@formbricks/lib/cn";
export const Slider: React.ForwardRefExoticComponent<
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> &
React.RefAttributes<React.ElementRef<typeof SliderPrimitive.Root>>
> = React.forwardRef(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn("relative flex w-full touch-none select-none items-center", className)}
{...props}>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-slate-300">
<SliderPrimitive.Range className="absolute h-full bg-slate-300" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="border-primary ring-offset-background focus-visible:ring-ring block h-5 w-5 rounded-full border-2 bg-slate-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;

View File

@@ -0,0 +1,80 @@
import { useMemo } from "react";
import { cn } from "@formbricks/lib/cn";
import { TCardArrangementOptions } from "@formbricks/types/styling";
import { Button } from "../../Button";
type CardArrangementProps = {
surveyType: "link" | "web";
activeCardArrangement: TCardArrangementOptions;
setActiveCardArrangement: (arrangement: TCardArrangementOptions) => void;
disabled?: boolean;
};
export const CardArrangement = ({
activeCardArrangement,
surveyType,
setActiveCardArrangement,
disabled = false,
}: CardArrangementProps) => {
const surveyTypeDerived = useMemo(() => {
return surveyType == "link" ? "Link" : "In App";
}, [surveyType]);
const handleCardArrangementChange = (arrangement: TCardArrangementOptions) => {
if (disabled) return;
setActiveCardArrangement(arrangement);
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<h3 className="text-base font-semibold text-slate-900">
Card Arrangement for {surveyTypeDerived} Surveys
</h3>
<p className="text-sm text-slate-800">
How funky do you want your cards in {surveyTypeDerived} Surveys
</p>
</div>
<div className="flex gap-2 rounded-md border border-slate-300 bg-white p-1">
<Button
variant="minimal"
size="sm"
className={cn(
"flex flex-1 justify-center bg-white text-center",
activeCardArrangement === "casual" && "bg-slate-200"
)}
disabled={disabled}
onClick={() => handleCardArrangementChange("casual")}>
Casual
</Button>
<Button
variant="minimal"
size="sm"
onClick={() => handleCardArrangementChange("straight")}
disabled={disabled}
className={cn(
"flex flex-1 justify-center bg-white text-center",
activeCardArrangement === "straight" && "bg-slate-200"
)}>
Straight
</Button>
<Button
variant="minimal"
size="sm"
onClick={() => handleCardArrangementChange("simple")}
disabled={disabled}
className={cn(
"flex flex-1 justify-center bg-white text-center",
activeCardArrangement === "simple" && "bg-slate-200"
)}>
Simple
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,37 @@
import { cn } from "@formbricks/lib/cn";
import { ColorPicker } from "../../ColorPicker";
type ColorSelectorWithLabelProps = {
label: string;
description?: string;
color: string;
Badge?: React.FC;
setColor: React.Dispatch<React.SetStateAction<string>>;
className?: string;
disabled?: boolean;
};
export const ColorSelectorWithLabel = ({
color,
description = "",
label,
Badge,
setColor,
className = "",
disabled = false,
}: ColorSelectorWithLabelProps) => {
return (
<div className={cn("flex max-w-xs flex-col gap-4", disabled ? "opacity-40" : "", className)}>
<div className="flex flex-col">
<div className="flex">
<h3 className="mr-2 text-sm font-semibold text-slate-700">{label}</h3>
{Badge && <Badge />}
</div>
{description && <p className="text-xs text-slate-500">{description}</p>}
</div>
<ColorPicker color={color} onChange={setColor} containerClass="my-0" disabled={disabled} />
</div>
);
};

View File

@@ -0,0 +1,108 @@
import { useEffect } from "react";
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
import { Switch } from "../../Switch";
import { ColorSelectorWithLabel } from "./ColorSelectorWithLabel";
type DarModeColorProps = {
isDarkMode: boolean;
setIsDarkMode: React.Dispatch<React.SetStateAction<boolean>>;
brandColor?: string;
setBrandColor: React.Dispatch<React.SetStateAction<string>>;
questionColor?: string;
setQuestionColor: React.Dispatch<React.SetStateAction<string>>;
inputColor?: string;
setInputColor: React.Dispatch<React.SetStateAction<string>>;
inputBorderColor?: string;
setInputBorderColor: React.Dispatch<React.SetStateAction<string>>;
cardBackgroundColor?: string;
setCardBackgroundColor: React.Dispatch<React.SetStateAction<string>>;
highlightBorderColor?: string;
setHighlighBorderColor: React.Dispatch<React.SetStateAction<string>>;
disabled?: boolean;
};
export const DarkModeColors = ({
isDarkMode,
setIsDarkMode,
brandColor,
cardBackgroundColor,
highlightBorderColor,
inputBorderColor,
inputColor,
questionColor,
setBrandColor,
setCardBackgroundColor,
setHighlighBorderColor,
setInputBorderColor,
setInputColor,
setQuestionColor,
disabled = false,
}: DarModeColorProps) => {
useEffect(() => {
if (disabled) {
setIsDarkMode(false);
}
}, [disabled, setIsDarkMode]);
return (
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-4">
<Switch
checked={isDarkMode}
onCheckedChange={(value) => {
setIsDarkMode(value);
}}
disabled={disabled}
/>
<div className="flex flex-col">
<h3 className="text-base font-semibold text-slate-900">Add &quot;Dark Mode&quot; Colors</h3>
<p className="text-sm text-slate-800">Your app has a dark mode? Set a different set of colors.</p>
</div>
</div>
{isDarkMode && (
<div className="grid grid-cols-2 gap-4">
<ColorSelectorWithLabel
label="Brand color"
color={brandColor ?? COLOR_DEFAULTS.brandColor}
setColor={setBrandColor}
className="gap-2"
/>
<ColorSelectorWithLabel
label="Text color"
color={questionColor ?? COLOR_DEFAULTS.questionColor}
setColor={setQuestionColor}
className="gap-2"
/>
<ColorSelectorWithLabel
label="Input color"
color={inputColor ?? COLOR_DEFAULTS.inputColor}
setColor={setInputColor}
className="gap-2"
/>
<ColorSelectorWithLabel
label="Input border color"
color={inputBorderColor ?? COLOR_DEFAULTS.inputBorderColor}
setColor={setInputBorderColor}
className="gap-2"
/>
<ColorSelectorWithLabel
label="Card background color"
color={cardBackgroundColor ?? COLOR_DEFAULTS.cardBackgroundColor}
setColor={setCardBackgroundColor}
className="gap-2"
/>
<ColorSelectorWithLabel
label="Highlight border color"
color={highlightBorderColor ?? COLOR_DEFAULTS.highlightBorderColor}
setColor={setHighlighBorderColor}
className="gap-2"
/>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,3 @@
export { CardArrangement } from "./components/CardArrangement";
export { ColorSelectorWithLabel } from "./components/ColorSelectorWithLabel";
export { DarkModeColors } from "./components/DarkModeColors";

View File

@@ -5,7 +5,7 @@ import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbrick
const createContainerId = () => `formbricks-survey-container`;
export const SurveyInline = (props: Omit<SurveyInlineProps & { brandColor: string }, "containerId">) => {
export const SurveyInline = (props: Omit<SurveyInlineProps, "containerId">) => {
const containerId = useMemo(() => createContainerId(), []);
useEffect(() => {
renderSurveyInline({
@@ -16,7 +16,7 @@ export const SurveyInline = (props: Omit<SurveyInlineProps & { brandColor: strin
return <div id={containerId} className="h-full w-full" />;
};
export const SurveyModal = (props: SurveyModalProps & { brandColor: string }) => {
export const SurveyModal = (props: SurveyModalProps) => {
useEffect(() => {
renderSurveyModal(props);
}, [props]);

View File

@@ -19,7 +19,7 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
<TooltipTrigger>
{status === "inProgress" && (
<span className="relative flex h-3 w-3">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
)}
@@ -45,7 +45,7 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
<>
<span>Gathering responses</span>
<span className="relative flex h-3 w-3">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
</>
@@ -74,7 +74,7 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
<span>
{status === "inProgress" && (
<span className="relative flex h-3 w-3">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500"></span>
</span>
)}