diff --git a/apps/web/lib/styling/constants.ts b/apps/web/lib/styling/constants.ts index e6b10a71f288..ca3f2769bc22 100644 --- a/apps/web/lib/styling/constants.ts +++ b/apps/web/lib/styling/constants.ts @@ -149,6 +149,42 @@ export const STYLE_DEFAULTS: TProjectStyling = { progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] }, }; +/** + * Fills in new v4.7 color fields from legacy v4.6 fields when they are missing. + * + * v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor. + * v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc. + * + * When loading v4.6 data the new fields are absent. Without this helper the + * form would fall back to STYLE_DEFAULTS (derived from the *default* brand + * colour), causing a visible mismatch. This function derives the new fields + * from the actually-saved legacy fields so the preview and form stay coherent. + * + * Only sets a field when the legacy source exists AND the new field is absent. + */ +export const deriveNewFieldsFromLegacy = (saved: Record): Record => { + const light = (key: string): string | undefined => + (saved[key] as { light?: string } | null | undefined)?.light; + + const q = light("questionColor"); + const b = light("brandColor"); + const i = light("inputColor"); + + return { + ...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }), + ...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }), + ...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }), + ...(q && !saved.inputTextColor && { inputTextColor: { light: q } }), + ...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }), + ...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }), + ...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }), + ...(i && !saved.optionBgColor && { optionBgColor: { light: i } }), + ...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }), + ...(b && + !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }), + }; +}; + /** * Builds a complete TProjectStyling object from a single brand color. * diff --git a/apps/web/modules/projects/settings/look/components/theme-styling.tsx b/apps/web/modules/projects/settings/look/components/theme-styling.tsx index 8821273784fe..9bcea700a996 100644 --- a/apps/web/modules/projects/settings/look/components/theme-styling.tsx +++ b/apps/web/modules/projects/settings/look/components/theme-styling.tsx @@ -11,7 +11,12 @@ import { useTranslation } from "react-i18next"; import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project"; import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types"; import { previewSurvey } from "@/app/lib/templates"; -import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants"; +import { + COLOR_DEFAULTS, + STYLE_DEFAULTS, + deriveNewFieldsFromLegacy, + getSuggestedColors, +} from "@/lib/styling/constants"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { updateProjectAction } from "@/modules/projects/settings/actions"; import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings"; @@ -62,11 +67,23 @@ export const ThemeStyling = ({ ? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null)) : {}; + const legacyFills = deriveNewFieldsFromLegacy(cleanSaved); + const form = useForm({ - defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved }, + defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...cleanSaved }, resolver: zodResolver(ZProjectStyling), }); + // Brand color shown in the preview. Only updated when the user triggers + // "Suggest colors", "Save", or "Reset to default" — NOT on every keystroke + // in the brand-color picker. This prevents the loading-spinner / progress + // bar from updating while the user is still picking a colour. + const [previewBrandColor, setPreviewBrandColor] = useState( + (cleanSaved as Partial).brandColor?.light ?? + STYLE_DEFAULTS.brandColor?.light ?? + COLOR_DEFAULTS.brandColor + ); + const [previewSurveyType, setPreviewSurveyType] = useState("link"); const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false); const [confirmSuggestColorsOpen, setConfirmSuggestColorsOpen] = useState(false); @@ -84,6 +101,7 @@ export const ThemeStyling = ({ if (updatedProjectResponse?.data) { form.reset({ ...STYLE_DEFAULTS }); + setPreviewBrandColor(STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor); toast.success(t("environments.workspace.look.styling_updated_successfully")); router.refresh(); } else { @@ -100,6 +118,9 @@ export const ThemeStyling = ({ form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true }); } + // Commit brand color to the preview now that all derived colours are in sync. + setPreviewBrandColor(brandColor ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor); + toast.success(t("environments.workspace.look.suggested_colors_applied_please_save")); setConfirmSuggestColorsOpen(false); }; @@ -113,7 +134,11 @@ export const ThemeStyling = ({ }); if (updatedProjectResponse?.data) { - form.reset({ ...updatedProjectResponse.data.styling }); + const saved = updatedProjectResponse.data.styling; + form.reset({ ...saved }); + setPreviewBrandColor( + saved?.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor + ); toast.success(t("environments.workspace.look.styling_updated_successfully")); } else { const errorMessage = getFormattedErrorMessage(updatedProjectResponse); @@ -249,7 +274,9 @@ export const ThemeStyling = ({ survey={previewSurvey(project.name, t)} project={{ ...project, - styling: form.watch("allowStyleOverwrite") ? form.watch() : STYLE_DEFAULTS, + styling: form.watch("allowStyleOverwrite") + ? { ...form.watch(), brandColor: { light: previewBrandColor } } + : STYLE_DEFAULTS, }} previewType={previewSurveyType} setPreviewType={setPreviewSurveyType} diff --git a/apps/web/modules/survey/editor/components/styling-view.tsx b/apps/web/modules/survey/editor/components/styling-view.tsx index c512d1732027..03ab83165731 100644 --- a/apps/web/modules/survey/editor/components/styling-view.tsx +++ b/apps/web/modules/survey/editor/components/styling-view.tsx @@ -9,7 +9,7 @@ import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { TProjectStyling } from "@formbricks/types/project"; import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types"; -import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants"; +import { STYLE_DEFAULTS, deriveNewFieldsFromLegacy, getSuggestedColors } from "@/lib/styling/constants"; import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings"; import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card"; import { AlertDialog } from "@/modules/ui/components/alert-dialog"; @@ -68,10 +68,15 @@ export const StylingView = ({ ? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null)) : {}; + const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject); + const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey); + const form = useForm({ defaultValues: { ...STYLE_DEFAULTS, + ...projectLegacyFills, ...cleanProject, + ...surveyLegacyFills, ...cleanSurvey, }, }); diff --git a/packages/surveys/src/lib/styles.ts b/packages/surveys/src/lib/styles.ts index b767e0722368..baaf8549d39e 100644 --- a/packages/surveys/src/lib/styles.ts +++ b/packages/surveys/src/lib/styles.ts @@ -194,8 +194,13 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS } // Buttons (Advanced) - appendCssVariable("button-bg-color", styling.buttonBgColor?.light); - appendCssVariable("button-text-color", styling.buttonTextColor?.light); + const buttonBg = styling.buttonBgColor?.light ?? styling.brandColor?.light; + let buttonText = styling.buttonTextColor?.light; + if (buttonText === undefined && buttonBg) { + buttonText = isLight(buttonBg) ? "#0f172a" : "#ffffff"; + } + appendCssVariable("button-bg-color", buttonBg); + appendCssVariable("button-text-color", buttonText); if (styling.buttonBorderRadius !== undefined) appendCssVariable("button-border-radius", formatDimension(styling.buttonBorderRadius)); if (styling.buttonHeight !== undefined) @@ -211,12 +216,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS // Inputs (Advanced) appendCssVariable("input-background-color", styling.inputBgColor?.light ?? styling.inputColor?.light); - appendCssVariable("input-text-color", styling.inputTextColor?.light); - if (styling.inputTextColor?.light) { - appendCssVariable( - "input-placeholder-color", - mixColor(styling.inputTextColor.light, "#ffffff", 0.3) - ); + const inputTextColor = styling.inputTextColor?.light ?? styling.questionColor?.light; + appendCssVariable("input-text-color", inputTextColor); + if (inputTextColor) { + appendCssVariable("input-placeholder-color", mixColor(inputTextColor, "#ffffff", 0.3)); } if (styling.inputBorderRadius !== undefined) appendCssVariable("input-border-radius", formatDimension(styling.inputBorderRadius)); @@ -233,8 +236,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS appendCssVariable("input-shadow", styling.inputShadow); // Options (Advanced) - appendCssVariable("option-bg-color", styling.optionBgColor?.light); - appendCssVariable("option-label-color", styling.optionLabelColor?.light); + appendCssVariable("option-bg-color", styling.optionBgColor?.light ?? styling.inputColor?.light); + appendCssVariable("option-label-color", styling.optionLabelColor?.light ?? styling.questionColor?.light); if (styling.optionBorderRadius !== undefined) appendCssVariable("option-border-radius", formatDimension(styling.optionBorderRadius)); if (styling.optionPaddingX !== undefined) @@ -285,8 +288,15 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS // Implicitly set the progress track border radius to the roundness of the card appendCssVariable("progress-track-border-radius", formatDimension(roundness)); - appendCssVariable("progress-track-bg-color", styling.progressTrackBgColor?.light); - appendCssVariable("progress-indicator-bg-color", styling.progressIndicatorBgColor?.light); + appendCssVariable( + "progress-track-bg-color", + styling.progressTrackBgColor?.light ?? + (styling.brandColor?.light ? mixColor(styling.brandColor.light, "#ffffff", 0.8) : undefined) + ); + appendCssVariable( + "progress-indicator-bg-color", + styling.progressIndicatorBgColor?.light ?? styling.brandColor?.light + ); // Close the #fbjs variable block cssVariables += "}\n"; @@ -312,7 +322,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS headlineDecls += " font-size: var(--fb-element-headline-font-size) !important;\n"; if (styling.elementHeadlineFontWeight !== undefined && styling.elementHeadlineFontWeight !== null) headlineDecls += " font-weight: var(--fb-element-headline-font-weight) !important;\n"; - if (styling.elementHeadlineColor?.light) + if (styling.elementHeadlineColor?.light || styling.questionColor?.light) headlineDecls += " color: var(--fb-element-headline-color) !important;\n"; addRule("#fbjs .label-headline,\n#fbjs .label-headline *", headlineDecls); @@ -322,7 +332,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS descriptionDecls += " font-size: var(--fb-element-description-font-size) !important;\n"; if (styling.elementDescriptionFontWeight !== undefined && styling.elementDescriptionFontWeight !== null) descriptionDecls += " font-weight: var(--fb-element-description-font-weight) !important;\n"; - if (styling.elementDescriptionColor?.light) + if (styling.elementDescriptionColor?.light || styling.questionColor?.light) descriptionDecls += " color: var(--fb-element-description-color) !important;\n"; addRule("#fbjs .label-description,\n#fbjs .label-description *", descriptionDecls); @@ -332,7 +342,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS upperDecls += " font-size: var(--fb-element-upper-label-font-size) !important;\n"; if (styling.elementUpperLabelFontWeight !== undefined && styling.elementUpperLabelFontWeight !== null) upperDecls += " font-weight: var(--fb-element-upper-label-font-weight) !important;\n"; - if (styling.elementUpperLabelColor?.light) { + if (styling.elementUpperLabelColor?.light || styling.questionColor?.light) { upperDecls += " color: var(--fb-element-upper-label-color) !important;\n"; upperDecls += " opacity: var(--fb-element-upper-label-opacity, 1) !important;\n"; } @@ -340,9 +350,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS // --- Buttons --- let buttonDecls = ""; - if (styling.buttonBgColor?.light) + if (styling.buttonBgColor?.light || styling.brandColor?.light) buttonDecls += " background-color: var(--fb-button-bg-color) !important;\n"; - if (styling.buttonTextColor?.light) buttonDecls += " color: var(--fb-button-text-color) !important;\n"; + if (styling.buttonTextColor?.light || styling.brandColor?.light) + buttonDecls += " color: var(--fb-button-text-color) !important;\n"; if (styling.buttonBorderRadius !== undefined) buttonDecls += " border-radius: var(--fb-button-border-radius) !important;\n"; if (styling.buttonHeight !== undefined) buttonDecls += " height: var(--fb-button-height) !important;\n"; @@ -363,11 +374,11 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS // --- Options --- if (styling.optionBorderRadius !== undefined) addRule("#fbjs .rounded-option", " border-radius: var(--fb-option-border-radius) !important;\n"); - if (styling.optionBgColor?.light) + if (styling.optionBgColor?.light || styling.inputColor?.light) addRule("#fbjs .bg-option-bg", " background-color: var(--fb-option-bg-color) !important;\n"); let optionLabelDecls = ""; - if (styling.optionLabelColor?.light) + if (styling.optionLabelColor?.light || styling.questionColor?.light) optionLabelDecls += " color: var(--fb-option-label-color) !important;\n"; if (styling.optionFontSize !== undefined) optionLabelDecls += " font-size: var(--fb-option-font-size) !important;\n"; @@ -393,7 +404,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS addRule("#fbjs .border-input-border", " border-color: var(--fb-input-border-color) !important;\n"); let inputTextDecls = ""; - if (styling.inputTextColor?.light) inputTextDecls += " color: var(--fb-input-text-color) !important;\n"; + if (styling.inputTextColor?.light || styling.questionColor?.light) + inputTextDecls += " color: var(--fb-input-text-color) !important;\n"; if (styling.inputFontSize !== undefined) inputTextDecls += " font-size: var(--fb-input-font-size) !important;\n"; addRule("#fbjs .text-input-text", inputTextDecls);