diff --git a/src/api/landings.ts b/src/api/landings.ts index b9bf5f1..18b5b1d 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -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; diff --git a/src/components/admin/BackgroundConfigEditor.tsx b/src/components/admin/BackgroundConfigEditor.tsx new file mode 100644 index 0000000..e168932 --- /dev/null +++ b/src/components/admin/BackgroundConfigEditor.tsx @@ -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 ( +
+ +
+ onChange(parseFloat(e.target.value))} + className="w-24 accent-accent-500" + /> + {displayVal} +
+
+ ); + } + + 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 ( +
+ +
+ onChange(e.target.value)} + className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent" + /> + {colorVal} +
+
+ ); + } + + if (def.type === 'boolean') { + const boolVal = (value as boolean) ?? (def.default as boolean); + return ( +
+ + onChange(!boolVal)} /> +
+ ); + } + + if (def.type === 'select' && def.options) { + const selectVal = (value as string) ?? (def.default as string); + return ( +
+ + +
+ ); + } + + return null; +} + +interface BackgroundConfigEditorProps { + value: AnimationConfig; + onChange: (config: AnimationConfig) => void; +} + +export function BackgroundConfigEditor({ value: config, onChange }: BackgroundConfigEditorProps) { + const { t } = useTranslation(); + + const updateConfig = (patch: Partial) => { + 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 = {}; + 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(); + for (const def of backgroundRegistry) { + const list = cats.get(def.category) ?? []; + list.push(def); + cats.set(def.category, list); + } + return cats; + }, []); + + return ( +
+ {/* Header with enable toggle */} +
+
+

{t('admin.backgrounds.title')}

+

{t('admin.backgrounds.description')}

+
+ updateConfig({ enabled: !config.enabled })} + /> +
+ + {config.enabled && ( + <> + {/* Preview */} +
+ + +
+ + {/* Type selector gallery */} +
+ + + {/* None option */} + + + {/* Background types by category */} +
+ {Array.from(categories.entries()).map(([category, defs]) => ( +
+ + {t(`admin.backgrounds.category${category.toUpperCase()}`)} + +
+ {defs.map((def) => ( + + ))} +
+
+ ))} +
+
+ + {/* Per-type settings */} + {currentDef && currentDef.settings.length > 0 && ( +
+

+ {t('admin.backgrounds.settings')} +

+
+ {currentDef.settings.map((def) => ( + updateSetting(def.key, val)} + t={t} + /> + ))} +
+
+ )} + + {/* Global settings */} +
+
+
+ +
+ updateConfig({ opacity: parseFloat(e.target.value) })} + className="w-24 accent-accent-500" + /> + + {config.opacity} + +
+
+ +
+ +
+ updateConfig({ blur: Number(e.target.value) })} + className="w-24 accent-accent-500" + /> + + {config.blur}px + +
+
+ +
+
+ +

+ {t('admin.backgrounds.reducedOnMobileDesc')} +

+
+ updateConfig({ reducedOnMobile: !config.reducedOnMobile })} + /> +
+
+
+ + )} +
+ ); +} diff --git a/src/components/admin/BackgroundEditor.tsx b/src/components/admin/BackgroundEditor.tsx index 979761d..f57f651 100644 --- a/src/components/admin/BackgroundEditor.tsx +++ b/src/components/admin/BackgroundEditor.tsx @@ -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 ( -
- -
- onChange(parseFloat(e.target.value))} - className="w-24 accent-accent-500" - /> - {displayVal} -
-
- ); - } - - 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 ( -
- -
- onChange(e.target.value)} - className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent" - /> - {colorVal} -
-
- ); - } - - if (def.type === 'boolean') { - const boolVal = (value as boolean) ?? (def.default as boolean); - return ( -
- - onChange(!boolVal)} /> -
- ); - } - - if (def.type === 'select' && def.options) { - const selectVal = (value as string) ?? (def.default as string); - return ( -
- - -
- ); - } - - return null; -} - export function BackgroundEditor() { const { t } = useTranslation(); const queryClient = useQueryClient(); const saveTimerRef = useRef>(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) => { - 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 = {}; - 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(); - for (const def of backgroundRegistry) { - const list = cats.get(def.category) ?? []; - list.push(def); - cats.set(def.category, list); - } - return cats; - }, []); - return (
- {/* Header with enable toggle */} -
-
-

{t('admin.backgrounds.title')}

-

{t('admin.backgrounds.description')}

-
- updateConfig({ enabled: !config.enabled })} - /> -
- - {config.enabled && ( - <> - {/* Preview */} -
- - -
- - {/* Type selector gallery */} -
- - - {/* None option */} - - - {/* Background types by category */} -
- {Array.from(categories.entries()).map(([category, defs]) => ( -
- - {t(`admin.backgrounds.category${category.toUpperCase()}`)} - -
- {defs.map((def) => ( - - ))} -
-
- ))} -
-
- - {/* Per-type settings */} - {currentDef && currentDef.settings.length > 0 && ( -
-

- {t('admin.backgrounds.settings')} -

-
- {currentDef.settings.map((def) => ( - updateSetting(def.key, val)} - t={t} - /> - ))} -
-
- )} - - {/* Global settings */} -
-
-
- -
- updateConfig({ opacity: parseFloat(e.target.value) })} - className="w-24 accent-accent-500" - /> - - {config.opacity} - -
-
- -
- -
- updateConfig({ blur: parseInt(e.target.value) })} - className="w-24 accent-accent-500" - /> - - {config.blur}px - -
-
- -
-
- -

- {t('admin.backgrounds.reducedOnMobileDesc')} -

-
- updateConfig({ reducedOnMobile: !config.reducedOnMobile })} - /> -
-
-
- - )} + {/* Save button */} {showSaveButton && ( diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index d7e7f93..2638c3d 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -96,52 +96,35 @@ function reduceMobileSettings(settings: Record): Record 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; + const bgType = config.type as Exclude; 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(
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 ; +} + +/** 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 ; +} diff --git a/src/locales/ru.json b/src/locales/ru.json index 83426aa..a899ce6 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3779,6 +3779,7 @@ "discountOverridesHint": "Оставьте пустым для использования общей скидки", "discountPreview": "Предпросмотр", "discountActive": "Скидка", + "background": "Анимированный фон", "statistics": "Статистика", "stats": { "title": "Статистика лендинга", diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index 961aaa2..39dc68f 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -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({}); const [customCss, setCustomCss] = useState(''); + // Background config state + const [backgroundConfig, setBackgroundConfig] = useState({ + ...DEFAULT_ANIMATION_CONFIG, + enabled: false, + }); + // Discount state const [discountPercent, setDiscountPercent] = useState(null); const [discountOverrides, setDiscountOverrides] = useState>({}); @@ -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() {
+ {/* Background Section */} +
toggleSection('background')} + > + +
+ {/* Footer & Custom CSS Section */}
+ {config.background_config && }
{/* Language switcher */}