mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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
166 lines
5.9 KiB
TypeScript
166 lines
5.9 KiB
TypeScript
import { Suspense, useMemo } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { brandingApi } from '@/api/branding';
|
|
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
|
|
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
|
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
|
|
|
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
|
const MAX_CONFIG_JSON_LENGTH = 10_000;
|
|
|
|
const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
|
|
'aurora',
|
|
'sparkles',
|
|
'vortex',
|
|
'shooting-stars',
|
|
'background-beams',
|
|
'background-beams-collision',
|
|
'gradient-animation',
|
|
'wavy',
|
|
'background-lines',
|
|
'boxes',
|
|
'meteors',
|
|
'grid',
|
|
'dots',
|
|
'spotlight',
|
|
'ripple',
|
|
'none',
|
|
]);
|
|
|
|
function validateConfig(parsed: unknown): AnimationConfig | null {
|
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
|
const obj = parsed as Record<string, unknown>;
|
|
if (typeof obj.enabled !== 'boolean') return null;
|
|
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null;
|
|
if (typeof obj.opacity !== 'number') return null;
|
|
if (typeof obj.blur !== 'number') return null;
|
|
if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings)))
|
|
return null;
|
|
return {
|
|
enabled: obj.enabled,
|
|
type: obj.type as BackgroundType,
|
|
opacity: Math.max(0, Math.min(1, obj.opacity)),
|
|
blur: Math.max(0, Math.min(50, obj.blur)),
|
|
settings: (obj.settings as Record<string, unknown>) ?? {},
|
|
reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true,
|
|
};
|
|
}
|
|
|
|
function getCachedConfig(): AnimationConfig | null {
|
|
try {
|
|
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
|
if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null;
|
|
return validateConfig(JSON.parse(cached));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Prefetch the background JS chunk immediately based on localStorage cache.
|
|
const cachedConfig = getCachedConfig();
|
|
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
|
prefetchBackground(cachedConfig.type);
|
|
}
|
|
|
|
function setCachedConfig(config: AnimationConfig) {
|
|
try {
|
|
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
|
|
} catch {
|
|
// localStorage not available
|
|
}
|
|
}
|
|
|
|
export function setCachedAnimationConfig(config: AnimationConfig) {
|
|
const validated = validateConfig(config);
|
|
if (validated) setCachedConfig(validated);
|
|
}
|
|
|
|
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
|
const reduced = { ...settings };
|
|
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
|
if (typeof reduced.particleCount === 'number')
|
|
reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4));
|
|
if (typeof reduced.particleDensity === 'number')
|
|
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
|
|
if (typeof reduced.number === 'number')
|
|
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
|
|
if ('interactive' in reduced) reduced.interactive = false;
|
|
if (typeof reduced.lineCount === 'number')
|
|
reduced.lineCount = Math.max(5, Math.floor(reduced.lineCount / 2));
|
|
if (typeof reduced.rippleCount === 'number')
|
|
reduced.rippleCount = Math.max(2, Math.floor(reduced.rippleCount / 2));
|
|
if (typeof reduced.count === 'number') reduced.count = Math.max(3, Math.floor(reduced.count / 2));
|
|
if (typeof reduced.rows === 'number') reduced.rows = Math.max(4, Math.floor(reduced.rows * 0.6));
|
|
if (typeof reduced.cols === 'number') reduced.cols = Math.max(4, Math.floor(reduced.cols * 0.6));
|
|
return reduced;
|
|
}
|
|
|
|
/** 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,
|
|
[],
|
|
);
|
|
|
|
if (!config.enabled || config.type === 'none' || prefersReducedMotion) {
|
|
return null;
|
|
}
|
|
|
|
const bgType = config.type as Exclude<BackgroundType, 'none'>;
|
|
const Component = backgroundComponents[bgType];
|
|
|
|
if (!Component) return null;
|
|
|
|
const isMobile = window.innerWidth < 768;
|
|
const 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(config.blur, 4) : config.blur;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="pointer-events-none fixed inset-0"
|
|
style={{
|
|
zIndex: -2,
|
|
opacity: config.opacity,
|
|
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
|
|
contain: 'strict',
|
|
backfaceVisibility: 'hidden',
|
|
}}
|
|
>
|
|
<Suspense fallback={null}>
|
|
<Component settings={settings} />
|
|
</Suspense>
|
|
</div>,
|
|
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} />;
|
|
}
|