feat: add configurable animated background for landing pages

Extract BackgroundConfigEditor as reusable controlled component from
BackgroundEditor. Add background settings section to landing editor.
Render per-landing backgrounds on public purchase pages.

- Extract BackgroundConfigEditor (value/onChange) from BackgroundEditor
- Refactor BackgroundEditor to thin wrapper with API persistence
- Add StaticBackgroundRenderer for prop-based config (public pages)
- Add background_config to landing API types and editor form
- Render animated background on QuickPurchase page
This commit is contained in:
Fringg
2026-03-07 12:46:05 +03:00
parent 887b13dec2
commit a404690334
7 changed files with 374 additions and 329 deletions

View File

@@ -1,4 +1,5 @@
import apiClient from './client';
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
// ============================================================
// Public types
@@ -91,6 +92,7 @@ export interface LandingConfig {
meta_title: string | null;
meta_description: string | null;
discount: LandingDiscountInfo | null;
background_config: AnimationConfig | null;
}
export interface PurchaseRequest {
@@ -201,6 +203,7 @@ export interface LandingDetail {
discount_starts_at: string | null;
discount_ends_at: string | null;
discount_badge_text: LocaleDict | null;
background_config: AnimationConfig | null;
}
export interface LandingCreateRequest {
@@ -222,6 +225,7 @@ export interface LandingCreateRequest {
discount_starts_at?: string | null;
discount_ends_at?: string | null;
discount_badge_text?: LocaleDict | null;
background_config?: AnimationConfig | null;
}
export type LandingUpdateRequest = Partial<LandingCreateRequest>;

View File

@@ -0,0 +1,304 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { backgroundRegistry } from '@/components/ui/backgrounds/registry';
import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview';
import type {
AnimationConfig,
BackgroundType,
SettingDefinition,
} from '@/components/ui/backgrounds/types';
import { Toggle } from './Toggle';
import { cn } from '@/lib/utils';
function SettingField({
def,
value,
onChange,
t,
}: {
def: SettingDefinition;
value: unknown;
onChange: (val: unknown) => void;
t: (key: string) => string;
}) {
if (def.type === 'number') {
const numVal = (value as number) ?? (def.default as number);
const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={numVal}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-24 accent-accent-500"
/>
<span className="w-16 text-right text-xs tabular-nums text-dark-400">{displayVal}</span>
</div>
</div>
);
}
if (def.type === 'color') {
const colorVal = (value as string) ?? (def.default as string);
const hexForInput = /^#[0-9a-fA-F]{3,8}$/.test(colorVal) ? colorVal : '#818cf8';
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexForInput}
onChange={(e) => onChange(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent"
/>
<span className="w-16 text-right text-xs text-dark-400">{colorVal}</span>
</div>
</div>
);
}
if (def.type === 'boolean') {
const boolVal = (value as boolean) ?? (def.default as boolean);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<Toggle checked={boolVal} onChange={() => onChange(!boolVal)} />
</div>
);
}
if (def.type === 'select' && def.options) {
const selectVal = (value as string) ?? (def.default as string);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<select
value={selectVal}
onChange={(e) => onChange(e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-sm text-dark-200 focus:border-accent-500 focus:outline-none"
>
{def.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label.startsWith('admin.') ? t(opt.label) : opt.label}
</option>
))}
</select>
</div>
);
}
return null;
}
interface BackgroundConfigEditorProps {
value: AnimationConfig;
onChange: (config: AnimationConfig) => void;
}
export function BackgroundConfigEditor({ value: config, onChange }: BackgroundConfigEditorProps) {
const { t } = useTranslation();
const updateConfig = (patch: Partial<AnimationConfig>) => {
onChange({ ...config, ...patch });
};
const updateSetting = (key: string, val: unknown) => {
onChange({ ...config, settings: { ...config.settings, [key]: val } });
};
const handleTypeChange = (type: BackgroundType) => {
const def = backgroundRegistry.find((d) => d.type === type);
const defaults: Record<string, unknown> = {};
if (def) {
for (const s of def.settings) {
defaults[s.key] = s.default;
}
}
onChange({ ...config, type, settings: defaults });
};
const currentDef = useMemo(
() => backgroundRegistry.find((d) => d.type === config.type),
[config.type],
);
const categories = useMemo(() => {
const cats = new Map<string, typeof backgroundRegistry>();
for (const def of backgroundRegistry) {
const list = cats.get(def.category) ?? [];
list.push(def);
cats.set(def.category, list);
}
return cats;
}, []);
return (
<div className="space-y-6">
{/* Header with enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-dark-100">{t('admin.backgrounds.title')}</h3>
<p className="text-sm text-dark-400">{t('admin.backgrounds.description')}</p>
</div>
<Toggle
checked={config.enabled}
onChange={() => updateConfig({ enabled: !config.enabled })}
/>
</div>
{config.enabled && (
<>
{/* Preview */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.preview')}
</label>
<BackgroundPreview
type={config.type}
settings={config.settings}
opacity={config.opacity}
blur={config.blur}
className="h-48"
/>
</div>
{/* Type selector gallery */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.selectType')}
</label>
{/* None option */}
<button
onClick={() => handleTypeChange('none')}
className={cn(
'mb-3 w-full rounded-xl border p-3 text-left transition-colors',
config.type === 'none'
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="text-sm font-medium text-dark-200">
{t('admin.backgrounds.none')}
</span>
<span className="ml-2 text-xs text-dark-400">{t('admin.backgrounds.noneDesc')}</span>
</button>
{/* Background types by category */}
<div className="space-y-4">
{Array.from(categories.entries()).map(([category, defs]) => (
<div key={category}>
<span className="mb-2 block text-xs font-medium uppercase tracking-wider text-dark-500">
{t(`admin.backgrounds.category${category.toUpperCase()}`)}
</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{defs.map((def) => (
<button
key={def.type}
onClick={() => handleTypeChange(def.type)}
className={cn(
'rounded-xl border p-3 text-left transition-colors',
config.type === def.type
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="block text-sm font-medium text-dark-200">
{t(def.labelKey)}
</span>
<span className="block text-xs text-dark-400">{t(def.descriptionKey)}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* Per-type settings */}
{currentDef && currentDef.settings.length > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<h4 className="mb-3 text-sm font-medium text-dark-200">
{t('admin.backgrounds.settings')}
</h4>
<div className="space-y-3">
{currentDef.settings.map((def) => (
<SettingField
key={def.key}
def={def}
value={config.settings[def.key]}
onChange={(val) => updateSetting(def.key, val)}
t={t}
/>
))}
</div>
</div>
)}
{/* Global settings */}
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">
{t('admin.backgrounds.globalOpacity')}
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0.1}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.opacity}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t('admin.backgrounds.globalBlur')}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={20}
step={1}
value={config.blur}
onChange={(e) => updateConfig({ blur: Number(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.blur}px
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<label className="text-sm text-dark-300">
{t('admin.backgrounds.reducedOnMobile')}
</label>
<p className="text-xs text-dark-500">
{t('admin.backgrounds.reducedOnMobileDesc')}
</p>
</div>
<Toggle
checked={config.reducedOnMobile}
onChange={() => updateConfig({ reducedOnMobile: !config.reducedOnMobile })}
/>
</div>
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -1,111 +1,18 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi } from '@/api/branding';
import { backgroundRegistry } from '@/components/ui/backgrounds/registry';
import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview';
import { setCachedAnimationConfig } from '@/components/backgrounds/BackgroundRenderer';
import type {
AnimationConfig,
BackgroundType,
SettingDefinition,
} from '@/components/ui/backgrounds/types';
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { Toggle } from './Toggle';
import { BackgroundConfigEditor } from './BackgroundConfigEditor';
import { cn } from '@/lib/utils';
function SettingField({
def,
value,
onChange,
t,
}: {
def: SettingDefinition;
value: unknown;
onChange: (val: unknown) => void;
t: (key: string) => string;
}) {
if (def.type === 'number') {
const numVal = (value as number) ?? (def.default as number);
const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={def.min}
max={def.max}
step={def.step}
value={numVal}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="w-24 accent-accent-500"
/>
<span className="w-16 text-right text-xs tabular-nums text-dark-400">{displayVal}</span>
</div>
</div>
);
}
if (def.type === 'color') {
const colorVal = (value as string) ?? (def.default as string);
// HTML color input only supports hex — for rgba defaults, show a neutral hex
const hexForInput = /^#[0-9a-fA-F]{3,8}$/.test(colorVal) ? colorVal : '#818cf8';
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<div className="flex items-center gap-2">
<input
type="color"
value={hexForInput}
onChange={(e) => onChange(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent"
/>
<span className="w-16 text-right text-xs text-dark-400">{colorVal}</span>
</div>
</div>
);
}
if (def.type === 'boolean') {
const boolVal = (value as boolean) ?? (def.default as boolean);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<Toggle checked={boolVal} onChange={() => onChange(!boolVal)} />
</div>
);
}
if (def.type === 'select' && def.options) {
const selectVal = (value as string) ?? (def.default as string);
return (
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t(def.label)}</label>
<select
value={selectVal}
onChange={(e) => onChange(e.target.value)}
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-sm text-dark-200 focus:border-accent-500 focus:outline-none"
>
{def.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label.startsWith('admin.') ? t(opt.label) : opt.label}
</option>
))}
</select>
</div>
);
}
return null;
}
export function BackgroundEditor() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Clear save timer on unmount
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
@@ -137,40 +44,9 @@ export function BackgroundEditor() {
onError: () => setSaveStatus('idle'),
});
const updateConfig = useCallback(
(patch: Partial<AnimationConfig>) => {
setLocalConfig((prev) => ({
...(prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG),
...patch,
}));
},
[serverConfig],
);
// Use functional updater to avoid stale closure when rapidly changing settings
const updateSetting = useCallback(
(key: string, value: unknown) => {
setLocalConfig((prev) => {
const base = prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG;
return { ...base, settings: { ...base.settings, [key]: value } };
});
},
[serverConfig],
);
const handleTypeChange = useCallback(
(type: BackgroundType) => {
const def = backgroundRegistry.find((d) => d.type === type);
const defaults: Record<string, unknown> = {};
if (def) {
for (const s of def.settings) {
defaults[s.key] = s.default;
}
}
updateConfig({ type, settings: defaults });
},
[updateConfig],
);
const handleChange = useCallback((newConfig: AnimationConfig) => {
setLocalConfig(newConfig);
}, []);
const handleSave = () => {
saveMutation.mutate(config);
@@ -179,183 +55,9 @@ export function BackgroundEditor() {
const isDirty = localConfig !== null;
const showSaveButton = isDirty || saveStatus === 'saved' || saveStatus === 'saving';
const currentDef = useMemo(
() => backgroundRegistry.find((d) => d.type === config.type),
[config.type],
);
const categories = useMemo(() => {
const cats = new Map<string, typeof backgroundRegistry>();
for (const def of backgroundRegistry) {
const list = cats.get(def.category) ?? [];
list.push(def);
cats.set(def.category, list);
}
return cats;
}, []);
return (
<div className="space-y-6">
{/* Header with enable toggle */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-dark-100">{t('admin.backgrounds.title')}</h3>
<p className="text-sm text-dark-400">{t('admin.backgrounds.description')}</p>
</div>
<Toggle
checked={config.enabled}
onChange={() => updateConfig({ enabled: !config.enabled })}
/>
</div>
{config.enabled && (
<>
{/* Preview */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.preview')}
</label>
<BackgroundPreview
type={config.type}
settings={config.settings}
opacity={config.opacity}
blur={config.blur}
className="h-48"
/>
</div>
{/* Type selector gallery */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.backgrounds.selectType')}
</label>
{/* None option */}
<button
onClick={() => handleTypeChange('none')}
className={cn(
'mb-3 w-full rounded-xl border p-3 text-left transition-colors',
config.type === 'none'
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="text-sm font-medium text-dark-200">
{t('admin.backgrounds.none')}
</span>
<span className="ml-2 text-xs text-dark-400">{t('admin.backgrounds.noneDesc')}</span>
</button>
{/* Background types by category */}
<div className="space-y-4">
{Array.from(categories.entries()).map(([category, defs]) => (
<div key={category}>
<span className="mb-2 block text-xs font-medium uppercase tracking-wider text-dark-500">
{t(`admin.backgrounds.category${category.toUpperCase()}`)}
</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{defs.map((def) => (
<button
key={def.type}
onClick={() => handleTypeChange(def.type)}
className={cn(
'rounded-xl border p-3 text-left transition-colors',
config.type === def.type
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
)}
>
<span className="block text-sm font-medium text-dark-200">
{t(def.labelKey)}
</span>
<span className="block text-xs text-dark-400">{t(def.descriptionKey)}</span>
</button>
))}
</div>
</div>
))}
</div>
</div>
{/* Per-type settings */}
{currentDef && currentDef.settings.length > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<h4 className="mb-3 text-sm font-medium text-dark-200">
{t('admin.backgrounds.settings')}
</h4>
<div className="space-y-3">
{currentDef.settings.map((def) => (
<SettingField
key={def.key}
def={def}
value={config.settings[def.key]}
onChange={(val) => updateSetting(def.key, val)}
t={t}
/>
))}
</div>
</div>
)}
{/* Global settings */}
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<div className="space-y-3">
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">
{t('admin.backgrounds.globalOpacity')}
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0.1}
max={1}
step={0.05}
value={config.opacity}
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.opacity}
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<label className="text-sm text-dark-300">{t('admin.backgrounds.globalBlur')}</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={20}
step={1}
value={config.blur}
onChange={(e) => updateConfig({ blur: parseInt(e.target.value) })}
className="w-24 accent-accent-500"
/>
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
{config.blur}px
</span>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<label className="text-sm text-dark-300">
{t('admin.backgrounds.reducedOnMobile')}
</label>
<p className="text-xs text-dark-500">
{t('admin.backgrounds.reducedOnMobileDesc')}
</p>
</div>
<Toggle
checked={config.reducedOnMobile}
onChange={() => updateConfig({ reducedOnMobile: !config.reducedOnMobile })}
/>
</div>
</div>
</div>
</>
)}
<BackgroundConfigEditor value={config} onChange={handleChange} />
{/* Save button */}
{showSaveButton && (

View File

@@ -96,52 +96,35 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
return reduced;
}
export function BackgroundRenderer() {
/** Renders the background animation into a portal at document.body */
function RenderBackground({ config }: { config: AnimationConfig }) {
const prefersReducedMotion = useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[],
);
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const raw = await brandingApi.getAnimationConfig();
// Validate and clamp API response same as localStorage
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
setCachedConfig(result);
return result;
},
initialData: getCachedConfig() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 30_000,
});
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
if (!effectiveConfig.enabled || effectiveConfig.type === 'none' || prefersReducedMotion) {
if (!config.enabled || config.type === 'none' || prefersReducedMotion) {
return null;
}
const bgType = effectiveConfig.type as Exclude<BackgroundType, 'none'>;
const bgType = config.type as Exclude<BackgroundType, 'none'>;
const Component = backgroundComponents[bgType];
if (!Component) return null;
const isMobile = window.innerWidth < 768;
const settings =
effectiveConfig.reducedOnMobile && isMobile
? reduceMobileSettings(effectiveConfig.settings)
: effectiveConfig.settings;
config.reducedOnMobile && isMobile ? reduceMobileSettings(config.settings) : config.settings;
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
const effectiveBlur = isMobile ? Math.min(config.blur, 4) : config.blur;
return createPortal(
<div
className="pointer-events-none fixed inset-0"
style={{
zIndex: -2,
opacity: effectiveConfig.opacity,
opacity: config.opacity,
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
contain: 'strict',
backfaceVisibility: 'hidden',
@@ -154,3 +137,29 @@ export function BackgroundRenderer() {
document.body,
);
}
/** BackgroundRenderer that fetches config from the branding API (for authenticated app shell) */
export function BackgroundRenderer() {
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const raw = await brandingApi.getAnimationConfig();
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
setCachedConfig(result);
return result;
},
initialData: getCachedConfig() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 30_000,
});
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
return <RenderBackground config={effectiveConfig} />;
}
/** StaticBackgroundRenderer that uses a provided config (for public landing pages) */
export function StaticBackgroundRenderer({ config }: { config: AnimationConfig }) {
const validated = useMemo(() => validateConfig(config), [config]);
if (!validated) return null;
return <RenderBackground config={validated} />;
}

View File

@@ -3779,6 +3779,7 @@
"discountOverridesHint": "Оставьте пустым для использования общей скидки",
"discountPreview": "Предпросмотр",
"discountActive": "Скидка",
"background": "Анимированный фон",
"statistics": "Статистика",
"stats": {
"title": "Статистика лендинга",

View File

@@ -15,6 +15,9 @@ import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
import { formatPrice } from '../utils/format';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
import { BackgroundConfigEditor } from '../components/admin/BackgroundConfigEditor';
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { SortableFeatureItem, type FeatureWithId } from '../components/admin/SortableFeatureItem';
import {
SortableSelectedMethodCard,
@@ -102,6 +105,7 @@ export default function AdminLandingEditor() {
discount: false,
methods: false,
gifts: false,
background: false,
footer: false,
});
@@ -127,6 +131,12 @@ export default function AdminLandingEditor() {
const [footerText, setFooterText] = useState<LocaleDict>({});
const [customCss, setCustomCss] = useState('');
// Background config state
const [backgroundConfig, setBackgroundConfig] = useState<AnimationConfig>({
...DEFAULT_ANIMATION_CONFIG,
enabled: false,
});
// Discount state
const [discountPercent, setDiscountPercent] = useState<number | null>(null);
const [discountOverrides, setDiscountOverrides] = useState<Record<string, number>>({});
@@ -245,6 +255,9 @@ export default function AdminLandingEditor() {
setGiftEnabled(landingData.gift_enabled);
setFooterText(toLocaleDict(landingData.footer_text));
setCustomCss(landingData.custom_css ?? '');
if (landingData.background_config) {
setBackgroundConfig(landingData.background_config);
}
setDiscountPercent(landingData.discount_percent ?? null);
setDiscountOverrides(landingData.discount_overrides ?? {});
setDiscountStartsAt(
@@ -364,6 +377,7 @@ export default function AdminLandingEditor() {
discountPercent !== null && discountEndsAt ? new Date(discountEndsAt).toISOString() : null,
discount_badge_text:
discountPercent !== null ? (nonEmptyDict(discountBadgeText) ?? null) : null,
background_config: backgroundConfig.enabled ? backgroundConfig : null,
};
if (isEdit) {
@@ -1056,6 +1070,15 @@ export default function AdminLandingEditor() {
</div>
</Section>
{/* Background Section */}
<Section
title={t('admin.landings.background', 'Background')}
open={openSections.background}
onToggle={() => toggleSection('background')}
>
<BackgroundConfigEditor value={backgroundConfig} onChange={setBackgroundConfig} />
</Section>
{/* Footer & Custom CSS Section */}
<Section
title={t('admin.landings.content')}

View File

@@ -12,6 +12,7 @@ import type {
LandingPaymentMethod,
PurchaseRequest,
} from '../api/landings';
import { StaticBackgroundRenderer } from '../components/backgrounds/BackgroundRenderer';
import LanguageSwitcher from '../components/LanguageSwitcher';
import { cn } from '../lib/utils';
import { getApiErrorMessage } from '../utils/api-error';
@@ -963,6 +964,7 @@ export default function QuickPurchase() {
return (
<div className="min-h-dvh overflow-x-hidden bg-dark-950">
{config.background_config && <StaticBackgroundRenderer config={config.background_config} />}
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
{/* Language switcher */}
<div className="mb-4 flex justify-end">