import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useCallback, useMemo, useState } from 'react'; import { trackConfigChange } from '../services/analytics'; import type { BannerConfig, ButtonConfig } from '../types/api'; import { Button } from './ui/button.tsx'; import { Card, CardContent } from './ui/card.tsx'; import { Alert } from './ui/alert.tsx'; import { Select } from './ui/select.tsx'; import { TabGroup } from './ui/tab-group.tsx'; import BannerPreview from './BannerPreview'; type DisplayMode = 'bottom_banner' | 'top_banner' | 'overlay' | 'corner_popup'; type CornerPosition = 'left' | 'right'; type Viewport = 'desktop' | 'mobile'; const DISPLAY_MODES: { value: DisplayMode; label: string }[] = [ { value: 'bottom_banner', label: 'Bottom banner' }, { value: 'top_banner', label: 'Top banner' }, { value: 'overlay', label: 'Overlay (modal)' }, { value: 'corner_popup', label: 'Corner popup' }, ]; const FONT_OPTIONS = [ { value: 'system-ui', label: 'System default' }, { value: "'Inter', sans-serif", label: 'Inter' }, { value: "'Roboto', sans-serif", label: 'Roboto' }, { value: "'Open Sans', sans-serif", label: 'Open Sans' }, { value: "'Lato', sans-serif", label: 'Lato' }, { value: "Georgia, serif", label: 'Georgia (serif)' }, ]; interface Props { /** Unique key for cache invalidation (e.g. ['sites', siteId, 'config'] or ['org-config']) */ configQueryKey: string[]; /** The config object containing banner_config */ config: { banner_config: BannerConfig | null } | null; /** Function to save the updated banner config */ onSave: (body: { banner_config: BannerConfig }) => Promise; /** Optional domain for the preview iframe */ siteDomain?: string | null; } interface Defaults { primaryColour: string; backgroundColour: string; textColour: string; buttonStyle: 'filled' | 'outline'; fontFamily: string; borderRadius: number; showRejectAll: boolean; showManagePreferences: boolean; showCloseButton: boolean; showLogo: boolean; logoUrl: string; showCookieCount: boolean; displayMode: DisplayMode; cornerPosition: CornerPosition; acceptButton: ButtonConfig; rejectButton: ButtonConfig; manageButton: ButtonConfig; } function getDefaults(config: { banner_config: BannerConfig | null } | null): Defaults { const bc = config?.banner_config; return { primaryColour: bc?.primaryColour ?? '#2563eb', backgroundColour: bc?.backgroundColour ?? '#ffffff', textColour: bc?.textColour ?? '#1a1a2e', buttonStyle: bc?.buttonStyle ?? 'filled', fontFamily: bc?.fontFamily ?? 'system-ui', borderRadius: bc?.borderRadius ?? 6, showRejectAll: bc?.showRejectAll ?? true, showManagePreferences: bc?.showManagePreferences ?? true, showCloseButton: bc?.showCloseButton ?? false, showLogo: bc?.showLogo ?? false, logoUrl: bc?.logoUrl ?? '', showCookieCount: bc?.showCookieCount ?? false, displayMode: (bc?.displayMode as DisplayMode) ?? 'bottom_banner', cornerPosition: (bc?.cornerPosition as CornerPosition) ?? 'right', acceptButton: bc?.acceptButton ?? {}, rejectButton: bc?.rejectButton ?? {}, manageButton: bc?.manageButton ?? {}, }; } export default function BannerBuilderTab({ configQueryKey, config, onSave, siteDomain }: Props) { const queryClient = useQueryClient(); const defaults = useMemo(() => getDefaults(config), [config]); // Theme state const [primaryColour, setPrimaryColour] = useState(defaults.primaryColour); const [backgroundColour, setBackgroundColour] = useState(defaults.backgroundColour); const [textColour, setTextColour] = useState(defaults.textColour); const [buttonStyle, setButtonStyle] = useState(defaults.buttonStyle); const [fontFamily, setFontFamily] = useState(defaults.fontFamily); const [borderRadius, setBorderRadius] = useState(defaults.borderRadius); // Layout state const [showRejectAll, setShowRejectAll] = useState(defaults.showRejectAll); const [showManagePreferences, setShowManagePreferences] = useState(defaults.showManagePreferences); const [showCloseButton, setShowCloseButton] = useState(defaults.showCloseButton); const [showLogo, setShowLogo] = useState(defaults.showLogo); const [logoUrl, setLogoUrl] = useState(defaults.logoUrl); const [showCookieCount, setShowCookieCount] = useState(defaults.showCookieCount); // Display mode and viewport const [displayMode, setDisplayMode] = useState(defaults.displayMode); const [cornerPosition, setCornerPosition] = useState(defaults.cornerPosition); const [viewport, setViewport] = useState('desktop'); // Per-button styling const [acceptButton, setAcceptButton] = useState(defaults.acceptButton); const [rejectButton, setRejectButton] = useState(defaults.rejectButton); const [manageButton, setManageButton] = useState(defaults.manageButton); const [saved, setSaved] = useState(false); const mutation = useMutation({ mutationFn: (body: { banner_config: BannerConfig }) => onSave(body), onSuccess: () => { queryClient.invalidateQueries({ queryKey: configQueryKey }); trackConfigChange('banner_config'); setSaved(true); setTimeout(() => setSaved(false), 2000); }, }); const bannerConfig: BannerConfig = useMemo( () => ({ primaryColour, backgroundColour, textColour, buttonStyle, fontFamily, borderRadius, showRejectAll, showManagePreferences, showCloseButton, showLogo, logoUrl: logoUrl || undefined, showCookieCount, cornerPosition, acceptButton: Object.keys(acceptButton).length > 0 ? acceptButton : undefined, rejectButton: Object.keys(rejectButton).length > 0 ? rejectButton : undefined, manageButton: Object.keys(manageButton).length > 0 ? manageButton : undefined, }), [ primaryColour, backgroundColour, textColour, buttonStyle, fontFamily, borderRadius, showRejectAll, showManagePreferences, showCloseButton, showLogo, logoUrl, showCookieCount, cornerPosition, acceptButton, rejectButton, manageButton, ], ); const handleSave = useCallback(() => { mutation.mutate({ banner_config: { ...bannerConfig, displayMode }, }); }, [mutation, bannerConfig, displayMode]); return (
{/* Left panel — controls */}
{/* Display mode */}

Display mode

{DISPLAY_MODES.map((mode) => ( ))}
{/* Corner position — only shown for corner_popup */} {displayMode === 'corner_popup' && (
{(['left', 'right'] as const).map((pos) => ( ))}
)}
{/* Theme */}

Theme

setBorderRadius(Number(e.target.value))} className="w-full" />
{(['filled', 'outline'] as const).map((style) => ( ))}
{/* Button styling */}

