Merge pull request #456 from LLC-INCY/feature/pages-and-backgrounds

feat: per-method quick amounts, system pages admin, animated backgrounds overhaul
This commit is contained in:
c0mrade
2026-06-11 13:33:02 +03:00
committed by GitHub
34 changed files with 2433 additions and 73 deletions

View File

@@ -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() {
</PermissionRoute>
}
/>
<Route
path="/admin/legal-pages"
element={
<PermissionRoute permission="info_pages:read">
<LazyPage>
<AdminLegalPages />
</LazyPage>
</PermissionRoute>
}
/>
<Route
path="/admin/audit-log"

156
src/api/adminLegalPages.ts Normal file
View File

@@ -0,0 +1,156 @@
import apiClient from './client';
import type { InfoPageDisplayMode } from './infoPages';
export type LegalDisplayMode = InfoPageDisplayMode;
export interface LegalDocumentItem {
language: string;
content: string;
is_enabled: boolean;
updated_at: string | null;
}
export interface LegalDocumentResponse {
display_mode: LegalDisplayMode;
display_mode_env_locked: boolean;
items: LegalDocumentItem[];
}
export interface LegalDocumentUpdateRequest {
display_mode?: LegalDisplayMode;
items?: Array<{ language: string; content: string; is_enabled: boolean }>;
}
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<LegalDocumentResponse> => {
const response = await apiClient.get<LegalDocumentResponse>(
'/cabinet/admin/legal-pages/privacy-policy',
);
return response.data;
},
updatePrivacyPolicy: async (data: LegalDocumentUpdateRequest): Promise<LegalDocumentResponse> => {
const response = await apiClient.put<LegalDocumentResponse>(
'/cabinet/admin/legal-pages/privacy-policy',
data,
);
return response.data;
},
getPublicOffer: async (): Promise<LegalDocumentResponse> => {
const response = await apiClient.get<LegalDocumentResponse>(
'/cabinet/admin/legal-pages/public-offer',
);
return response.data;
},
updatePublicOffer: async (data: LegalDocumentUpdateRequest): Promise<LegalDocumentResponse> => {
const response = await apiClient.put<LegalDocumentResponse>(
'/cabinet/admin/legal-pages/public-offer',
data,
);
return response.data;
},
getRules: async (): Promise<AdminRulesResponse> => {
const response = await apiClient.get<AdminRulesResponse>('/cabinet/admin/legal-pages/rules');
return response.data;
},
updateRules: async (data: RulesUpdateRequest): Promise<AdminRulesResponse> => {
const response = await apiClient.put<AdminRulesResponse>(
'/cabinet/admin/legal-pages/rules',
data,
);
return response.data;
},
getFaq: async (): Promise<FaqResponse> => {
const response = await apiClient.get<FaqResponse>('/cabinet/admin/legal-pages/faq');
return response.data;
},
updateFaq: async (data: FaqUpdateRequest): Promise<FaqResponse> => {
const response = await apiClient.put<FaqResponse>('/cabinet/admin/legal-pages/faq', data);
return response.data;
},
createFaqPage: async (data: FaqPageCreateRequest): Promise<FaqPageItem> => {
const response = await apiClient.post<FaqPageItem>(
'/cabinet/admin/legal-pages/faq/pages',
data,
);
return response.data;
},
updateFaqPage: async (id: number, data: FaqPageUpdateRequest): Promise<FaqPageItem> => {
const response = await apiClient.put<FaqPageItem>(
`/cabinet/admin/legal-pages/faq/pages/${id}`,
data,
);
return response.data;
},
deleteFaqPage: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/legal-pages/faq/pages/${id}`);
},
};

View File

@@ -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<FaqPage[]> => {
@@ -99,4 +106,9 @@ export const infoApi = {
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config');
return response.data;
},
getVisibility: async (): Promise<InfoVisibility> => {
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
return response.data;
},
};

View File

@@ -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<ReplacesTab, string | null>;

View File

@@ -20,6 +20,10 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
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.density === 'number')
reduced.density = Math.max(20, Math.floor(reduced.density / 2));
if (typeof reduced.starCount === 'number')
reduced.starCount = Math.max(50, Math.floor(reduced.starCount / 4));
if (typeof reduced.number === 'number')
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
if ('interactive' in reduced) reduced.interactive = false;

View File

@@ -1,5 +1,5 @@
import { cn } from '@/lib/utils';
import { safeBoolean } from './types';
import { sanitizeColor, safeBoolean, safeSelect } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
@@ -8,7 +8,17 @@ interface Props {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const SPEED_DURATIONS: Record<string, string> = {
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',
}}
/>

View File

@@ -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<HTMLDivElement>) {
function Explosion({
beamColor,
explosionColor,
...props
}: React.HTMLProps<HTMLDivElement> & { 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<HTMLDivElement>) {
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) => (
<motion.span
@@ -48,7 +56,10 @@ function Explosion(props: React.HTMLProps<HTMLDivElement>) {
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})`,
}}
/>
))}
</div>
@@ -59,10 +70,14 @@ function CollisionMechanism({
containerRef,
parentRef,
beamOptions,
beamColor,
explosionColor,
}: {
containerRef: React.RefObject<HTMLDivElement | null>;
parentRef: React.RefObject<HTMLDivElement | null>;
beamOptions: BeamOptions;
beamColor: string;
explosionColor: string;
}) {
const beamRef = useRef<HTMLDivElement>(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)`,
}}
/>
<AnimatePresence>
{collision.detected && collision.coordinates && (
<Explosion
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
beamColor={beamColor}
explosionColor={explosionColor}
style={{
left: `${collision.coordinates.x}px`,
top: `${collision.coordinates.y}px`,
@@ -174,11 +191,14 @@ function CollisionMechanism({
);
}
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
export default function BackgroundBeamsCollision({ settings }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
const paused = useAnimationPause();
const beamColor = sanitizeColor(settings.beamColor, '#6366f1');
const explosionColor = sanitizeColor(settings.explosionColor, '#a855f7');
return (
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
{!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 */}
<div
ref={containerRef}
className="pointer-events-none absolute inset-x-0 bottom-0 w-full"

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { sanitizeColor } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
@@ -99,10 +100,17 @@ function ensureStyles() {
document.head.appendChild(style);
}
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
export default React.memo(function BackgroundBeams({ settings }: Props) {
const paused = useAnimationPause();
const uid = React.useId();
const beamGradientId = `beamGradient-${uid}`;
const radialGradientId = `beamsRadial-${uid}`;
const gradientStart = sanitizeColor(settings.gradientStart, '#18CCFC');
const gradientMid = sanitizeColor(settings.gradientMid, '#6344F5');
const gradientEnd = sanitizeColor(settings.gradientEnd, '#AE48FF');
const staticColor = sanitizeColor(settings.staticColor, '#d4d4d4');
// Inject CSS once on first render
React.useEffect(() => {
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 */}
<path
d={bgPath}
stroke="url(#paint0_radial_beams)"
stroke={`url(#${radialGradientId})`}
strokeOpacity="0.05"
strokeWidth="0.5"
/>
{/* Animated beams — only every 3rd path (17 beams), pure stroke-dashoffset */}
{animatedPaths.map(({ path, paramIndex }) => (
<path
key={paramIndex}
d={path}
stroke="url(#beamGradient)"
stroke={`url(#${beamGradientId})`}
strokeWidth="0.5"
strokeOpacity="0.4"
pathLength={1}
@@ -144,26 +150,24 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
))}
<defs>
{/* Single shared gradient for all beams (cyan → purple → magenta) */}
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop stopColor="#18CCFC" stopOpacity="0" />
<stop offset="10%" stopColor="#18CCFC" />
<stop offset="50%" stopColor="#6344F5" />
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
<linearGradient id={beamGradientId} x1="0%" y1="0%" x2="100%" y2="100%">
<stop stopColor={gradientStart} stopOpacity="0" />
<stop offset="10%" stopColor={gradientStart} />
<stop offset="50%" stopColor={gradientMid} />
<stop offset="100%" stopColor={gradientEnd} stopOpacity="0" />
</linearGradient>
{/* Radial gradient for static background */}
<radialGradient
id="paint0_radial_beams"
id={radialGradientId}
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="#d4d4d4" />
<stop offset="0.243243" stopColor="#d4d4d4" />
<stop offset="0.43594" stopColor="white" stopOpacity="0" />
<stop offset="0.0666667" stopColor={staticColor} />
<stop offset="0.243243" stopColor={staticColor} />
<stop offset="0.43594" stopColor={staticColor} stopOpacity="0" />
</radialGradient>
</defs>
</svg>

View File

@@ -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 (

View File

@@ -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`,
}}
/>
)}

View File

@@ -0,0 +1,146 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLCanvasElement>(null);
const stateRef = useRef<ConstellationState | null>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,122 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLCanvasElement>(null);
const stateRef = useRef<FirefliesState | null>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,59 @@
import { cn } from '@/lib/utils';
import { sanitizeColor, clampNumber, safeSelect } from './types';
import { useAnimationPause } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
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<string, number> = {
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 (
<div className="absolute inset-0 overflow-hidden">
<div className="absolute inset-0" style={{ filter: `blur(${effectiveBlur}px)` }}>
{BLOBS.map((blob, i) => (
<div
key={i}
className={cn('absolute h-[55%] w-[55%] rounded-full', blob.anim)}
style={{
top: blob.top,
left: blob.left,
opacity: 0.7,
background: `radial-gradient(circle at center, ${colors[i]} 0%, transparent 65%)`,
animationDuration: `${blob.baseDuration * multiplier}s`,
animationPlayState: paused ? 'paused' : 'running',
}}
/>
))}
</div>
</div>
);
}

View File

@@ -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<string, unknown>;
}
const FONT_SIZE = 16;
const BASE_STEP_MS = 80;
const CHARSETS: Record<string, string> = {
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<HTMLCanvasElement>(null);
const stateRef = useRef<MatrixState | null>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -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,
}}
>

View File

@@ -18,6 +18,12 @@ const backgroundImports: Record<Exclude<BackgroundType, 'none'>, () => Promise<u
dots: () => 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,
},
],
},
];

View File

@@ -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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLCanvasElement>(null);
const stateRef = useRef<SnowfallState | null>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -0,0 +1,123 @@
import { useEffect, useRef } from 'react';
import { sanitizeColor, clampNumber } from './types';
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
interface Props {
settings: Record<string, unknown>;
}
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<HTMLCanvasElement>(null);
const stateRef = useRef<StarfieldState | null>(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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -14,6 +14,12 @@ export type BackgroundType =
| 'dots'
| 'spotlight'
| 'ripple'
| 'fireflies'
| 'snowfall'
| 'starfield'
| 'matrix-rain'
| 'liquid-gradient'
| 'constellation'
| 'none';
export interface AnimationConfig {

View File

@@ -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 <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
}

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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<InfoPageType>(initialPageType);
const [replacesTab, setReplacesTab] = useState<ReplacesTab | null>(null);
const [displayMode, setDisplayMode] = useState<InfoPageDisplayMode>('both');
const [saveError, setSaveError] = useState<string | null>(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() {
<span className="text-sm text-dark-300">{t('admin.infoPages.fields.isActive')}</span>
</div>
<div>
<label id="ip-displaymode-label" className="label">
{t('admin.infoPages.fields.displayMode')}
</label>
<div className="flex gap-1" role="radiogroup" aria-labelledby="ip-displaymode-label">
{(['bot', 'web', 'both'] as const).map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={displayMode === mode}
onClick={() => setDisplayMode(mode)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
displayMode === mode
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
)}
>
{t(`admin.infoPages.displayModes.${mode}`)}
</button>
))}
</div>
</div>
{/* Page type selector */}
<div>
<label id="ip-pagetype-label" className="label">

View File

@@ -215,6 +215,17 @@ export default function AdminInfoPages() {
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => {
haptic.buttonPress();
navigate('/admin/legal-pages');
}}
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-dark-800 px-4 py-2.5 text-dark-200 transition-colors hover:bg-dark-700"
aria-label={t('admin.legalPages.open')}
>
<FileTextIcon className="h-4 w-4" />
<span className="hidden sm:inline">{t('admin.legalPages.open')}</span>
</button>
<button
onClick={() => refetch()}
className="min-h-[44px] min-w-[44px] rounded-lg bg-dark-800 p-2.5 text-dark-400 transition-colors hover:text-dark-100"

View File

@@ -0,0 +1,691 @@
import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
adminLegalPagesApi,
type FaqPageItem,
type FaqSettingItem,
type LegalDisplayMode,
} from '../api/adminLegalPages';
import { AdminBackButton } from '../components/admin';
import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import { cn } from '../lib/utils';
import { ChevronDownIcon, ChevronUpIcon, PlusIcon, TrashIcon } from '@/components/icons';
type LegalTab = 'privacy' | 'offer' | 'rules' | 'faq';
const DISPLAY_MODES: LegalDisplayMode[] = ['bot', 'web', 'both'];
function extractErrorDetail(err: unknown): string | null {
const error = err as { response?: { data?: { detail?: unknown } } };
const detail = error.response?.data?.detail;
return typeof detail === 'string' ? detail : null;
}
function DisplayModeSelector({
value,
onChange,
disabled,
}: {
value: LegalDisplayMode;
onChange: (mode: LegalDisplayMode) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<div>
<label className="label">{t('admin.legalPages.displayMode')}</label>
<div className="flex gap-1" role="radiogroup">
{DISPLAY_MODES.map((mode) => (
<button
key={mode}
type="button"
role="radio"
aria-checked={value === mode}
disabled={disabled}
onClick={() => onChange(mode)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
value === mode
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
disabled && 'cursor-not-allowed opacity-50',
)}
>
{t(`admin.legalPages.displayModes.${mode}`)}
</button>
))}
</div>
{disabled && (
<p className="mt-1.5 text-xs text-dark-500">{t('admin.legalPages.displayModeLocked')}</p>
)}
</div>
);
}
function LanguageTabs({
languages,
active,
onChange,
}: {
languages: string[];
active: string;
onChange: (lang: string) => void;
}) {
const { t } = useTranslation();
return (
<div>
<label className="label">{t('admin.legalPages.language')}</label>
<div className="flex flex-wrap gap-1">
{languages.map((lang) => (
<button
key={lang}
type="button"
onClick={() => onChange(lang)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium uppercase transition-colors',
active === lang
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
)}
>
{lang}
</button>
))}
</div>
</div>
);
}
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<LegalDisplayMode>('both');
const [contents, setContents] = useState<Record<string, string>>({});
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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<string, string> = {};
const nextEnabled: Record<string, boolean> = {};
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 <div className="skeleton h-64 w-full rounded-xl" />;
}
const languages = data.items.map((item) => item.language);
return (
<div className="space-y-5">
<DisplayModeSelector
value={displayMode}
onChange={setDisplayMode}
disabled={data.display_mode_env_locked}
/>
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} />
<div className="flex items-center gap-3">
<Toggle
checked={enabled[activeLang] ?? false}
onChange={() => setEnabled((prev) => ({ ...prev, [activeLang]: !prev[activeLang] }))}
aria-label={t('admin.legalPages.enabled')}
/>
<span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span>
</div>
<div>
<label className="label">{t('admin.legalPages.content')}</label>
<textarea
value={contents[activeLang] ?? ''}
onChange={(e) => {
setSaveError(null);
setContents((prev) => ({ ...prev, [activeLang]: e.target.value }));
}}
rows={16}
className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')}
/>
</div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
</button>
</div>
);
}
function RulesEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
const [contents, setContents] = useState<Record<string, string>>({});
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', 'rules'],
queryFn: adminLegalPagesApi.getRules,
staleTime: 0,
gcTime: 0,
});
useEffect(() => {
if (!data || isFetching || populated) return;
setDisplayMode(data.display_mode);
const nextContents: Record<string, string> = {};
for (const item of data.items) {
nextContents[item.language] = item.content;
}
setContents(nextContents);
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));
useEffect(() => {
onDirtyChange(isDirty);
}, [isDirty, onDirtyChange]);
const saveMutation = useMutation({
mutationFn: () =>
adminLegalPagesApi.updateRules({
...(data?.display_mode_env_locked ? {} : { display_mode: displayMode }),
items: Object.keys(contents).map((language) => ({
language,
content: contents[language] ?? '',
})),
}),
onSuccess: () => {
haptic.success();
setSaveError(null);
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'rules'] });
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
if (isLoading || !data) {
return <div className="skeleton h-64 w-full rounded-xl" />;
}
const languages = data.items.map((item) => item.language);
return (
<div className="space-y-5">
<DisplayModeSelector
value={displayMode}
onChange={setDisplayMode}
disabled={data.display_mode_env_locked}
/>
<LanguageTabs languages={languages} active={activeLang} onChange={setActiveLang} />
<div>
<label className="label">{t('admin.legalPages.content')}</label>
<textarea
value={contents[activeLang] ?? ''}
onChange={(e) => {
setSaveError(null);
setContents((prev) => ({ ...prev, [activeLang]: e.target.value }));
}}
rows={16}
className="input min-h-[320px] w-full font-mono text-sm"
placeholder={t('admin.legalPages.contentPlaceholder')}
/>
</div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="min-h-[44px] rounded-lg bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
</button>
</div>
);
}
function FaqQuestionRow({
page,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
onDelete,
onSaved,
onDirtyChange,
}: {
page: FaqPageItem;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
onSaved: () => void;
onDirtyChange: (id: number, dirty: boolean) => void;
}) {
const { t } = useTranslation();
const haptic = useHapticFeedback();
const [title, setTitle] = useState(page.title);
const [content, setContent] = useState(page.content);
const [isActive, setIsActive] = useState(page.is_active);
const [saveError, setSaveError] = useState<string | null>(null);
const isDirty = title !== page.title || content !== page.content || isActive !== page.is_active;
useEffect(() => {
onDirtyChange(page.id, isDirty);
return () => onDirtyChange(page.id, false);
}, [page.id, isDirty, onDirtyChange]);
const saveMutation = useMutation({
mutationFn: () =>
adminLegalPagesApi.updateFaqPage(page.id, { title, content, is_active: isActive }),
onSuccess: () => {
haptic.success();
setSaveError(null);
onSaved();
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
return (
<div className="space-y-3 rounded-xl border border-dark-700 bg-dark-800/50 p-4">
<div className="flex items-center gap-2">
<input
value={title}
onChange={(e) => {
setSaveError(null);
setTitle(e.target.value);
}}
className="input flex-1"
placeholder={t('admin.legalPages.questionTitle')}
/>
<Toggle
checked={isActive}
onChange={() => setIsActive((v) => !v)}
aria-label={t('admin.legalPages.active')}
/>
<button
type="button"
onClick={onMoveUp}
disabled={!canMoveUp}
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-30"
aria-label={t('admin.legalPages.moveUp')}
>
<ChevronUpIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={!canMoveDown}
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-30"
aria-label={t('admin.legalPages.moveDown')}
>
<ChevronDownIcon className="h-4 w-4" />
</button>
<button
type="button"
onClick={onDelete}
className="min-h-[44px] min-w-[44px] rounded-lg p-2.5 text-dark-400 transition-colors hover:bg-error-500/10 hover:text-error-400"
aria-label={t('admin.legalPages.delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
<textarea
value={content}
onChange={(e) => {
setSaveError(null);
setContent(e.target.value);
}}
rows={4}
className="input w-full font-mono text-sm"
placeholder={t('admin.legalPages.questionContent')}
/>
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
className="min-h-[44px] rounded-lg bg-dark-700 px-5 py-2 text-sm font-medium text-dark-100 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{saveMutation.isPending ? t('admin.legalPages.saving') : t('admin.legalPages.save')}
</button>
</div>
);
}
function FaqEditor({ onDirtyChange }: { onDirtyChange: (dirty: boolean) => void }) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const haptic = useHapticFeedback();
const confirm = useDestructiveConfirm();
const [displayMode, setDisplayMode] = useState<LegalDisplayMode>('both');
const [activeLang, setActiveLang] = useState('ru');
const [populated, setPopulated] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [dirtyRows, setDirtyRows] = useState<number[]>([]);
const { data, isLoading, isFetching } = useQuery({
queryKey: ['admin', 'legal-pages', 'faq'],
queryFn: adminLegalPagesApi.getFaq,
staleTime: 0,
gcTime: 0,
});
useEffect(() => {
if (!data || isFetching || populated) return;
setDisplayMode(data.display_mode);
if (data.settings.length > 0 && !data.settings.some((s) => s.language === 'ru')) {
setActiveLang(data.settings[0].language);
}
setPopulated(true);
}, [data, isFetching, populated]);
const handleRowDirtyChange = useCallback((id: number, dirty: boolean) => {
setDirtyRows((prev) => {
if (dirty) return prev.includes(id) ? prev : [...prev, id];
return prev.includes(id) ? prev.filter((rowId) => rowId !== id) : prev;
});
}, []);
const hasDirtyRows = dirtyRows.length > 0;
useEffect(() => {
onDirtyChange(hasDirtyRows);
}, [hasDirtyRows, onDirtyChange]);
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ['admin', 'legal-pages', 'faq'] });
const settingsMutation = useMutation({
mutationFn: (payload: { display_mode?: LegalDisplayMode; settings?: FaqSettingItem[] }) =>
adminLegalPagesApi.updateFaq(payload),
onSuccess: (resp) => {
haptic.success();
setSaveError(null);
queryClient.setQueryData(['admin', 'legal-pages', 'faq'], resp);
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
if (data) setDisplayMode(data.display_mode);
},
});
const createMutation = useMutation({
mutationFn: () =>
adminLegalPagesApi.createFaqPage({
language: activeLang,
title: t('admin.legalPages.newQuestion'),
content: '',
}),
onSuccess: () => {
haptic.success();
setSaveError(null);
invalidate();
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
const reorderMutation = useMutation({
mutationFn: async ({ a, b }: { a: FaqPageItem; b: FaqPageItem }) => {
await adminLegalPagesApi.updateFaqPage(a.id, { display_order: b.display_order });
try {
await adminLegalPagesApi.updateFaqPage(b.id, { display_order: a.display_order });
} catch (err) {
await adminLegalPagesApi
.updateFaqPage(a.id, { display_order: a.display_order })
.catch(() => {});
throw err;
}
},
onSuccess: () => {
haptic.success();
setSaveError(null);
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
onSettled: () => {
invalidate();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => adminLegalPagesApi.deleteFaqPage(id),
onSuccess: () => {
haptic.success();
setSaveError(null);
invalidate();
},
onError: (err) => {
haptic.error();
setSaveError(extractErrorDetail(err) ?? t('admin.legalPages.saveError'));
},
});
if (isLoading || !data) {
return <div className="skeleton h-64 w-full rounded-xl" />;
}
const languages = data.settings.map((s) => s.language);
const langEnabled = data.settings.find((s) => s.language === activeLang)?.is_enabled ?? false;
const pages = data.pages.filter((p) => p.language === activeLang);
return (
<div className="space-y-5">
<DisplayModeSelector
value={displayMode}
onChange={(mode) => {
setDisplayMode(mode);
settingsMutation.mutate({ display_mode: mode });
}}
disabled={data.display_mode_env_locked}
/>
<LanguageTabs
languages={languages}
active={activeLang}
onChange={async (lang) => {
if (lang === activeLang) return;
if (hasDirtyRows && !(await confirm(t('admin.legalPages.unsavedWarning')))) return;
setActiveLang(lang);
}}
/>
<div className="flex items-center gap-3">
<Toggle
checked={langEnabled}
onChange={() =>
settingsMutation.mutate({
settings: [{ language: activeLang, is_enabled: !langEnabled }],
})
}
disabled={settingsMutation.isPending}
aria-label={t('admin.legalPages.enabled')}
/>
<span className="text-sm text-dark-300">{t('admin.legalPages.enabled')}</span>
</div>
{saveError && <p className="text-sm text-error-400">{saveError}</p>}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-dark-200">{t('admin.legalPages.faqPages')}</h2>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
className="flex min-h-[44px] items-center gap-2 rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
<PlusIcon />
<span className="hidden sm:inline">{t('admin.legalPages.addQuestion')}</span>
</button>
</div>
{pages.length === 0 ? (
<p className="rounded-xl border border-dark-700 bg-dark-800/50 p-6 text-center text-sm text-dark-400">
{t('admin.legalPages.noQuestions')}
</p>
) : (
pages.map((page, index) => (
<FaqQuestionRow
key={page.id}
page={page}
canMoveUp={index > 0 && !reorderMutation.isPending}
canMoveDown={index < pages.length - 1 && !reorderMutation.isPending}
onMoveUp={() => reorderMutation.mutate({ a: page, b: pages[index - 1] })}
onMoveDown={() => reorderMutation.mutate({ a: page, b: pages[index + 1] })}
onDelete={async () => {
const confirmed = await confirm(t('admin.legalPages.confirmDeleteQuestion'));
if (confirmed) deleteMutation.mutate(page.id);
}}
onSaved={invalidate}
onDirtyChange={handleRowDirtyChange}
/>
))
)}
</div>
</div>
);
}
export default function AdminLegalPages() {
const { t } = useTranslation();
const confirm = useDestructiveConfirm();
const [activeTab, setActiveTab] = useState<LegalTab>('privacy');
const [dirty, setDirty] = useState(false);
const handleTabChange = async (tab: LegalTab) => {
if (tab === activeTab) return;
if (dirty && !(await confirm(t('admin.legalPages.unsavedWarning')))) return;
setDirty(false);
setActiveTab(tab);
};
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<AdminBackButton to="/admin/info-pages" />
<div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.legalPages.title')}</h1>
<p className="text-sm text-dark-400">{t('admin.legalPages.subtitle')}</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
{(['privacy', 'offer', 'rules', 'faq'] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => handleTabChange(tab)}
className={cn(
'min-h-[44px] rounded-lg px-4 py-2.5 text-sm font-medium transition-colors',
activeTab === tab
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-dark-100',
)}
>
{t(`admin.legalPages.tabs.${tab}`)}
</button>
))}
</div>
{activeTab === 'privacy' && (
<DocumentEditor key="privacy" kind="privacy-policy" onDirtyChange={setDirty} />
)}
{activeTab === 'offer' && (
<DocumentEditor key="offer" kind="public-offer" onDirtyChange={setDirty} />
)}
{activeTab === 'rules' && <RulesEditor onDirtyChange={setDirty} />}
{activeTab === 'faq' && <FaqEditor onDirtyChange={setDirty} />}
</div>
);
}

View File

@@ -303,6 +303,12 @@ const sections: AdminSection[] = [
to: '/admin/info-pages',
permission: 'info_pages:read',
},
{
name: 'admin.nav.legalPages',
icon: 'file-text',
to: '/admin/legal-pages',
permission: 'info_pages:read',
},
{
name: 'admin.nav.updates',
icon: 'refresh',

View File

@@ -41,6 +41,9 @@ export default function AdminPaymentMethodEdit() {
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState<'all' | 'selected'>('all');
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>([]);
const [openUrlDirect, setOpenUrlDirect] = useState(false);
const [quickAmounts, setQuickAmounts] = useState<number[]>([]);
const [quickAmountInput, setQuickAmountInput] = useState('');
const [quickAmountsError, setQuickAmountsError] = useState<string | null>(null);
// Initialize state when config loads
useEffect(() => {
@@ -56,6 +59,7 @@ export default function AdminPaymentMethodEdit() {
setSelectedPromoGroupIds(config.allowed_promo_group_ids);
// ?? false — защита от stale-config (backend ещё не пришёл с миграцией)
setOpenUrlDirect(config.open_url_direct ?? false);
setQuickAmounts((config.quick_amounts ?? []).map((kopeks) => kopeks / 100));
}
}, [config]);
@@ -104,6 +108,14 @@ export default function AdminPaymentMethodEdit() {
data.reset_max_amount = true;
}
if (quickAmounts.length > 0) {
data.quick_amounts = [...quickAmounts]
.sort((a, b) => a - b)
.map((rubles) => Math.round(rubles * 100));
} else {
data.reset_quick_amounts = true;
}
updateMethodMutation.mutate(data);
};
@@ -113,6 +125,30 @@ export default function AdminPaymentMethodEdit() {
);
};
const addQuickAmount = () => {
setQuickAmountsError(null);
const parsed = parseFloat(quickAmountInput.replace(',', '.'));
const value = Math.round(parsed);
if (isNaN(value) || value <= 0) {
setQuickAmountsError(t('admin.paymentMethods.quickAmountsInvalid'));
return;
}
if (quickAmounts.includes(value)) {
setQuickAmountInput('');
return;
}
if (quickAmounts.length >= 10) {
setQuickAmountsError(t('admin.paymentMethods.quickAmountsLimit'));
return;
}
setQuickAmounts((prev) => [...prev, value].sort((a, b) => a - b));
setQuickAmountInput('');
};
const removeQuickAmount = (value: number) => {
setQuickAmounts((prev) => prev.filter((amount) => amount !== value));
};
if (isLoading) {
return (
<div className="min-h-viewport flex items-center justify-center">
@@ -305,6 +341,55 @@ export default function AdminPaymentMethodEdit() {
</div>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.paymentMethods.quickAmounts')}
</label>
{quickAmounts.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{quickAmounts.map((value) => (
<button
key={value}
type="button"
onClick={() => removeQuickAmount(value)}
aria-label={t('admin.paymentMethods.quickAmountsRemove', { value })}
className="flex items-center gap-1.5 rounded-xl border border-accent-500/30 bg-accent-500/10 px-3 py-1.5 text-sm font-medium text-accent-300 transition-colors hover:border-error-500/40 hover:bg-error-500/10 hover:text-error-400"
>
<span>{value} </span>
<span className="text-base leading-none">×</span>
</button>
))}
</div>
)}
<div className="flex gap-2">
<input
type="number"
min="1"
value={quickAmountInput}
onChange={(e) => setQuickAmountInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addQuickAmount();
}
}}
placeholder={t('admin.paymentMethods.quickAmountsPlaceholder')}
className="input flex-1"
/>
<button type="button" onClick={addQuickAmount} className="btn-secondary shrink-0">
{t('admin.paymentMethods.quickAmountsAdd')}
</button>
</div>
{quickAmountsError && <p className="mt-1 text-xs text-error-400">{quickAmountsError}</p>}
<p className="mt-1 text-xs text-dark-500">
{t('admin.paymentMethods.quickAmountsHint', {
defaults: (config.default_quick_amounts ?? [])
.map((kopeks) => kopeks / 100)
.join(', '),
})}
</p>
</div>
{/* Display conditions */}
<div className="border-t border-dark-700 pt-3">
<h3 className="mb-4 text-sm font-semibold text-dark-200">

View File

@@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { PiCaretDown } from 'react-icons/pi';
import DOMPurify from 'dompurify';
import { infoApi, FaqPage } from '../api/info';
import { infoApi, FaqPage, InfoVisibility } from '../api/info';
import { infoPagesApi } from '../api/infoPages';
import { promoApi, LoyaltyTierInfo } from '../api/promo';
import type { FaqItem, ReplacesTab } from '../api/infoPages';
@@ -331,6 +331,12 @@ export default function Info() {
staleTime: 60_000,
});
const { data: visibility } = useQuery({
queryKey: ['info-visibility'],
queryFn: infoApi.getVisibility,
staleTime: 60_000,
});
// Filter to only pages that don't replace a built-in tab and don't collide with built-in IDs
const extraPages = useMemo(
() => (customPages ?? []).filter((p) => !p.replaces_tab && !BUILTIN_TABS.has(p.slug)),
@@ -425,6 +431,7 @@ export default function Info() {
refetchOnMount: 'always',
});
const tabs = useMemo(() => {
const builtinTabs: Array<{ id: string; label: string; icon: React.FC; emoji?: string }> = [
{ id: 'faq', label: t('info.faq'), icon: QuestionIcon },
{ id: 'rules', label: t('info.rules'), icon: DocumentIcon },
@@ -433,12 +440,27 @@ export default function Info() {
{ id: 'loyalty', label: t('info.loyalty'), icon: StarIcon },
];
const visibleBuiltinTabs = builtinTabs.filter((tab) => {
if (tab.id === 'loyalty') return true;
if (tabReplacements?.[tab.id as ReplacesTab]) return true;
if (!visibility) return true;
return visibility[tab.id as keyof InfoVisibility];
});
const customTabs = extraPages.map((p) => {
const label = p.title[locale] || p.title['ru'] || p.title['en'] || p.slug;
return { id: p.slug, label, icon: DocumentIcon, emoji: p.icon ?? undefined };
});
const tabs = [...builtinTabs, ...customTabs];
return [...visibleBuiltinTabs, ...customTabs];
}, [visibility, tabReplacements, extraPages, locale, t]);
useEffect(() => {
if (tabs.length === 0) return;
if (!tabs.some((tab) => tab.id === activeTab)) {
setActiveTab(tabs[0].id);
}
}, [tabs, activeTab]);
const toggleFaq = useCallback((id: number) => {
setExpandedFaq((prev) => (prev === id ? null : id));

View File

@@ -375,7 +375,11 @@ export default function TopUpAmount() {
}
};
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles);
const quickAmounts = (
method.quick_amounts != null
? method.quick_amounts.map((kopeks) => kopeks / 100)
: [100, 300, 500, 1000]
).filter((a) => a >= minRubles && a <= maxRubles);
const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2;
const getQuickValue = (rub: number) =>
targetCurrency === 'IRR'

View File

@@ -448,6 +448,7 @@ export interface PaymentMethod {
max_amount_kopeks: number;
is_available: boolean;
options?: PaymentMethodOption[] | null;
quick_amounts?: number[];
// Если true — после получения payment_url кабинет сразу делает
// window.location.href вместо показа панели с кнопкой "Открыть".
open_url_direct?: boolean;
@@ -687,6 +688,8 @@ export interface PaymentMethodConfig {
default_display_name: string;
sub_options: Record<string, boolean> | null;
available_sub_options: PaymentMethodSubOptionInfo[] | null;
quick_amounts: number[] | null;
default_quick_amounts: number[];
min_amount_kopeks: number | null;
max_amount_kopeks: number | null;
default_min_amount_kopeks: number;

View File

@@ -19,6 +19,12 @@ const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
'dots',
'spotlight',
'ripple',
'fireflies',
'snowfall',
'starfield',
'matrix-rain',
'liquid-gradient',
'constellation',
'none',
]);