mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
)}>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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.">
|
||||
|
||||
@@ -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.">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 ?? ""}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
134
packages/lib/styling/constants.ts
Normal file
134
packages/lib/styling/constants.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
41
packages/types/styling.ts
Normal 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(),
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
22
packages/ui/Slider/index.tsx
Normal file
22
packages/ui/Slider/index.tsx
Normal 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;
|
||||
80
packages/ui/Styling/components/CardArrangement.tsx
Normal file
80
packages/ui/Styling/components/CardArrangement.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
packages/ui/Styling/components/ColorSelectorWithLabel.tsx
Normal file
37
packages/ui/Styling/components/ColorSelectorWithLabel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
packages/ui/Styling/components/DarkModeColors.tsx
Normal file
108
packages/ui/Styling/components/DarkModeColors.tsx
Normal 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 "Dark Mode" 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>
|
||||
);
|
||||
};
|
||||
3
packages/ui/Styling/index.tsx
Normal file
3
packages/ui/Styling/index.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CardArrangement } from "./components/CardArrangement";
|
||||
export { ColorSelectorWithLabel } from "./components/ColorSelectorWithLabel";
|
||||
export { DarkModeColors } from "./components/DarkModeColors";
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user