Button styling

Override colours per button, or leave blank to use the theme defaults.

{showRejectAll && ( )} {showManagePreferences && ( )}
{/* Layout */}

Layout

{showLogo && (
setLogoUrl(e.target.value)} placeholder="https://example.com/logo.svg" className="w-full rounded-lg border border-border px-3 py-1.5 text-sm" />
)}
{/* Save */}
{saved && Saved successfully} {mutation.isError && Failed to save. Please try again.}
{/* Right panel — preview */}

Live preview

setViewport(v as Viewport)} />
)?.privacy_policy_url as string ?? null} siteUrl={siteDomain} />
); } /* ── Helper components ─────────────────────────────────────────────── */ function ColourField({ label, value, onChange, }: { label: string; value: string; onChange: (v: string) => void; }) { return (
onChange(e.target.value)} className="h-8 w-8 cursor-pointer rounded border border-border" />
onChange(e.target.value)} className="w-full rounded border border-border px-2 py-0.5 text-xs font-mono text-text-secondary" />
); } function ToggleField({ label, checked, onChange, }: { label: string; checked: boolean; onChange: (v: boolean) => void; }) { return ( ); } function ButtonStyleEditor({ label, config, onChange, defaults, }: { label: string; config: ButtonConfig; onChange: (c: ButtonConfig) => void; defaults: { backgroundColour: string; textColour: string; style: string }; }) { const update = (patch: Partial) => onChange({ ...config, ...patch }); const bgColour = config.backgroundColour ?? defaults.backgroundColour; const txtColour = config.textColour ?? defaults.textColour; const style = config.style ?? defaults.style; return (

{label}

{(['filled', 'outline', 'text'] as const).map((s) => ( ))}
update({ backgroundColour: e.target.value })} className="h-6 w-6 cursor-pointer rounded border border-border" /> Background update({ textColour: e.target.value })} className="ml-auto h-6 w-6 cursor-pointer rounded border border-border" /> Text
); }