mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -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>;
|
||||
|
||||
304
src/components/admin/BackgroundConfigEditor.tsx
Normal file
304
src/components/admin/BackgroundConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -3779,6 +3779,7 @@
|
||||
"discountOverridesHint": "Оставьте пустым для использования общей скидки",
|
||||
"discountPreview": "Предпросмотр",
|
||||
"discountActive": "Скидка",
|
||||
"background": "Анимированный фон",
|
||||
"statistics": "Статистика",
|
||||
"stats": {
|
||||
"title": "Статистика лендинга",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user