diff --git a/src/App.tsx b/src/App.tsx index 46b78bc..abbd610 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -155,6 +155,7 @@ const AdminNewsCreate = lazyWithRetry(() => import('./pages/AdminNewsCreate')); const InfoPageView = lazyWithRetry(() => import('./pages/InfoPageView')); const AdminInfoPages = lazyWithRetry(() => import('./pages/AdminInfoPages')); const AdminInfoPageEditor = lazyWithRetry(() => import('./pages/AdminInfoPageEditor')); +const AdminLegalPages = lazyWithRetry(() => import('./pages/AdminLegalPages')); function ProtectedRoute({ children, @@ -1345,6 +1346,16 @@ function App() { } /> + + + + + + } + /> ; +} + +export interface RulesItem { + language: string; + content: string; + updated_at: string | null; +} + +export interface AdminRulesResponse { + display_mode: LegalDisplayMode; + display_mode_env_locked: boolean; + items: RulesItem[]; +} + +export interface RulesUpdateRequest { + display_mode?: LegalDisplayMode; + items?: Array<{ language: string; content: string }>; +} + +export interface FaqSettingItem { + language: string; + is_enabled: boolean; +} + +export interface FaqPageItem { + id: number; + language: string; + title: string; + content: string; + display_order: number; + is_active: boolean; + updated_at: string | null; +} + +export interface FaqResponse { + display_mode: LegalDisplayMode; + display_mode_env_locked: boolean; + settings: FaqSettingItem[]; + pages: FaqPageItem[]; +} + +export interface FaqUpdateRequest { + display_mode?: LegalDisplayMode; + settings?: FaqSettingItem[]; +} + +export interface FaqPageCreateRequest { + language: string; + title: string; + content: string; + display_order?: number; + is_active?: boolean; +} + +export interface FaqPageUpdateRequest { + title?: string; + content?: string; + display_order?: number; + is_active?: boolean; +} + +export const adminLegalPagesApi = { + getPrivacyPolicy: async (): Promise => { + const response = await apiClient.get( + '/cabinet/admin/legal-pages/privacy-policy', + ); + return response.data; + }, + + updatePrivacyPolicy: async (data: LegalDocumentUpdateRequest): Promise => { + const response = await apiClient.put( + '/cabinet/admin/legal-pages/privacy-policy', + data, + ); + return response.data; + }, + + getPublicOffer: async (): Promise => { + const response = await apiClient.get( + '/cabinet/admin/legal-pages/public-offer', + ); + return response.data; + }, + + updatePublicOffer: async (data: LegalDocumentUpdateRequest): Promise => { + const response = await apiClient.put( + '/cabinet/admin/legal-pages/public-offer', + data, + ); + return response.data; + }, + + getRules: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/legal-pages/rules'); + return response.data; + }, + + updateRules: async (data: RulesUpdateRequest): Promise => { + const response = await apiClient.put( + '/cabinet/admin/legal-pages/rules', + data, + ); + return response.data; + }, + + getFaq: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/legal-pages/faq'); + return response.data; + }, + + updateFaq: async (data: FaqUpdateRequest): Promise => { + const response = await apiClient.put('/cabinet/admin/legal-pages/faq', data); + return response.data; + }, + + createFaqPage: async (data: FaqPageCreateRequest): Promise => { + const response = await apiClient.post( + '/cabinet/admin/legal-pages/faq/pages', + data, + ); + return response.data; + }, + + updateFaqPage: async (id: number, data: FaqPageUpdateRequest): Promise => { + const response = await apiClient.put( + `/cabinet/admin/legal-pages/faq/pages/${id}`, + data, + ); + return response.data; + }, + + deleteFaqPage: async (id: number): Promise => { + await apiClient.delete(`/cabinet/admin/legal-pages/faq/pages/${id}`); + }, +}; diff --git a/src/api/info.ts b/src/api/info.ts index 7ccf50d..76894d1 100644 --- a/src/api/info.ts +++ b/src/api/info.ts @@ -37,6 +37,13 @@ export interface LanguageInfo { flag: string; } +export interface InfoVisibility { + faq: boolean; + rules: boolean; + privacy: boolean; + offer: boolean; +} + export const infoApi = { // Get FAQ pages list getFaqPages: async (): Promise => { @@ -99,4 +106,9 @@ export const infoApi = { const response = await apiClient.get('/cabinet/info/support-config'); return response.data; }, + + getVisibility: async (): Promise => { + const response = await apiClient.get('/cabinet/info/visibility'); + return response.data; + }, }; diff --git a/src/api/infoPages.ts b/src/api/infoPages.ts index 39a39a4..899a438 100644 --- a/src/api/infoPages.ts +++ b/src/api/infoPages.ts @@ -4,6 +4,8 @@ export type InfoPageType = 'page' | 'faq'; export type ReplacesTab = 'faq' | 'rules' | 'privacy' | 'offer'; +export type InfoPageDisplayMode = 'bot' | 'web' | 'both'; + export interface InfoPage { id: number; slug: string; @@ -14,6 +16,7 @@ export interface InfoPage { sort_order: number; icon: string | null; replaces_tab: ReplacesTab | null; + display_mode: InfoPageDisplayMode; created_at: string; updated_at: string | null; } @@ -27,6 +30,7 @@ export interface InfoPageListItem { sort_order: number; icon: string | null; replaces_tab: ReplacesTab | null; + display_mode: InfoPageDisplayMode; updated_at: string | null; } @@ -39,6 +43,7 @@ export interface InfoPageCreateRequest { sort_order: number; icon: string | null; replaces_tab: ReplacesTab | null; + display_mode: InfoPageDisplayMode; } export interface InfoPageUpdateRequest { @@ -50,6 +55,7 @@ export interface InfoPageUpdateRequest { sort_order?: number; icon?: string | null; replaces_tab?: ReplacesTab | null; + display_mode?: InfoPageDisplayMode; } export type TabReplacements = Record; diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index df8b53e..2f8b251 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -20,6 +20,10 @@ function reduceMobileSettings(settings: Record): Record = { + slow: '90s', + normal: '60s', + fast: '30s', +}; + export default function AuroraBackground({ settings }: Props) { + const firstColor = sanitizeColor(settings.firstColor, '#3b82f6'); + const secondColor = sanitizeColor(settings.secondColor, '#a5b4fc'); + const thirdColor = sanitizeColor(settings.thirdColor, '#93c5fd'); + const speed = safeSelect(settings.speed, ['slow', 'normal', 'fast'] as const, 'normal'); const showRadialGradient = safeBoolean(settings.showRadialGradient, true); const paused = useAnimationPause(); @@ -22,9 +32,9 @@ export default function AuroraBackground({ settings }: Props) { '[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]', )} style={{ - backgroundImage: - 'repeating-linear-gradient(100deg, #000 0%, #000 7%, transparent 10%, transparent 12%, #000 16%), repeating-linear-gradient(100deg, #3b82f6 10%, #a5b4fc 15%, #93c5fd 20%, #ddd6fe 25%, #60a5fa 30%)', + backgroundImage: `repeating-linear-gradient(100deg, #000 0%, #000 7%, transparent 10%, transparent 12%, #000 16%), repeating-linear-gradient(100deg, ${firstColor} 10%, ${secondColor} 15%, ${thirdColor} 20%, ${secondColor} 25%, ${firstColor} 30%)`, backgroundSize: isMobile ? '100%, 100%' : '300%, 200%', + animationDuration: SPEED_DURATIONS[speed], animationPlayState: paused ? 'paused' : 'running', }} /> diff --git a/src/components/ui/backgrounds/background-beams-collision.tsx b/src/components/ui/backgrounds/background-beams-collision.tsx index 4e2f58d..e92b60a 100644 --- a/src/components/ui/backgrounds/background-beams-collision.tsx +++ b/src/components/ui/backgrounds/background-beams-collision.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { cn } from '@/lib/utils'; +import { sanitizeColor } from './types'; import { useAnimationPause } from '@/hooks/useAnimationLoop'; interface Props { @@ -26,7 +27,11 @@ const BEAMS: BeamOptions[] = [ { initialX: 1200, translateX: 1200, duration: 6, repeatDelay: 4, delay: 2, className: 'h-6' }, ]; -function Explosion(props: React.HTMLProps) { +function Explosion({ + beamColor, + explosionColor, + ...props +}: React.HTMLProps & { beamColor: string; explosionColor: string }) { const spans = Array.from({ length: 20 }, (_, i) => ({ id: i, directionX: Math.floor(Math.random() * 80 - 40), @@ -40,7 +45,10 @@ function Explosion(props: React.HTMLProps) { animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 1.5, ease: 'easeOut' }} - className="absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full bg-gradient-to-r from-transparent via-indigo-500 to-transparent blur-sm" + className="absolute -inset-x-10 top-0 m-auto h-2 w-10 rounded-full blur-sm" + style={{ + background: `linear-gradient(to right, transparent, ${explosionColor}, transparent)`, + }} /> {spans.map((span) => ( ) { initial={{ x: 0, y: 0, opacity: 1 }} animate={{ x: span.directionX, y: span.directionY, opacity: 0 }} transition={{ duration: Math.random() * 1.5 + 0.5, ease: 'easeOut' }} - className="absolute h-1 w-1 rounded-full bg-gradient-to-b from-indigo-500 to-purple-500" + className="absolute h-1 w-1 rounded-full" + style={{ + background: `linear-gradient(to bottom, ${beamColor}, ${explosionColor})`, + }} /> ))} @@ -59,10 +70,14 @@ function CollisionMechanism({ containerRef, parentRef, beamOptions, + beamColor, + explosionColor, }: { containerRef: React.RefObject; parentRef: React.RefObject; beamOptions: BeamOptions; + beamColor: string; + explosionColor: string; }) { const beamRef = useRef(null); const [collision, setCollision] = useState<{ @@ -92,8 +107,6 @@ function CollisionMechanism({ } }, [containerRef, parentRef]); - // Throttled collision detection loop. - // Parent unmounts this component when paused, so no visibility handling needed here. useEffect(() => { let animId = 0; let lastCheck = 0; @@ -114,7 +127,6 @@ function CollisionMechanism({ }; }, [checkCollision]); - // Collision reset with proper timeout cleanup useEffect(() => { if (!collision.detected || !collision.coordinates) return; @@ -154,14 +166,19 @@ function CollisionMechanism({ repeatDelay: beamOptions.repeatDelay, }} className={cn( - 'absolute left-0 top-20 m-auto h-14 w-px rounded-full bg-gradient-to-t from-indigo-500 via-purple-500 to-transparent', + 'absolute left-0 top-20 m-auto h-14 w-px rounded-full', beamOptions.className, )} + style={{ + background: `linear-gradient(to top, ${beamColor}, ${explosionColor}, transparent)`, + }} /> {collision.detected && collision.coordinates && ( (null); const parentRef = useRef(null); const paused = useAnimationPause(); + const beamColor = sanitizeColor(settings.beamColor, '#6366f1'); + const explosionColor = sanitizeColor(settings.explosionColor, '#a855f7'); + return (
{!paused && @@ -188,9 +208,10 @@ export default function BackgroundBeamsCollision({ settings: _settings }: Props) beamOptions={beam} containerRef={containerRef} parentRef={parentRef} + beamColor={beamColor} + explosionColor={explosionColor} /> ))} - {/* Bottom collision line */}
{ ensureStyles(); }, []); @@ -117,20 +125,18 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop fill="none" xmlns="http://www.w3.org/2000/svg" > - {/* Static background — all paths at very low opacity */} - {/* Animated beams — only every 3rd path (17 beams), pure stroke-dashoffset */} {animatedPaths.map(({ path, paramIndex }) => ( - {/* Single shared gradient for all beams (cyan → purple → magenta) */} - - - - - + + + + + - {/* Radial gradient for static background */} - - - + + + diff --git a/src/components/ui/backgrounds/background-boxes.tsx b/src/components/ui/backgrounds/background-boxes.tsx index 63def23..9e1b939 100644 --- a/src/components/ui/backgrounds/background-boxes.tsx +++ b/src/components/ui/backgrounds/background-boxes.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useMemo } from 'react'; -import { sanitizeColor, clampNumber } from './types'; +import { sanitizeColor, clampNumber, safeBoolean } from './types'; import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; interface Props { @@ -18,7 +18,14 @@ const COLORS = [ ]; function hexToRgb(hex: string): [number, number, number] { - const v = parseInt(hex.slice(1), 16); + let value = hex.replace('#', ''); + if (value.length === 3) + value = value + .split('') + .map((c) => c + c) + .join(''); + if (!/^[0-9a-fA-F]{6}$/.test(value)) return [129, 140, 248]; + const v = parseInt(value, 16); return [(v >> 16) & 255, (v >> 8) & 255, v & 255]; } @@ -44,16 +51,19 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) { const rows = clampNumber(settings.rows, 4, 30, 15); const cols = clampNumber(settings.cols, 4, 30, 15); const boxColor = sanitizeColor(settings.boxColor, '#818cf8'); + const lineColor = sanitizeColor(settings.lineColor, '#334155'); + const multicolor = + settings.multicolor === undefined + ? boxColor === '#818cf8' + : safeBoolean(settings.multicolor, true); const cells = useMemo((): CellData[] => { return Array.from({ length: rows * cols }, () => ({ - rgb: hexToRgb( - boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor, - ), + rgb: hexToRgb(multicolor ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor), phase: Math.random() * 8, period: 3 + Math.random() * 4, })); - }, [rows, cols, boxColor]); + }, [rows, cols, boxColor, multicolor]); useEffect(() => { const canvas = canvasRef.current; @@ -124,7 +134,8 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) { ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH); } - ctx.strokeStyle = 'rgba(51,65,85,0.5)'; + ctx.globalAlpha = 0.5; + ctx.strokeStyle = lineColor; ctx.lineWidth = 1; ctx.beginPath(); @@ -142,7 +153,7 @@ export default React.memo(function BackgroundBoxes({ settings }: Props) { ctx.stroke(); ctx.restore(); }, - [cells, rows, cols], + [cells, rows, cols, lineColor], ); return ( diff --git a/src/components/ui/backgrounds/background-gradient-animation.tsx b/src/components/ui/backgrounds/background-gradient-animation.tsx index 5f614fa..77c18a2 100644 --- a/src/components/ui/backgrounds/background-gradient-animation.tsx +++ b/src/components/ui/backgrounds/background-gradient-animation.tsx @@ -32,6 +32,7 @@ export default function BackgroundGradientAnimation({ settings }: Props) { const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF')); const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232')); const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432')); + const pointerColor = hexToRgbString(sanitizeColor(settings.pointerColor, '#8C64FF')); const interactive = safeBoolean(settings.interactive, true); const size = safeSelect(settings.size, ['60%', '80%', '100%'] as const, '80%'); @@ -172,7 +173,7 @@ export default function BackgroundGradientAnimation({ settings }: Props) { ref={interactiveRef} className="absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-70 mix-blend-hard-light" style={{ - background: `radial-gradient(circle at center, rgba(140, 100, 255, 0.8) 0, rgba(140, 100, 255, 0) 50%) no-repeat`, + background: `radial-gradient(circle at center, rgba(${pointerColor}, 0.8) 0, rgba(${pointerColor}, 0) 50%) no-repeat`, }} /> )} diff --git a/src/components/ui/backgrounds/constellation.tsx b/src/components/ui/backgrounds/constellation.tsx new file mode 100644 index 0000000..5c38255 --- /dev/null +++ b/src/components/ui/backgrounds/constellation.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; +} + +interface ConstellationState { + ctx: CanvasRenderingContext2D; + particles: Particle[]; + w: number; + h: number; + dpr: number; +} + +export default function ConstellationBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const particleColor = sanitizeColor(settings.particleColor, '#818cf8'); + const lineColor = sanitizeColor(settings.lineColor, '#818cf8'); + const count = clampNumber(settings.count, 10, 200, 60); + const linkDistance = clampNumber(settings.linkDistance, 40, 300, 120); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const particles: Particle[] = Array.from({ length: Math.floor(count) }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.5, + vy: (Math.random() - 0.5) * 0.5, + })); + + stateRef.current = { ctx, particles, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + const state = stateRef.current; + if (state) { + state.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + if (state.w > 0 && state.h > 0) { + for (const p of state.particles) { + p.x *= nw / state.w; + p.y *= nh / state.h; + } + } + state.w = nw; + state.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [count]); + + useAnimationLoop(() => { + const state = stateRef.current; + if (!state) return; + + const { ctx, particles, w, h } = state; + const linkDistanceSq = linkDistance * linkDistance; + + ctx.clearRect(0, 0, w, h); + + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + + if (p.x < 0) { + p.x = 0; + p.vx = Math.abs(p.vx); + } else if (p.x > w) { + p.x = w; + p.vx = -Math.abs(p.vx); + } + if (p.y < 0) { + p.y = 0; + p.vy = Math.abs(p.vy); + } else if (p.y > h) { + p.y = h; + p.vy = -Math.abs(p.vy); + } + } + + ctx.strokeStyle = lineColor; + ctx.lineWidth = 0.8; + + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const dx = particles[i].x - particles[j].x; + const dy = particles[i].y - particles[j].y; + const distSq = dx * dx + dy * dy; + + if (distSq < linkDistanceSq) { + const dist = Math.sqrt(distSq); + ctx.globalAlpha = (1 - dist / linkDistance) * 0.35; + ctx.beginPath(); + ctx.moveTo(particles[i].x, particles[i].y); + ctx.lineTo(particles[j].x, particles[j].y); + ctx.stroke(); + } + } + } + + ctx.globalAlpha = 0.8; + ctx.fillStyle = particleColor; + + for (const p of particles) { + ctx.beginPath(); + ctx.arc(p.x, p.y, 1.8, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, [particleColor, lineColor, count, linkDistance]); + + return ; +} diff --git a/src/components/ui/backgrounds/fireflies.tsx b/src/components/ui/backgrounds/fireflies.tsx new file mode 100644 index 0000000..e3cfbe6 --- /dev/null +++ b/src/components/ui/backgrounds/fireflies.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +interface Firefly { + x: number; + y: number; + vx: number; + vy: number; + radius: number; + phase: number; + pulseSpeed: number; +} + +interface FirefliesState { + ctx: CanvasRenderingContext2D; + fireflies: Firefly[]; + w: number; + h: number; + dpr: number; +} + +export default function FirefliesBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#ffd166'); + const count = clampNumber(settings.count, 5, 200, 40); + const speed = clampNumber(settings.speed, 0.1, 3, 1); + const size = clampNumber(settings.size, 0.5, 6, 2); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const fireflies: Firefly[] = Array.from({ length: Math.floor(count) }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.6, + vy: (Math.random() - 0.5) * 0.6, + radius: 0.5 + Math.random() * 0.5, + phase: Math.random() * Math.PI * 2, + pulseSpeed: 0.5 + Math.random() * 1.5, + })); + + stateRef.current = { ctx, fireflies, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [count]); + + useAnimationLoop( + (time) => { + const state = stateRef.current; + if (!state) return; + + const { ctx, fireflies, w, h } = state; + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = color; + + for (const f of fireflies) { + f.x += f.vx * speed + Math.sin(time / 2000 + f.phase) * 0.3 * speed; + f.y += f.vy * speed + Math.cos(time / 2400 + f.phase) * 0.2 * speed; + + if (f.x < -20) f.x = w + 20; + if (f.x > w + 20) f.x = -20; + if (f.y < -20) f.y = h + 20; + if (f.y > h + 20) f.y = -20; + + const pulse = 0.35 + 0.65 * (0.5 + 0.5 * Math.sin((time / 1000) * f.pulseSpeed + f.phase)); + const radius = f.radius * size; + + ctx.globalAlpha = pulse * 0.25; + ctx.beginPath(); + ctx.arc(f.x, f.y, radius * 3, 0, Math.PI * 2); + ctx.fill(); + + ctx.globalAlpha = pulse; + ctx.beginPath(); + ctx.arc(f.x, f.y, radius, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, + [color, count, speed, size], + ); + + return ; +} diff --git a/src/components/ui/backgrounds/liquid-gradient.tsx b/src/components/ui/backgrounds/liquid-gradient.tsx new file mode 100644 index 0000000..e82f3e8 --- /dev/null +++ b/src/components/ui/backgrounds/liquid-gradient.tsx @@ -0,0 +1,59 @@ +import { cn } from '@/lib/utils'; +import { sanitizeColor, clampNumber, safeSelect } from './types'; +import { useAnimationPause } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + +const BLOBS = [ + { anim: 'animate-move-vertical', baseDuration: 30, top: '5%', left: '10%' }, + { anim: 'animate-move-in-circle', baseDuration: 20, top: '35%', left: '50%' }, + { anim: 'animate-move-horizontal', baseDuration: 40, top: '55%', left: '15%' }, + { anim: 'animate-move-in-circle-slow', baseDuration: 40, top: '15%', left: '55%' }, +]; + +const SPEED_MULTIPLIERS: Record = { + slow: 1.6, + normal: 1, + fast: 0.5, +}; + +export default function LiquidGradientBackground({ settings }: Props) { + const paused = useAnimationPause(); + + const colors = [ + sanitizeColor(settings.color1, '#6366f1'), + sanitizeColor(settings.color2, '#ec4899'), + sanitizeColor(settings.color3, '#22d3ee'), + sanitizeColor(settings.color4, '#a855f7'), + ]; + const speed = safeSelect(settings.speed, ['slow', 'normal', 'fast'] as const, 'normal'); + const blurAmount = clampNumber(settings.blurAmount, 10, 120, 60); + + const multiplier = SPEED_MULTIPLIERS[speed]; + const effectiveBlur = isMobile ? Math.min(blurAmount, 30) : blurAmount; + + return ( +
+
+ {BLOBS.map((blob, i) => ( +
+ ))} +
+
+ ); +} diff --git a/src/components/ui/backgrounds/matrix-rain.tsx b/src/components/ui/backgrounds/matrix-rain.tsx new file mode 100644 index 0000000..d83854e --- /dev/null +++ b/src/components/ui/backgrounds/matrix-rain.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber, safeSelect } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +const FONT_SIZE = 16; +const BASE_STEP_MS = 80; + +const CHARSETS: Record = { + katakana: + 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲンガギグゲゴザジズゼゾダヂヅデドバビブベボ', + latin: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + binary: '01', +}; + +interface MatrixState { + ctx: CanvasRenderingContext2D; + drops: number[]; + active: boolean[]; + acc: number; + w: number; + h: number; + dpr: number; +} + +export default function MatrixRainBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#00ff41'); + const density = clampNumber(settings.density, 10, 100, 70); + const speed = clampNumber(settings.speed, 0.2, 3, 1); + const charset = safeSelect( + settings.charset, + ['katakana', 'latin', 'binary'] as const, + 'katakana', + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const initColumns = (width: number, height: number) => { + const cols = Math.max(1, Math.floor(width / FONT_SIZE)); + return { + drops: Array.from({ length: cols }, () => Math.floor(Math.random() * (height / FONT_SIZE))), + active: Array.from({ length: cols }, () => Math.random() * 100 < density), + }; + }; + + const { drops, active } = initColumns(w, h); + stateRef.current = { ctx, drops, active, acc: 0, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + const next = initColumns(nw, nh); + stateRef.current.drops = next.drops; + stateRef.current.active = next.active; + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [density]); + + useAnimationLoop( + (_time, delta) => { + const state = stateRef.current; + if (!state) return; + + const { ctx, drops, active, w, h } = state; + const chars = CHARSETS[charset]; + + state.acc += delta; + const stepMs = BASE_STEP_MS / speed; + if (state.acc < stepMs) return; + state.acc %= stepMs; + + ctx.globalCompositeOperation = 'destination-out'; + ctx.fillStyle = 'rgba(0,0,0,0.12)'; + ctx.fillRect(0, 0, w, h); + ctx.globalCompositeOperation = 'source-over'; + + ctx.fillStyle = color; + ctx.font = `${FONT_SIZE}px monospace`; + + for (let i = 0; i < drops.length; i++) { + if (!active[i]) continue; + const char = chars[Math.floor(Math.random() * chars.length)]; + ctx.fillText(char, i * FONT_SIZE, drops[i] * FONT_SIZE); + + if (drops[i] * FONT_SIZE > h && Math.random() > 0.975) { + drops[i] = 0; + active[i] = Math.random() * 100 < density; + } + drops[i]++; + } + }, + [color, density, speed, charset], + ); + + return ; +} diff --git a/src/components/ui/backgrounds/meteors.tsx b/src/components/ui/backgrounds/meteors.tsx index 9cc0e54..b5ad436 100644 --- a/src/components/ui/backgrounds/meteors.tsx +++ b/src/components/ui/backgrounds/meteors.tsx @@ -36,7 +36,7 @@ export default function Meteors({ settings }: Props) { animationPlayState: paused ? 'paused' : 'running', width: meteor.size, height: meteor.size, - boxShadow: `0 0 0 1px rgba(255,255,255,0.05), 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`, + boxShadow: `0 0 0 1px ${meteorColor}0d, 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`, background: meteorColor, }} > diff --git a/src/components/ui/backgrounds/registry.ts b/src/components/ui/backgrounds/registry.ts index 18638d7..3605d00 100644 --- a/src/components/ui/backgrounds/registry.ts +++ b/src/components/ui/backgrounds/registry.ts @@ -18,6 +18,12 @@ const backgroundImports: Record, () => Promise import('./grid-background'), spotlight: () => import('./spotlight-bg'), ripple: () => import('./background-ripple'), + fireflies: () => import('./fireflies'), + snowfall: () => import('./snowfall'), + starfield: () => import('./starfield'), + 'matrix-rain': () => import('./matrix-rain'), + 'liquid-gradient': () => import('./liquid-gradient'), + constellation: () => import('./constellation'), }; /** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */ @@ -47,6 +53,12 @@ export const backgroundComponents: Record< dots: lazy(() => import('./grid-background')), spotlight: lazy(() => import('./spotlight-bg')), ripple: lazy(() => import('./background-ripple')), + fireflies: lazy(() => import('./fireflies')), + snowfall: lazy(() => import('./snowfall')), + starfield: lazy(() => import('./starfield')), + 'matrix-rain': lazy(() => import('./matrix-rain')), + 'liquid-gradient': lazy(() => import('./liquid-gradient')), + constellation: lazy(() => import('./constellation')), }; // Registry of all background definitions with settings for the editor @@ -59,9 +71,15 @@ export const backgroundRegistry: BackgroundDefinition[] = [ descriptionKey: 'admin.backgrounds.auroraDesc', category: 'css', settings: [ - { key: 'firstColor', label: 'admin.backgrounds.color1', type: 'color', default: '#00d2ff' }, - { key: 'secondColor', label: 'admin.backgrounds.color2', type: 'color', default: '#7928ca' }, - { key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#ff0080' }, + { key: 'firstColor', label: 'admin.backgrounds.color1', type: 'color', default: '#3b82f6' }, + { key: 'secondColor', label: 'admin.backgrounds.color2', type: 'color', default: '#a5b4fc' }, + { key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#93c5fd' }, + { + key: 'showRadialGradient', + label: 'admin.backgrounds.radialGradient', + type: 'boolean', + default: true, + }, { key: 'speed', label: 'admin.backgrounds.speed', @@ -188,6 +206,12 @@ export const backgroundRegistry: BackgroundDefinition[] = [ type: 'color', default: '#2EB9DF', }, + { + key: 'bgStarColor', + label: 'admin.backgrounds.bgStarColor', + type: 'color', + default: '#ffffff', + }, { key: 'starDensity', label: 'admin.backgrounds.density', @@ -222,14 +246,37 @@ export const backgroundRegistry: BackgroundDefinition[] = [ labelKey: 'admin.backgrounds.beams', descriptionKey: 'admin.backgrounds.beamsDesc', category: 'svg', - settings: [], + settings: [ + { + key: 'gradientStart', + label: 'admin.backgrounds.color1', + type: 'color', + default: '#18CCFC', + }, + { key: 'gradientMid', label: 'admin.backgrounds.color2', type: 'color', default: '#6344F5' }, + { key: 'gradientEnd', label: 'admin.backgrounds.color3', type: 'color', default: '#AE48FF' }, + { + key: 'staticColor', + label: 'admin.backgrounds.fillColor', + type: 'color', + default: '#d4d4d4', + }, + ], }, { type: 'background-beams-collision', labelKey: 'admin.backgrounds.beamsCollision', descriptionKey: 'admin.backgrounds.beamsCollisionDesc', category: 'svg', - settings: [], + settings: [ + { key: 'beamColor', label: 'admin.backgrounds.beamColor', type: 'color', default: '#6366f1' }, + { + key: 'explosionColor', + label: 'admin.backgrounds.explosionColor', + type: 'color', + default: '#a855f7', + }, + ], }, { type: 'gradient-animation', @@ -242,6 +289,12 @@ export const backgroundRegistry: BackgroundDefinition[] = [ { key: 'thirdColor', label: 'admin.backgrounds.color3', type: 'color', default: '#64DCFF' }, { key: 'fourthColor', label: 'admin.backgrounds.color4', type: 'color', default: '#C83232' }, { key: 'fifthColor', label: 'admin.backgrounds.color5', type: 'color', default: '#B4B432' }, + { + key: 'pointerColor', + label: 'admin.backgrounds.pointerColor', + type: 'color', + default: '#8C64FF', + }, { key: 'interactive', label: 'admin.backgrounds.interactive', @@ -310,6 +363,11 @@ export const backgroundRegistry: BackgroundDefinition[] = [ type: 'color', default: '#000000', }, + { key: 'waveColor1', label: 'admin.backgrounds.color1', type: 'color', default: '#38bdf8' }, + { key: 'waveColor2', label: 'admin.backgrounds.color2', type: 'color', default: '#818cf8' }, + { key: 'waveColor3', label: 'admin.backgrounds.color3', type: 'color', default: '#c084fc' }, + { key: 'waveColor4', label: 'admin.backgrounds.color4', type: 'color', default: '#e879f9' }, + { key: 'waveColor5', label: 'admin.backgrounds.color5', type: 'color', default: '#22d3ee' }, ], }, { @@ -378,6 +436,18 @@ export const backgroundRegistry: BackgroundDefinition[] = [ default: 12, }, { key: 'boxColor', label: 'admin.backgrounds.fillColor', type: 'color', default: '#818cf8' }, + { + key: 'multicolor', + label: 'admin.backgrounds.multicolor', + type: 'boolean', + default: true, + }, + { + key: 'lineColor', + label: 'admin.backgrounds.lineColor', + type: 'color', + default: '#334155', + }, ], }, { @@ -520,4 +590,206 @@ export const backgroundRegistry: BackgroundDefinition[] = [ }, ], }, + { + type: 'fireflies', + labelKey: 'admin.backgrounds.fireflies', + descriptionKey: 'admin.backgrounds.firefliesDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#ffd166' }, + { + key: 'count', + label: 'admin.backgrounds.count', + type: 'number', + min: 5, + max: 200, + step: 5, + default: 40, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 3, + step: 0.1, + default: 1, + }, + { + key: 'size', + label: 'admin.backgrounds.size', + type: 'number', + min: 0.5, + max: 6, + step: 0.5, + default: 2, + }, + ], + }, + { + type: 'snowfall', + labelKey: 'admin.backgrounds.snowfall', + descriptionKey: 'admin.backgrounds.snowfallDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#ffffff' }, + { + key: 'density', + label: 'admin.backgrounds.density', + type: 'number', + min: 20, + max: 400, + step: 10, + default: 150, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 3, + step: 0.1, + default: 1, + }, + { + key: 'wind', + label: 'admin.backgrounds.wind', + type: 'number', + min: -3, + max: 3, + step: 0.5, + default: 0.5, + }, + ], + }, + { + type: 'starfield', + labelKey: 'admin.backgrounds.starfield', + descriptionKey: 'admin.backgrounds.starfieldDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.starColor', type: 'color', default: '#ffffff' }, + { + key: 'starCount', + label: 'admin.backgrounds.particles', + type: 'number', + min: 50, + max: 800, + step: 25, + default: 200, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.1, + max: 5, + step: 0.1, + default: 1, + }, + ], + }, + { + type: 'matrix-rain', + labelKey: 'admin.backgrounds.matrixRain', + descriptionKey: 'admin.backgrounds.matrixRainDesc', + category: 'canvas', + settings: [ + { key: 'color', label: 'admin.backgrounds.particleColor', type: 'color', default: '#00ff41' }, + { + key: 'density', + label: 'admin.backgrounds.density', + type: 'number', + min: 10, + max: 100, + step: 5, + default: 70, + }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'number', + min: 0.2, + max: 3, + step: 0.1, + default: 1, + }, + { + key: 'charset', + label: 'admin.backgrounds.charset', + type: 'select', + default: 'katakana', + options: [ + { label: 'Katakana', value: 'katakana' }, + { label: 'Latin', value: 'latin' }, + { label: 'Binary', value: 'binary' }, + ], + }, + ], + }, + { + type: 'liquid-gradient', + labelKey: 'admin.backgrounds.liquidGradient', + descriptionKey: 'admin.backgrounds.liquidGradientDesc', + category: 'css', + settings: [ + { key: 'color1', label: 'admin.backgrounds.color1', type: 'color', default: '#6366f1' }, + { key: 'color2', label: 'admin.backgrounds.color2', type: 'color', default: '#ec4899' }, + { key: 'color3', label: 'admin.backgrounds.color3', type: 'color', default: '#22d3ee' }, + { key: 'color4', label: 'admin.backgrounds.color4', type: 'color', default: '#a855f7' }, + { + key: 'speed', + label: 'admin.backgrounds.speed', + type: 'select', + default: 'normal', + options: [ + { label: 'admin.backgrounds.slow', value: 'slow' }, + { label: 'admin.backgrounds.normal', value: 'normal' }, + { label: 'admin.backgrounds.fast', value: 'fast' }, + ], + }, + { + key: 'blurAmount', + label: 'admin.backgrounds.blurAmount', + type: 'number', + min: 10, + max: 120, + step: 5, + default: 60, + }, + ], + }, + { + type: 'constellation', + labelKey: 'admin.backgrounds.constellation', + descriptionKey: 'admin.backgrounds.constellationDesc', + category: 'canvas', + settings: [ + { + key: 'particleColor', + label: 'admin.backgrounds.particleColor', + type: 'color', + default: '#818cf8', + }, + { key: 'lineColor', label: 'admin.backgrounds.lineColor', type: 'color', default: '#818cf8' }, + { + key: 'count', + label: 'admin.backgrounds.count', + type: 'number', + min: 10, + max: 200, + step: 5, + default: 60, + }, + { + key: 'linkDistance', + label: 'admin.backgrounds.linkDistance', + type: 'number', + min: 40, + max: 300, + step: 10, + default: 120, + }, + ], + }, ]; diff --git a/src/components/ui/backgrounds/shooting-stars.tsx b/src/components/ui/backgrounds/shooting-stars.tsx index cf94944..e4f1a6a 100644 --- a/src/components/ui/backgrounds/shooting-stars.tsx +++ b/src/components/ui/backgrounds/shooting-stars.tsx @@ -41,6 +41,7 @@ export default function ShootingStarsBackground({ settings }: Props) { const starColor = sanitizeColor(settings.starColor, '#9E00FF'); const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF'); + const bgStarColor = sanitizeColor(settings.bgStarColor, '#ffffff'); const starDensity = clampNumber(settings.starDensity, 0.00001, 0.001, 0.00015); const minSpeed = clampNumber(settings.minSpeed, 1, 50, 10); const maxSpeed = clampNumber(settings.maxSpeed, 5, 100, 30); @@ -117,9 +118,11 @@ export default function ShootingStarsBackground({ settings }: Props) { } ctx.beginPath(); ctx.arc(s.x, s.y, s.radius, 0, Math.PI * 2); - ctx.fillStyle = `rgba(255,255,255,${opacity})`; + ctx.globalAlpha = opacity; + ctx.fillStyle = bgStarColor; ctx.fill(); } + ctx.globalAlpha = 1; if (time - state.lastShootingTime > state.nextShootingDelay) { state.shootingStars.push({ @@ -166,7 +169,7 @@ export default function ShootingStarsBackground({ settings }: Props) { return true; }); }, - [starColor, trailColor, starDensity, minSpeed, maxSpeed], + [starColor, trailColor, bgStarColor, starDensity, minSpeed, maxSpeed], ); return ; diff --git a/src/components/ui/backgrounds/snowfall.tsx b/src/components/ui/backgrounds/snowfall.tsx new file mode 100644 index 0000000..a4a476a --- /dev/null +++ b/src/components/ui/backgrounds/snowfall.tsx @@ -0,0 +1,114 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +interface Flake { + x: number; + y: number; + radius: number; + fallSpeed: number; + phase: number; + opacity: number; +} + +interface SnowfallState { + ctx: CanvasRenderingContext2D; + flakes: Flake[]; + w: number; + h: number; + dpr: number; +} + +export default function SnowfallBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#ffffff'); + const density = clampNumber(settings.density, 20, 400, 150); + const speed = clampNumber(settings.speed, 0.1, 3, 1); + const wind = clampNumber(settings.wind, -3, 3, 0.5); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const flakes: Flake[] = Array.from({ length: Math.floor(density) }, () => ({ + x: Math.random() * w, + y: Math.random() * h, + radius: 0.8 + Math.random() * 2.2, + fallSpeed: 0.4 + Math.random() * 1.2, + phase: Math.random() * Math.PI * 2, + opacity: 0.3 + Math.random() * 0.7, + })); + + stateRef.current = { ctx, flakes, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [density]); + + useAnimationLoop( + (time) => { + const state = stateRef.current; + if (!state) return; + + const { ctx, flakes, w, h } = state; + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = color; + + for (const f of flakes) { + f.y += f.fallSpeed * speed; + f.x += wind * f.fallSpeed * 0.6 + Math.sin(time / 1800 + f.phase) * 0.4; + + if (f.y > h + 5) { + f.y = -5; + f.x = Math.random() * w; + } + if (f.x > w + 5) f.x = -5; + if (f.x < -5) f.x = w + 5; + + ctx.globalAlpha = f.opacity; + ctx.beginPath(); + ctx.arc(f.x, f.y, f.radius, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, + [color, density, speed, wind], + ); + + return ; +} diff --git a/src/components/ui/backgrounds/starfield.tsx b/src/components/ui/backgrounds/starfield.tsx new file mode 100644 index 0000000..d161402 --- /dev/null +++ b/src/components/ui/backgrounds/starfield.tsx @@ -0,0 +1,123 @@ +import { useEffect, useRef } from 'react'; +import { sanitizeColor, clampNumber } from './types'; +import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop'; + +interface Props { + settings: Record; +} + +const MAX_DEPTH = 1000; + +interface Star { + x: number; + y: number; + z: number; +} + +interface StarfieldState { + ctx: CanvasRenderingContext2D; + stars: Star[]; + w: number; + h: number; + dpr: number; +} + +function spawnStar(w: number, h: number, randomDepth: boolean): Star { + return { + x: (Math.random() - 0.5) * w * 2, + y: (Math.random() - 0.5) * h * 2, + z: randomDepth ? Math.random() * MAX_DEPTH : MAX_DEPTH, + }; +} + +export default function StarfieldBackground({ settings }: Props) { + const canvasRef = useRef(null); + const stateRef = useRef(null); + + const color = sanitizeColor(settings.color, '#ffffff'); + const starCount = clampNumber(settings.starCount, 50, 800, 200); + const speed = clampNumber(settings.speed, 0.1, 5, 1); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const dpr = getMobileDpr(); + const parent = canvas.parentElement; + const w = parent?.offsetWidth ?? window.innerWidth; + const h = parent?.offsetHeight ?? window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const stars = Array.from({ length: Math.floor(starCount) }, () => spawnStar(w, h, true)); + + stateRef.current = { ctx, stars, w, h, dpr }; + + const onResize = () => { + const nw = parent?.offsetWidth ?? window.innerWidth; + const nh = parent?.offsetHeight ?? window.innerHeight; + canvas.width = nw * dpr; + canvas.height = nh * dpr; + canvas.style.width = `${nw}px`; + canvas.style.height = `${nh}px`; + if (stateRef.current) { + stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + stateRef.current.w = nw; + stateRef.current.h = nh; + } + }; + + window.addEventListener('resize', onResize); + return () => window.removeEventListener('resize', onResize); + }, [starCount]); + + useAnimationLoop(() => { + const state = stateRef.current; + if (!state) return; + + const { ctx, stars, w, h } = state; + const cx = w / 2; + const cy = h / 2; + const fov = Math.min(w, h); + + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = color; + + for (let i = 0; i < stars.length; i++) { + const s = stars[i]; + s.z -= speed * 4; + + if (s.z <= 1) { + stars[i] = spawnStar(w, h, false); + continue; + } + + const k = fov / s.z; + const sx = cx + s.x * k; + const sy = cy + s.y * k; + + if (sx < 0 || sx > w || sy < 0 || sy > h) { + stars[i] = spawnStar(w, h, false); + continue; + } + + const depth = 1 - s.z / MAX_DEPTH; + const radius = Math.max(0.3, depth * 2.2); + + ctx.globalAlpha = 0.2 + depth * 0.8; + ctx.beginPath(); + ctx.arc(sx, sy, radius, 0, Math.PI * 2); + ctx.fill(); + } + + ctx.globalAlpha = 1; + }, [color, starCount, speed]); + + return ; +} diff --git a/src/components/ui/backgrounds/types.ts b/src/components/ui/backgrounds/types.ts index d4ddc3b..fa88a78 100644 --- a/src/components/ui/backgrounds/types.ts +++ b/src/components/ui/backgrounds/types.ts @@ -14,6 +14,12 @@ export type BackgroundType = | 'dots' | 'spotlight' | 'ripple' + | 'fireflies' + | 'snowfall' + | 'starfield' + | 'matrix-rain' + | 'liquid-gradient' + | 'constellation' | 'none'; export interface AnimationConfig { diff --git a/src/components/ui/backgrounds/wavy-background.tsx b/src/components/ui/backgrounds/wavy-background.tsx index 9014bf6..880207c 100644 --- a/src/components/ui/backgrounds/wavy-background.tsx +++ b/src/components/ui/backgrounds/wavy-background.tsx @@ -27,7 +27,13 @@ export default function WavyBackground({ settings }: Props) { const blur = clampNumber(settings.blur, 0, 50, 10); const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5); const backgroundFill = sanitizeColor(settings.backgroundFill, '#000000'); - const colors = ['#38bdf8', '#818cf8', '#c084fc', '#e879f9', '#22d3ee']; + const colors = [ + sanitizeColor(settings.waveColor1, '#38bdf8'), + sanitizeColor(settings.waveColor2, '#818cf8'), + sanitizeColor(settings.waveColor3, '#c084fc'), + sanitizeColor(settings.waveColor4, '#e879f9'), + sanitizeColor(settings.waveColor5, '#22d3ee'), + ]; useEffect(() => { const canvas = canvasRef.current; @@ -100,7 +106,7 @@ export default function WavyBackground({ settings }: Props) { ctx.stroke(); ctx.closePath(); } - }, [speed, waveWidth, blur, waveOpacity, backgroundFill]); + }, [speed, waveWidth, blur, waveOpacity, backgroundFill, ...colors]); return ; } diff --git a/src/locales/en.json b/src/locales/en.json index 63eeeb0..3646c83 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1153,6 +1153,18 @@ "noiseDesc": "Noise gradients with motion", "ripple": "Ripple", "rippleDesc": "Ripple wave effect", + "fireflies": "Fireflies", + "firefliesDesc": "Drifting glowing dots with pulsation", + "snowfall": "Snowfall", + "snowfallDesc": "Falling snow with wind drift", + "starfield": "Starfield", + "starfieldDesc": "Stars flying toward the viewer", + "matrixRain": "Matrix Rain", + "matrixRainDesc": "Falling columns of glyphs", + "liquidGradient": "Liquid Gradient", + "liquidGradientDesc": "Blurred mesh gradient blobs", + "constellation": "Constellation", + "constellationDesc": "Particles linked by lines", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG effect like Google Gemini", "speed": "Speed", @@ -1168,6 +1180,15 @@ "particleColor": "Particle Color", "starColor": "Star Color", "trailColor": "Trail Color", + "bgStarColor": "Background Stars Color", + "pointerColor": "Pointer Color", + "beamColor": "Beam Color", + "explosionColor": "Explosion Color", + "multicolor": "Multicolor", + "lineColor": "Line Color", + "wind": "Wind", + "charset": "Charset", + "linkDistance": "Link Distance", "bgColor": "Background Color", "fillColor": "Fill Color", "color1": "Color 1", @@ -1236,7 +1257,8 @@ "referralNetwork": "Referral Network", "news": "News", "bulkActions": "Bulk Actions", - "infoPages": "Info Pages" + "infoPages": "Info Pages", + "legalPages": "System pages" }, "panel": { "title": "Admin Panel", @@ -1607,6 +1629,13 @@ "subOptions": "Payment options", "minAmount": "Min amount (kopeks)", "maxAmount": "Max amount (kopeks)", + "quickAmounts": "Quick amounts (₽)", + "quickAmountsHint": "Quick top-up amount buttons. Empty — defaults are used: {{defaults}} ₽", + "quickAmountsPlaceholder": "Amount in rubles", + "quickAmountsAdd": "Add", + "quickAmountsInvalid": "Enter a positive amount in rubles", + "quickAmountsLimit": "No more than 10 amounts", + "quickAmountsRemove": "Remove amount {{value}} ₽", "conditions": "Display conditions", "userTypeFilter": "User type", "userTypeAll": "All", @@ -4076,7 +4105,8 @@ "sortOrder": "Sort Order", "icon": "Icon (emoji)", "pageType": "Page Type", - "replacesTab": "Replaces Tab" + "replacesTab": "Replaces Tab", + "displayMode": "Visibility" }, "replacesTabNone": "None", "replacesTabOptions": { @@ -4085,6 +4115,11 @@ "privacy": "Privacy", "offer": "Offer" }, + "displayModes": { + "bot": "Bot only", + "web": "Web only", + "both": "Bot and web" + }, "replacesTabConflict": "already assigned", "replacesTabWarning": "Another page already replaces this tab. It will be unassigned on save.", "filter": { @@ -4119,6 +4154,43 @@ "zh": "Chinese", "fa": "Persian" } + }, + "legalPages": { + "title": "System pages", + "subtitle": "Rules, privacy policy, public offer and FAQ", + "open": "System pages", + "tabs": { + "privacy": "Privacy", + "offer": "Offer", + "rules": "Rules", + "faq": "FAQ" + }, + "displayMode": "Visibility", + "displayModes": { + "bot": "Bot only", + "web": "Web only", + "both": "Bot and web" + }, + "displayModeLocked": "Set via environment variable", + "enabled": "Visible to users", + "language": "Content language", + "content": "Content", + "contentPlaceholder": "HTML page content...", + "save": "Save", + "saving": "Saving...", + "saveError": "Failed to save. Please try again.", + "unsavedWarning": "Unsaved changes will be lost. Continue?", + "faqPages": "Questions and answers", + "addQuestion": "Add question", + "newQuestion": "New question", + "questionTitle": "Question", + "questionContent": "Answer (HTML supported)", + "confirmDeleteQuestion": "Delete this question?", + "noQuestions": "No questions yet", + "active": "Active", + "moveUp": "Move up", + "moveDown": "Move down", + "delete": "Delete" } }, "adminUpdates": { diff --git a/src/locales/fa.json b/src/locales/fa.json index c9d4d59..a62e7f1 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1035,6 +1035,18 @@ "noiseDesc": "گرادیان‌های نویزی با حرکت", "ripple": "موج", "rippleDesc": "اثر موج دایره‌ای", + "fireflies": "کرم‌های شب‌تاب", + "firefliesDesc": "نقاط درخشان شناور با تپش", + "snowfall": "بارش برف", + "snowfallDesc": "برف در حال بارش با وزش باد", + "starfield": "میدان ستارگان", + "starfieldDesc": "پرواز در میان ستاره‌ها", + "matrixRain": "باران ماتریکس", + "matrixRainDesc": "ستون‌های نمادهای در حال سقوط", + "liquidGradient": "گرادیان مایع", + "liquidGradientDesc": "حباب‌های گرادیان مات و روان", + "constellation": "صورت فلکی", + "constellationDesc": "ذرات متصل با خطوط", "geminiEffect": "افکت Gemini", "geminiEffectDesc": "افکت SVG مشابه Google Gemini", "speed": "سرعت", @@ -1050,6 +1062,15 @@ "particleColor": "رنگ ذرات", "starColor": "رنگ ستاره", "trailColor": "رنگ دنباله", + "bgStarColor": "رنگ ستاره‌های پس‌زمینه", + "pointerColor": "رنگ اشاره‌گر", + "beamColor": "رنگ پرتو", + "explosionColor": "رنگ انفجار", + "multicolor": "حالت چندرنگ", + "lineColor": "رنگ خطوط", + "wind": "باد", + "charset": "مجموعه نویسه‌ها", + "linkDistance": "فاصله اتصال", "bgColor": "رنگ پس‌زمینه", "fillColor": "رنگ پر کردن", "color1": "رنگ ۱", @@ -1109,7 +1130,8 @@ "landings": "صفحات فرود", "referralNetwork": "شبکه ارجاع", "news": "اخبار", - "infoPages": "صفحات اطلاعات" + "infoPages": "صفحات اطلاعات", + "legalPages": "صفحات سیستمی" }, "panel": { "title": "پنل مدیریت", @@ -2283,7 +2305,14 @@ "noPromoGroups": "هیچ گروه تبلیغاتی نیست", "cancelButton": "لغو", "saveButton": "ذخیره", - "notFound": "روش پرداخت یافت نشد" + "notFound": "روش پرداخت یافت نشد", + "quickAmounts": "مبالغ سریع (₽)", + "quickAmountsHint": "دکمه‌های مبلغ شارژ سریع. خالی — مقادیر پیش‌فرض استفاده می‌شوند: {{defaults}} ₽", + "quickAmountsPlaceholder": "مبلغ به روبل", + "quickAmountsAdd": "افزودن", + "quickAmountsInvalid": "یک مبلغ مثبت به روبل وارد کنید", + "quickAmountsLimit": "حداکثر ۱۰ مبلغ مجاز است", + "quickAmountsRemove": "حذف مبلغ {{value}} ₽" }, "campaigns": { "title": "کمپین‌های تبلیغاتی", @@ -3792,7 +3821,8 @@ "sortOrder": "ترتیب", "icon": "آیکون (ایموجی)", "pageType": "نوع صفحه", - "replacesTab": "جایگزینی تب" + "replacesTab": "جایگزینی تب", + "displayMode": "نمایش" }, "replacesTabNone": "بدون جایگزینی", "replacesTabOptions": { @@ -3834,6 +3864,11 @@ "en": "انگلیسی", "zh": "چینی", "fa": "فارسی" + }, + "displayModes": { + "bot": "فقط ربات", + "web": "فقط وب", + "both": "ربات و وب" } }, "bulkActions": { @@ -3944,6 +3979,43 @@ "trafficGbUnit": "GB", "selectAllSubs": "انتخاب همه اشتراک‌ها", "deselectAllSubs": "لغو انتخاب همه اشتراک‌ها" + }, + "legalPages": { + "title": "صفحات سیستمی", + "subtitle": "قوانین، سیاست حریم خصوصی، پیشنهاد عمومی و FAQ", + "open": "صفحات سیستمی", + "tabs": { + "privacy": "حریم خصوصی", + "offer": "پیشنهاد", + "rules": "قوانین", + "faq": "FAQ" + }, + "displayMode": "نمایش", + "displayModes": { + "bot": "فقط ربات", + "web": "فقط وب", + "both": "ربات و وب" + }, + "displayModeLocked": "از طریق متغیر محیطی تنظیم شده", + "enabled": "نمایش به کاربران", + "language": "زبان محتوا", + "content": "متن", + "contentPlaceholder": "متن HTML صفحه...", + "save": "ذخیره", + "saving": "در حال ذخیره...", + "saveError": "ذخیره ناموفق بود. لطفاً دوباره تلاش کنید.", + "unsavedWarning": "تغییرات ذخیره‌نشده از بین می‌رود. ادامه می‌دهید؟", + "faqPages": "سوالات و پاسخ‌ها", + "addQuestion": "افزودن سوال", + "newQuestion": "سوال جدید", + "questionTitle": "سوال", + "questionContent": "پاسخ (HTML پشتیبانی می‌شود)", + "confirmDeleteQuestion": "این سوال حذف شود؟", + "noQuestions": "هنوز سوالی وجود ندارد", + "active": "فعال", + "moveUp": "انتقال به بالا", + "moveDown": "انتقال به پایین", + "delete": "حذف" } }, "adminUpdates": { diff --git a/src/locales/ru.json b/src/locales/ru.json index caec2c7..dcf9302 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1183,6 +1183,18 @@ "noiseDesc": "Шумовые градиенты с движением", "ripple": "Ripple", "rippleDesc": "Волновой эффект при клике", + "fireflies": "Fireflies", + "firefliesDesc": "Дрейфующие светящиеся точки с пульсацией", + "snowfall": "Snowfall", + "snowfallDesc": "Падающий снег с ветром", + "starfield": "Starfield", + "starfieldDesc": "Полёт сквозь звёздное небо", + "matrixRain": "Matrix Rain", + "matrixRainDesc": "Падающие колонки символов", + "liquidGradient": "Liquid Gradient", + "liquidGradientDesc": "Жидкий mesh-градиент из размытых блобов", + "constellation": "Constellation", + "constellationDesc": "Частицы, связанные линиями", "geminiEffect": "Gemini Effect", "geminiEffectDesc": "SVG-эффект как на Google Gemini", "speed": "Скорость", @@ -1198,6 +1210,15 @@ "particleColor": "Цвет частиц", "starColor": "Цвет звёзд", "trailColor": "Цвет следа", + "bgStarColor": "Цвет фоновых звёзд", + "pointerColor": "Цвет курсорного блоба", + "beamColor": "Цвет лучей", + "explosionColor": "Цвет взрыва", + "multicolor": "Разноцветный режим", + "lineColor": "Цвет линий", + "wind": "Ветер", + "charset": "Набор символов", + "linkDistance": "Дистанция связи", "bgColor": "Цвет фона", "fillColor": "Цвет заливки", "color1": "Цвет 1", @@ -1258,7 +1279,8 @@ "referralNetwork": "Реферальная сеть", "news": "Новости", "bulkActions": "Массовые действия", - "infoPages": "Инфо-страницы" + "infoPages": "Инфо-страницы", + "legalPages": "Системные страницы" }, "panel": { "title": "Панель администратора", @@ -1629,6 +1651,13 @@ "subOptions": "Варианты оплаты", "minAmount": "Мин. сумма (коп.)", "maxAmount": "Макс. сумма (коп.)", + "quickAmounts": "Быстрые суммы (₽)", + "quickAmountsHint": "Кнопки быстрого выбора суммы пополнения. Пусто — используются значения по умолчанию: {{defaults}} ₽", + "quickAmountsPlaceholder": "Сумма в рублях", + "quickAmountsAdd": "Добавить", + "quickAmountsInvalid": "Введите положительную сумму в рублях", + "quickAmountsLimit": "Не более 10 сумм", + "quickAmountsRemove": "Удалить сумму {{value}} ₽", "conditions": "Условия отображения", "userTypeFilter": "Тип пользователя", "userTypeAll": "Все", @@ -4621,7 +4650,8 @@ "sortOrder": "Порядок сортировки", "icon": "Иконка (эмодзи)", "pageType": "Тип страницы", - "replacesTab": "Заменяет таб" + "replacesTab": "Заменяет таб", + "displayMode": "Отображение" }, "replacesTabNone": "Не заменяет", "replacesTabOptions": { @@ -4630,6 +4660,11 @@ "privacy": "Конфиденциальность", "offer": "Оферта" }, + "displayModes": { + "bot": "Только бот", + "web": "Только веб", + "both": "Бот и веб" + }, "replacesTabConflict": "уже занят", "replacesTabWarning": "Другая страница уже заменяет этот таб. При сохранении она будет снята.", "filter": { @@ -4664,6 +4699,43 @@ "zh": "Китайский", "fa": "Персидский" } + }, + "legalPages": { + "title": "Системные страницы", + "subtitle": "Правила, политика конфиденциальности, оферта и FAQ", + "open": "Системные страницы", + "tabs": { + "privacy": "Политика конф.", + "offer": "Оферта", + "rules": "Правила", + "faq": "FAQ" + }, + "displayMode": "Отображение", + "displayModes": { + "bot": "Только бот", + "web": "Только веб", + "both": "Бот и веб" + }, + "displayModeLocked": "Задано через переменную окружения", + "enabled": "Показывать пользователям", + "language": "Язык контента", + "content": "Текст", + "contentPlaceholder": "HTML-текст страницы...", + "save": "Сохранить", + "saving": "Сохранение...", + "saveError": "Не удалось сохранить. Попробуйте ещё раз.", + "unsavedWarning": "Несохранённые изменения будут потеряны. Продолжить?", + "faqPages": "Вопросы и ответы", + "addQuestion": "Добавить вопрос", + "newQuestion": "Новый вопрос", + "questionTitle": "Вопрос", + "questionContent": "Ответ (поддерживается HTML)", + "confirmDeleteQuestion": "Удалить этот вопрос?", + "noQuestions": "Вопросов пока нет", + "active": "Активен", + "moveUp": "Переместить вверх", + "moveDown": "Переместить вниз", + "delete": "Удалить" } }, "adminUpdates": { diff --git a/src/locales/zh.json b/src/locales/zh.json index f78ac6c..f35e9f2 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1035,6 +1035,18 @@ "noiseDesc": "带运动的噪声渐变", "ripple": "涟漪", "rippleDesc": "涟漪波纹效果", + "fireflies": "萤火虫", + "firefliesDesc": "漂浮闪烁的发光点", + "snowfall": "降雪", + "snowfallDesc": "随风飘落的雪花", + "starfield": "星空", + "starfieldDesc": "向观察者飞来的星星", + "matrixRain": "黑客帝国雨", + "matrixRainDesc": "下落的字符列", + "liquidGradient": "流体渐变", + "liquidGradientDesc": "模糊的网格渐变色块", + "constellation": "星座", + "constellationDesc": "由线条连接的粒子", "geminiEffect": "Gemini效果", "geminiEffectDesc": "类似Google Gemini的SVG效果", "speed": "速度", @@ -1050,6 +1062,15 @@ "particleColor": "粒子颜色", "starColor": "星星颜色", "trailColor": "尾迹颜色", + "bgStarColor": "背景星星颜色", + "pointerColor": "指针颜色", + "beamColor": "光束颜色", + "explosionColor": "爆炸颜色", + "multicolor": "多彩模式", + "lineColor": "线条颜色", + "wind": "风力", + "charset": "字符集", + "linkDistance": "连接距离", "bgColor": "背景颜色", "fillColor": "填充颜色", "color1": "颜色1", @@ -1109,7 +1130,8 @@ "landings": "落地页", "referralNetwork": "推荐网络", "news": "新闻", - "infoPages": "信息页面" + "infoPages": "信息页面", + "legalPages": "系统页面" }, "panel": { "title": "管理面板", @@ -1435,7 +1457,14 @@ "noPromoGroups": "没有促销组", "cancelButton": "取消", "saveButton": "保存", - "notFound": "未找到支付方式" + "notFound": "未找到支付方式", + "quickAmounts": "快捷金额(₽)", + "quickAmountsHint": "快捷充值金额按钮。留空 — 使用默认值:{{defaults}} ₽", + "quickAmountsPlaceholder": "金额(卢布)", + "quickAmountsAdd": "添加", + "quickAmountsInvalid": "请输入正的卢布金额", + "quickAmountsLimit": "最多 10 个金额", + "quickAmountsRemove": "删除金额 {{value}} ₽" }, "emailTemplates": { "title": "邮件模板", @@ -3791,7 +3820,8 @@ "sortOrder": "排序", "icon": "图标(表情)", "pageType": "页面类型", - "replacesTab": "替换标签" + "replacesTab": "替换标签", + "displayMode": "显示" }, "replacesTabNone": "不替换", "replacesTabOptions": { @@ -3833,6 +3863,11 @@ "en": "英语", "zh": "中文", "fa": "波斯语" + }, + "displayModes": { + "bot": "仅机器人", + "web": "仅网页", + "both": "机器人和网页" } }, "bulkActions": { @@ -3943,6 +3978,43 @@ "trafficGbUnit": "GB", "selectAllSubs": "选择所有订阅", "deselectAllSubs": "取消选择所有订阅" + }, + "legalPages": { + "title": "系统页面", + "subtitle": "规则、隐私政策、公开要约和 FAQ", + "open": "系统页面", + "tabs": { + "privacy": "隐私", + "offer": "条款", + "rules": "规则", + "faq": "FAQ" + }, + "displayMode": "显示", + "displayModes": { + "bot": "仅机器人", + "web": "仅网页", + "both": "机器人和网页" + }, + "displayModeLocked": "通过环境变量设置", + "enabled": "对用户可见", + "language": "内容语言", + "content": "内容", + "contentPlaceholder": "HTML 页面内容...", + "save": "保存", + "saving": "保存中...", + "saveError": "保存失败,请重试。", + "unsavedWarning": "未保存的更改将丢失。是否继续?", + "faqPages": "问答", + "addQuestion": "添加问题", + "newQuestion": "新问题", + "questionTitle": "问题", + "questionContent": "答案(支持 HTML)", + "confirmDeleteQuestion": "确定删除此问题?", + "noQuestions": "暂无问题", + "active": "已激活", + "moveUp": "上移", + "moveDown": "下移", + "delete": "删除" } }, "adminUpdates": { diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx index 4fcc4f6..d69b979 100644 --- a/src/pages/AdminInfoPageEditor.tsx +++ b/src/pages/AdminInfoPageEditor.tsx @@ -37,7 +37,7 @@ import { TrashSmallIcon, PlusSmallIcon, } from '@/components/icons'; -import type { InfoPageType, FaqItem, ReplacesTab } from '../api/infoPages'; +import type { InfoPageType, FaqItem, ReplacesTab, InfoPageDisplayMode } from '../api/infoPages'; const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const; type LocaleCode = (typeof AVAILABLE_LOCALES)[number]; @@ -643,6 +643,7 @@ export default function AdminInfoPageEditor() { const [sortOrder, setSortOrder] = useState(0); const [pageType, setPageType] = useState(initialPageType); const [replacesTab, setReplacesTab] = useState(null); + const [displayMode, setDisplayMode] = useState('both'); const [saveError, setSaveError] = useState(null); // FAQ Q&A state per locale @@ -857,6 +858,7 @@ export default function AdminInfoPageEditor() { setSortOrder(pageData.sort_order); setPageType(pageData.page_type ?? 'page'); setReplacesTab(pageData.replaces_tab ?? null); + setDisplayMode(pageData.display_mode ?? 'both'); setTitles(pageData.title); if (pageData.page_type === 'faq') { @@ -927,6 +929,7 @@ export default function AdminInfoPageEditor() { sort_order: number; icon: string | null; replaces_tab: ReplacesTab | null; + display_mode: InfoPageDisplayMode; }) => { if (isEdit && pageId != null) { return infoPagesApi.updatePage(pageId, data); @@ -976,6 +979,7 @@ export default function AdminInfoPageEditor() { sort_order: sortOrder, icon: icon.trim() || null, replaces_tab: replacesTab, + display_mode: displayMode, }; haptic.buttonPress(); @@ -1089,6 +1093,31 @@ export default function AdminInfoPageEditor() { {t('admin.infoPages.fields.isActive')}
+
+ +
+ {(['bot', 'web', 'both'] as const).map((mode) => ( + + ))} +
+
+ {/* Page type selector */}
+ + ))} +
+ {disabled && ( +

{t('admin.legalPages.displayModeLocked')}

+ )} +
+ ); +} + +function LanguageTabs({ + languages, + active, + onChange, +}: { + languages: string[]; + active: string; + onChange: (lang: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+ +
+ {languages.map((lang) => ( + + ))} +
+
+ ); +} + +function DocumentEditor({ + kind, + onDirtyChange, +}: { + kind: 'privacy-policy' | 'public-offer'; + onDirtyChange: (dirty: boolean) => void; +}) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const haptic = useHapticFeedback(); + const [displayMode, setDisplayMode] = useState('both'); + const [contents, setContents] = useState>({}); + const [enabled, setEnabled] = useState>({}); + const [activeLang, setActiveLang] = useState('ru'); + const [populated, setPopulated] = useState(false); + const [saveError, setSaveError] = useState(null); + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['admin', 'legal-pages', kind], + queryFn: () => + kind === 'privacy-policy' + ? adminLegalPagesApi.getPrivacyPolicy() + : adminLegalPagesApi.getPublicOffer(), + staleTime: 0, + gcTime: 0, + }); + + useEffect(() => { + if (!data || isFetching || populated) return; + setDisplayMode(data.display_mode); + const nextContents: Record = {}; + const nextEnabled: Record = {}; + for (const item of data.items) { + nextContents[item.language] = item.content; + nextEnabled[item.language] = item.is_enabled; + } + setContents(nextContents); + setEnabled(nextEnabled); + if (data.items.length > 0 && !data.items.some((item) => item.language === 'ru')) { + setActiveLang(data.items[0].language); + } + setPopulated(true); + }, [data, isFetching, populated]); + + const isDirty = + populated && + !!data && + (displayMode !== data.display_mode || + data.items.some( + (item) => + (contents[item.language] ?? '') !== item.content || + (enabled[item.language] ?? false) !== item.is_enabled, + )); + + useEffect(() => { + onDirtyChange(isDirty); + }, [isDirty, onDirtyChange]); + + const saveMutation = useMutation({ + mutationFn: () => { + const payload = { + ...(data?.display_mode_env_locked ? {} : { display_mode: displayMode }), + items: Object.keys(contents).map((language) => ({ + language, + content: contents[language] ?? '', + is_enabled: enabled[language] ?? false, + })), + }; + return kind === 'privacy-policy' + ? adminLegalPagesApi.updatePrivacyPolicy(payload) + : adminLegalPagesApi.updatePublicOffer(payload); + }, + onSuccess: () => { + haptic.success(); + setSaveError(null); + queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', kind] }); + }, + onError: (err) => { + haptic.error(); + setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError')); + }, + }); + + if (isLoading || !data) { + return
; + } + + const languages = data.items.map((item) => item.language); + + return ( +
+ + +
+ setEnabled((prev) => ({ ...prev, [activeLang]: !prev[activeLang] }))} + aria-label={t('admin.legalPages.enabled')} + /> + {t('admin.legalPages.enabled')} +
+
+ +