mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -12,6 +12,12 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="format-detection" content="telephone=no" />
|
<meta name="format-detection" content="telephone=no" />
|
||||||
<title>Loading...</title>
|
<title>Loading...</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -36,12 +36,12 @@
|
|||||||
"i18next": "^25.8.4",
|
"i18next": "^25.8.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
"ogl": "^1.0.11",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
|
"simplex-noise": "^4.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
@@ -5233,12 +5233,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ogl": {
|
|
||||||
"version": "1.0.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz",
|
|
||||||
"integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==",
|
|
||||||
"license": "Unlicense"
|
|
||||||
},
|
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||||
@@ -6082,6 +6076,12 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simplex-noise": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/simplex-noise/-/simplex-noise-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-qSE2I4AngLQG7BXqoZj51jokT4WUXe8mOBrvfOXpci8+6Yu44+/dD5zqDpOx3Ux792eamTd2lLcI8jqFntk/lg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/slice-ansi": {
|
"node_modules/slice-ansi": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||||
|
|||||||
@@ -44,12 +44,12 @@
|
|||||||
"i18next": "^25.8.4",
|
"i18next": "^25.8.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
"ogl": "^1.0.11",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
|
"simplex-noise": "^4.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,8 +21,10 @@ import VerifyEmail from './pages/VerifyEmail';
|
|||||||
import ResetPassword from './pages/ResetPassword';
|
import ResetPassword from './pages/ResetPassword';
|
||||||
import OAuthCallback from './pages/OAuthCallback';
|
import OAuthCallback from './pages/OAuthCallback';
|
||||||
|
|
||||||
|
// Dashboard - load eagerly (default route, LCP-critical)
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
|
||||||
// User pages - lazy load
|
// User pages - lazy load
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
|
||||||
const Subscription = lazy(() => import('./pages/Subscription'));
|
const Subscription = lazy(() => import('./pages/Subscription'));
|
||||||
const Balance = lazy(() => import('./pages/Balance'));
|
const Balance = lazy(() => import('./pages/Balance'));
|
||||||
const Referral = lazy(() => import('./pages/Referral'));
|
const Referral = lazy(() => import('./pages/Referral'));
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
|
||||||
|
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||||
|
|
||||||
|
export type { AnimationConfig };
|
||||||
|
|
||||||
export interface BrandingInfo {
|
export interface BrandingInfo {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -180,6 +184,25 @@ export const brandingApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get animation config (public, no auth required)
|
||||||
|
getAnimationConfig: async (): Promise<AnimationConfig> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<AnimationConfig>('/cabinet/branding/animation-config');
|
||||||
|
return response.data;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_ANIMATION_CONFIG;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Update animation config (admin only, partial update)
|
||||||
|
updateAnimationConfig: async (config: Partial<AnimationConfig>): Promise<AnimationConfig> => {
|
||||||
|
const response = await apiClient.patch<AnimationConfig>(
|
||||||
|
'/cabinet/branding/animation-config',
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get fullscreen enabled (public, no auth required)
|
// Get fullscreen enabled (public, no auth required)
|
||||||
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
|
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { useEffect, useState, memo } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { brandingApi } from '../api/branding';
|
|
||||||
|
|
||||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled';
|
|
||||||
|
|
||||||
// Detect if user prefers reduced motion
|
|
||||||
const isLowPerformance = (): boolean => {
|
|
||||||
// Only check for reduced motion preference - let animation run everywhere else
|
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
return prefersReducedMotion;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get cached value from localStorage
|
|
||||||
const getCachedAnimationEnabled = (): boolean | null => {
|
|
||||||
try {
|
|
||||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
|
||||||
if (cached !== null) {
|
|
||||||
return cached === 'true';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update cache in localStorage
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const setCachedAnimationEnabled = (enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled));
|
|
||||||
} catch {
|
|
||||||
// localStorage not available
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoized background component to prevent re-renders
|
|
||||||
const AnimatedBackground = memo(function AnimatedBackground() {
|
|
||||||
// Start with cached value (null means unknown yet)
|
|
||||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled());
|
|
||||||
const [isLowPerf] = useState(() => isLowPerformance());
|
|
||||||
|
|
||||||
const { data: animationSettings } = useQuery({
|
|
||||||
queryKey: ['animation-enabled'],
|
|
||||||
queryFn: brandingApi.getAnimationEnabled,
|
|
||||||
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
|
|
||||||
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update state and cache when data arrives
|
|
||||||
useEffect(() => {
|
|
||||||
if (animationSettings !== undefined) {
|
|
||||||
const enabled = animationSettings.enabled;
|
|
||||||
setIsEnabled(enabled);
|
|
||||||
setCachedAnimationEnabled(enabled);
|
|
||||||
}
|
|
||||||
}, [animationSettings]);
|
|
||||||
|
|
||||||
// Don't render if disabled or on low-performance devices
|
|
||||||
if (isEnabled !== true || isLowPerf) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render only 2 blobs on mobile for better performance
|
|
||||||
const isMobile = window.innerWidth < 768;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="wave-bg-container">
|
|
||||||
<div className="wave-blob wave-blob-1" />
|
|
||||||
<div className="wave-blob wave-blob-2" />
|
|
||||||
{!isMobile && (
|
|
||||||
<>
|
|
||||||
<div className="wave-blob wave-blob-3" />
|
|
||||||
<div className="wave-blob wave-blob-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default AnimatedBackground;
|
|
||||||
@@ -115,6 +115,40 @@ export default function PaymentMethodIcon({
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'freekassa_sbp':
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 40 40">
|
||||||
|
<circle cx="20" cy="20" r="20" fill="#00B894" />
|
||||||
|
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
|
||||||
|
<text x="20" y="20" textAnchor="middle" fontSize="9">
|
||||||
|
СБП
|
||||||
|
</text>
|
||||||
|
<text x="20" y="30" textAnchor="middle" fontSize="8" fontWeight="400">
|
||||||
|
QR
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'freekassa_card':
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 40 40">
|
||||||
|
<circle cx="20" cy="20" r="20" fill="#6C5CE7" />
|
||||||
|
<rect
|
||||||
|
x="10"
|
||||||
|
y="14"
|
||||||
|
width="20"
|
||||||
|
height="14"
|
||||||
|
rx="2"
|
||||||
|
fill="none"
|
||||||
|
stroke="#fff"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
<line x1="10" y1="19" x2="30" y2="19" stroke="#fff" strokeWidth="1.5" />
|
||||||
|
<rect x="13" y="22" width="5" height="2" rx="0.5" fill="#fff" opacity=".6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
case 'cloudpayments':
|
case 'cloudpayments':
|
||||||
return (
|
return (
|
||||||
<svg className={className} viewBox="0 0 40 40">
|
<svg className={className} viewBox="0 0 40 40">
|
||||||
|
|||||||
381
src/components/admin/BackgroundEditor.tsx
Normal file
381
src/components/admin/BackgroundEditor.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { brandingApi } from '@/api/branding';
|
||||||
|
import { backgroundRegistry } from '@/components/ui/backgrounds/registry';
|
||||||
|
import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview';
|
||||||
|
import { setCachedAnimationConfig } from '@/components/backgrounds/BackgroundRenderer';
|
||||||
|
import type {
|
||||||
|
AnimationConfig,
|
||||||
|
BackgroundType,
|
||||||
|
SettingDefinition,
|
||||||
|
} from '@/components/ui/backgrounds/types';
|
||||||
|
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||||
|
import { Toggle } from './Toggle';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function SettingField({
|
||||||
|
def,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
def: SettingDefinition;
|
||||||
|
value: unknown;
|
||||||
|
onChange: (val: unknown) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}) {
|
||||||
|
if (def.type === 'number') {
|
||||||
|
const numVal = (value as number) ?? (def.default as number);
|
||||||
|
const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm text-dark-300">{t(def.label)}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={def.min}
|
||||||
|
max={def.max}
|
||||||
|
step={def.step}
|
||||||
|
value={numVal}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
|
className="w-24 accent-accent-500"
|
||||||
|
/>
|
||||||
|
<span className="w-16 text-right text-xs tabular-nums text-dark-400">{displayVal}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.type === 'color') {
|
||||||
|
const colorVal = (value as string) ?? (def.default as string);
|
||||||
|
// HTML color input only supports hex — for rgba defaults, show a neutral hex
|
||||||
|
const hexForInput = /^#[0-9a-fA-F]{3,8}$/.test(colorVal) ? colorVal : '#818cf8';
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm text-dark-300">{t(def.label)}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hexForInput}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent"
|
||||||
|
/>
|
||||||
|
<span className="w-16 text-right text-xs text-dark-400">{colorVal}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.type === 'boolean') {
|
||||||
|
const boolVal = (value as boolean) ?? (def.default as boolean);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm text-dark-300">{t(def.label)}</label>
|
||||||
|
<Toggle checked={boolVal} onChange={() => onChange(!boolVal)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.type === 'select' && def.options) {
|
||||||
|
const selectVal = (value as string) ?? (def.default as string);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm text-dark-300">{t(def.label)}</label>
|
||||||
|
<select
|
||||||
|
value={selectVal}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="rounded-lg border border-dark-600 bg-dark-700 px-3 py-1.5 text-sm text-dark-200 focus:border-accent-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
{def.options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label.startsWith('admin.') ? t(opt.label) : opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundEditor() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
// Clear save timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data: serverConfig } = useQuery({
|
||||||
|
queryKey: ['animation-config'],
|
||||||
|
queryFn: brandingApi.getAnimationConfig,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [localConfig, setLocalConfig] = useState<AnimationConfig | null>(null);
|
||||||
|
const config = localConfig ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG;
|
||||||
|
|
||||||
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: brandingApi.updateAnimationConfig,
|
||||||
|
onMutate: () => setSaveStatus('saving'),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setCachedAnimationConfig(data);
|
||||||
|
queryClient.setQueryData(['animation-config'], data);
|
||||||
|
setLocalConfig(null);
|
||||||
|
setSaveStatus('saved');
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
|
||||||
|
},
|
||||||
|
onError: () => setSaveStatus('idle'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateConfig = useCallback(
|
||||||
|
(patch: Partial<AnimationConfig>) => {
|
||||||
|
setLocalConfig((prev) => ({
|
||||||
|
...(prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG),
|
||||||
|
...patch,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[serverConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use functional updater to avoid stale closure when rapidly changing settings
|
||||||
|
const updateSetting = useCallback(
|
||||||
|
(key: string, value: unknown) => {
|
||||||
|
setLocalConfig((prev) => {
|
||||||
|
const base = prev ?? serverConfig ?? DEFAULT_ANIMATION_CONFIG;
|
||||||
|
return { ...base, settings: { ...base.settings, [key]: value } };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[serverConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTypeChange = useCallback(
|
||||||
|
(type: BackgroundType) => {
|
||||||
|
const def = backgroundRegistry.find((d) => d.type === type);
|
||||||
|
const defaults: Record<string, unknown> = {};
|
||||||
|
if (def) {
|
||||||
|
for (const s of def.settings) {
|
||||||
|
defaults[s.key] = s.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateConfig({ type, settings: defaults });
|
||||||
|
},
|
||||||
|
[updateConfig],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveMutation.mutate(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDirty = localConfig !== null;
|
||||||
|
const showSaveButton = isDirty || saveStatus === 'saved' || saveStatus === 'saving';
|
||||||
|
|
||||||
|
const currentDef = useMemo(
|
||||||
|
() => backgroundRegistry.find((d) => d.type === config.type),
|
||||||
|
[config.type],
|
||||||
|
);
|
||||||
|
|
||||||
|
const categories = useMemo(() => {
|
||||||
|
const cats = new Map<string, typeof backgroundRegistry>();
|
||||||
|
for (const def of backgroundRegistry) {
|
||||||
|
const list = cats.get(def.category) ?? [];
|
||||||
|
list.push(def);
|
||||||
|
cats.set(def.category, list);
|
||||||
|
}
|
||||||
|
return cats;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header with enable toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-dark-100">{t('admin.backgrounds.title')}</h3>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.backgrounds.description')}</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={config.enabled}
|
||||||
|
onChange={() => updateConfig({ enabled: !config.enabled })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.enabled && (
|
||||||
|
<>
|
||||||
|
{/* Preview */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.backgrounds.preview')}
|
||||||
|
</label>
|
||||||
|
<BackgroundPreview
|
||||||
|
type={config.type}
|
||||||
|
settings={config.settings}
|
||||||
|
opacity={config.opacity}
|
||||||
|
blur={config.blur}
|
||||||
|
className="h-48"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type selector gallery */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.backgrounds.selectType')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* None option */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleTypeChange('none')}
|
||||||
|
className={cn(
|
||||||
|
'mb-3 w-full rounded-xl border p-3 text-left transition-colors',
|
||||||
|
config.type === 'none'
|
||||||
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
|
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.backgrounds.none')}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-dark-400">{t('admin.backgrounds.noneDesc')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Background types by category */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{Array.from(categories.entries()).map(([category, defs]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<span className="mb-2 block text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||||
|
{t(`admin.backgrounds.category${category.toUpperCase()}`)}
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{defs.map((def) => (
|
||||||
|
<button
|
||||||
|
key={def.type}
|
||||||
|
onClick={() => handleTypeChange(def.type)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border p-3 text-left transition-colors',
|
||||||
|
config.type === def.type
|
||||||
|
? 'border-accent-500 bg-accent-500/10'
|
||||||
|
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-medium text-dark-200">
|
||||||
|
{t(def.labelKey)}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-dark-400">{t(def.descriptionKey)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Per-type settings */}
|
||||||
|
{currentDef && currentDef.settings.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.backgrounds.settings')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{currentDef.settings.map((def) => (
|
||||||
|
<SettingField
|
||||||
|
key={def.key}
|
||||||
|
def={def}
|
||||||
|
value={config.settings[def.key]}
|
||||||
|
onChange={(val) => updateSetting(def.key, val)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global settings */}
|
||||||
|
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm text-dark-300">
|
||||||
|
{t('admin.backgrounds.globalOpacity')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0.1}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={config.opacity}
|
||||||
|
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
|
||||||
|
className="w-24 accent-accent-500"
|
||||||
|
/>
|
||||||
|
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
|
||||||
|
{config.opacity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<label className="text-sm text-dark-300">{t('admin.backgrounds.globalBlur')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
step={1}
|
||||||
|
value={config.blur}
|
||||||
|
onChange={(e) => updateConfig({ blur: parseInt(e.target.value) })}
|
||||||
|
className="w-24 accent-accent-500"
|
||||||
|
/>
|
||||||
|
<span className="w-14 text-right text-xs tabular-nums text-dark-400">
|
||||||
|
{config.blur}px
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-dark-300">
|
||||||
|
{t('admin.backgrounds.reducedOnMobile')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-dark-500">
|
||||||
|
{t('admin.backgrounds.reducedOnMobileDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={config.reducedOnMobile}
|
||||||
|
onChange={() => updateConfig({ reducedOnMobile: !config.reducedOnMobile })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save button */}
|
||||||
|
{showSaveButton && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saveMutation.isPending || saveStatus === 'saved'}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-xl py-3 text-sm font-medium transition-colors',
|
||||||
|
saveStatus === 'saved'
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{saveStatus === 'saving'
|
||||||
|
? t('admin.backgrounds.saving')
|
||||||
|
: saveStatus === 'saved'
|
||||||
|
? t('admin.backgrounds.saved')
|
||||||
|
: t('admin.backgrounds.save')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@ import { useState, useRef } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { brandingApi, setCachedBranding } from '../../api/branding';
|
import { brandingApi, setCachedBranding } from '../../api/branding';
|
||||||
import { setCachedAnimationEnabled } from '../AnimatedBackground';
|
|
||||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
|
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
|
||||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
|
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
|
||||||
import { Toggle } from './Toggle';
|
import { Toggle } from './Toggle';
|
||||||
|
import { BackgroundEditor } from './BackgroundEditor';
|
||||||
|
|
||||||
interface BrandingTabProps {
|
interface BrandingTabProps {
|
||||||
accentColor?: string;
|
accentColor?: string;
|
||||||
@@ -25,11 +25,6 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
queryFn: brandingApi.getBranding,
|
queryFn: brandingApi.getBranding,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: animationSettings } = useQuery({
|
|
||||||
queryKey: ['animation-enabled'],
|
|
||||||
queryFn: brandingApi.getAnimationEnabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: fullscreenSettings } = useQuery({
|
const { data: fullscreenSettings } = useQuery({
|
||||||
queryKey: ['fullscreen-enabled'],
|
queryKey: ['fullscreen-enabled'],
|
||||||
queryFn: brandingApi.getFullscreenEnabled,
|
queryFn: brandingApi.getFullscreenEnabled,
|
||||||
@@ -66,14 +61,6 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateAnimationMutation = useMutation({
|
|
||||||
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setCachedAnimationEnabled(data.enabled);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateFullscreenMutation = useMutation({
|
const updateFullscreenMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
|
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -199,27 +186,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Animation & Fullscreen toggles */}
|
{/* Animated Background Editor */}
|
||||||
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
|
<BackgroundEditor />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen & Email toggles */}
|
||||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||||
{t('admin.settings.interfaceOptions')}
|
{t('admin.settings.interfaceOptions')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-dark-100">
|
|
||||||
{t('admin.settings.animatedBackground')}
|
|
||||||
</span>
|
|
||||||
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
checked={animationSettings?.enabled ?? true}
|
|
||||||
onChange={() => updateAnimationMutation.mutate(!(animationSettings?.enabled ?? true))}
|
|
||||||
disabled={updateAnimationMutation.isPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-dark-100">
|
<span className="font-medium text-dark-100">
|
||||||
|
|||||||
63
src/components/backgrounds/BackgroundPreview.tsx
Normal file
63
src/components/backgrounds/BackgroundPreview.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { Suspense } from 'react';
|
||||||
|
import { backgroundComponents } from '@/components/ui/backgrounds/registry';
|
||||||
|
import type { BackgroundType } from '@/components/ui/backgrounds/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface BackgroundPreviewProps {
|
||||||
|
type: BackgroundType;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
opacity?: number;
|
||||||
|
blur?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundPreview({
|
||||||
|
type,
|
||||||
|
settings,
|
||||||
|
opacity,
|
||||||
|
blur,
|
||||||
|
className,
|
||||||
|
}: BackgroundPreviewProps) {
|
||||||
|
if (type === 'none') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex h-full items-center justify-center text-dark-400">—</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = backgroundComponents[type];
|
||||||
|
if (!Component) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: opacity != null ? opacity : undefined,
|
||||||
|
filter: blur && blur > 0 ? `blur(${blur}px)` : undefined,
|
||||||
|
}}
|
||||||
|
className="absolute inset-0"
|
||||||
|
>
|
||||||
|
<Component settings={settings} />
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/backgrounds/BackgroundRenderer.tsx
Normal file
100
src/components/backgrounds/BackgroundRenderer.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Suspense, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { brandingApi } from '@/api/branding';
|
||||||
|
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
|
||||||
|
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||||
|
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
||||||
|
|
||||||
|
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
||||||
|
|
||||||
|
function getCachedConfig(): AnimationConfig | null {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||||
|
return cached ? JSON.parse(cached) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch the background JS chunk immediately based on localStorage cache.
|
||||||
|
// This starts the download before React even renders, so by the time
|
||||||
|
// Suspense needs the component, the chunk is already loaded.
|
||||||
|
const cachedConfig = getCachedConfig();
|
||||||
|
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
||||||
|
prefetchBackground(cachedConfig.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedConfig(config: AnimationConfig) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ANIMATION_CACHE_KEY, JSON.stringify(config));
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedAnimationConfig(config: AnimationConfig) {
|
||||||
|
setCachedConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const reduced = { ...settings };
|
||||||
|
if (typeof reduced.particleCount === 'number')
|
||||||
|
reduced.particleCount = Math.floor((reduced.particleCount as number) / 2);
|
||||||
|
if (typeof reduced.particleDensity === 'number')
|
||||||
|
reduced.particleDensity = Math.floor((reduced.particleDensity as number) / 2);
|
||||||
|
if (typeof reduced.number === 'number')
|
||||||
|
reduced.number = Math.floor((reduced.number as number) / 2);
|
||||||
|
if ('interactive' in reduced) reduced.interactive = false;
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundRenderer() {
|
||||||
|
const prefersReducedMotion = useMemo(
|
||||||
|
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: config } = useQuery({
|
||||||
|
queryKey: ['animation-config'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await brandingApi.getAnimationConfig();
|
||||||
|
setCachedConfig(result);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
initialData: getCachedConfig() ?? undefined,
|
||||||
|
initialDataUpdatedAt: 0,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
|
||||||
|
|
||||||
|
if (!effectiveConfig.enabled || effectiveConfig.type === 'none' || prefersReducedMotion) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgType = effectiveConfig.type as Exclude<BackgroundType, 'none'>;
|
||||||
|
const Component = backgroundComponents[bgType];
|
||||||
|
|
||||||
|
if (!Component) return null;
|
||||||
|
|
||||||
|
const isMobile = window.innerWidth < 768;
|
||||||
|
const settings =
|
||||||
|
effectiveConfig.reducedOnMobile && isMobile
|
||||||
|
? reduceMobileSettings(effectiveConfig.settings)
|
||||||
|
: effectiveConfig.settings;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed inset-0 z-0"
|
||||||
|
style={{
|
||||||
|
opacity: effectiveConfig.opacity,
|
||||||
|
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
||||||
|
contain: 'strict',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Component settings={settings} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/dashboard/Sparkline.tsx
Normal file
69
src/components/dashboard/Sparkline.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
|
||||||
|
interface SparklineProps {
|
||||||
|
data: number[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
color: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sparkline({
|
||||||
|
data,
|
||||||
|
width = 200,
|
||||||
|
height = 40,
|
||||||
|
color,
|
||||||
|
className = '',
|
||||||
|
}: SparklineProps) {
|
||||||
|
const gradientId = useId();
|
||||||
|
|
||||||
|
if (data.length < 2) return null;
|
||||||
|
|
||||||
|
const max = Math.max(...data, 1);
|
||||||
|
const points = data.map((v, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * width;
|
||||||
|
const y = height - (v / max) * height * 0.85 - 2;
|
||||||
|
return `${x},${y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const polyline = points.join(' ');
|
||||||
|
const lastPoint = points[points.length - 1].split(',');
|
||||||
|
const lastX = parseFloat(lastPoint[0]);
|
||||||
|
const lastY = parseFloat(lastPoint[1]);
|
||||||
|
const areaPath = `M${points[0]} ${points.slice(1).join(' ')} L${width},${height} L0,${height} Z`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
className={className}
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
|
||||||
|
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d={areaPath} fill={`url(#${gradientId})`} />
|
||||||
|
<polyline
|
||||||
|
points={polyline}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* Last point dot */}
|
||||||
|
<circle
|
||||||
|
cx={lastX}
|
||||||
|
cy={lastY}
|
||||||
|
r="3"
|
||||||
|
fill={color}
|
||||||
|
style={{ filter: `drop-shadow(0 0 4px ${color})` }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/components/dashboard/StatsGrid.tsx
Normal file
166
src/components/dashboard/StatsGrid.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import type { Subscription } from '../../types';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { getTrafficZone } from '../../utils/trafficZone';
|
||||||
|
import { getGlassColors } from '../../utils/glassTheme';
|
||||||
|
|
||||||
|
interface StatsGridProps {
|
||||||
|
balanceRubles: number;
|
||||||
|
subscription: Subscription | null;
|
||||||
|
referralCount: number;
|
||||||
|
earningsRubles: number;
|
||||||
|
refLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChevronIcon = ({ color }: { color: string }) => (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6 4l4 4-4 4"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function StatsGrid({
|
||||||
|
balanceRubles,
|
||||||
|
subscription,
|
||||||
|
referralCount,
|
||||||
|
earningsRubles,
|
||||||
|
refLoading,
|
||||||
|
}: StatsGridProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const g = getGlassColors(isDark);
|
||||||
|
|
||||||
|
const zone = useMemo(
|
||||||
|
() => getTrafficZone(subscription?.traffic_used_percent ?? 0),
|
||||||
|
[subscription?.traffic_used_percent],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
label: t('dashboard.stats.balance'),
|
||||||
|
value: `${formatAmount(balanceRubles)} ${currencySymbol}`,
|
||||||
|
valueColor: zone.mainHex,
|
||||||
|
to: '/balance',
|
||||||
|
icon: (color: string) => (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||||
|
<path d="M2 10h20" />
|
||||||
|
<path d="M6 14h.01M10 14h.01" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: `${zone.mainHex}12`,
|
||||||
|
iconColor: zone.mainHex,
|
||||||
|
loading: false,
|
||||||
|
onboarding: 'balance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('dashboard.stats.referrals'),
|
||||||
|
value: `${referralCount}`,
|
||||||
|
valueColor: g.text,
|
||||||
|
subtitle: `${formatPositive(earningsRubles)} ${currencySymbol}`,
|
||||||
|
subtitleColor: zone.mainHex,
|
||||||
|
to: '/referral',
|
||||||
|
icon: (color: string) => (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iconBg: g.trackBg,
|
||||||
|
iconColor: g.textSecondary,
|
||||||
|
loading: refLoading,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-2.5">
|
||||||
|
{cards.map((card, i) => (
|
||||||
|
<Link
|
||||||
|
key={i}
|
||||||
|
to={card.to}
|
||||||
|
className="group relative overflow-hidden rounded-[18px] transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
background: g.cardBg,
|
||||||
|
border: `1px solid ${g.cardBorder}`,
|
||||||
|
boxShadow: g.shadow,
|
||||||
|
padding: '18px 20px 20px',
|
||||||
|
}}
|
||||||
|
data-onboarding={card.onboarding}
|
||||||
|
>
|
||||||
|
{/* Top row: icon + label + arrow */}
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-[9px] transition-colors duration-500"
|
||||||
|
style={{ background: card.iconBg }}
|
||||||
|
>
|
||||||
|
{card.icon(card.iconColor)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[13px] font-medium text-dark-50/45">{card.label}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronIcon color={g.textFaint} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
{card.loading ? (
|
||||||
|
<div className="skeleton h-8 w-20" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="text-[28px] font-bold leading-tight tracking-tight transition-colors duration-500"
|
||||||
|
style={{ color: card.valueColor }}
|
||||||
|
>
|
||||||
|
{card.value}
|
||||||
|
</div>
|
||||||
|
{card.subtitle && (
|
||||||
|
<div
|
||||||
|
className="mt-0.5 text-[13px] font-semibold"
|
||||||
|
style={{ color: card.subtitleColor }}
|
||||||
|
>
|
||||||
|
{card.subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
src/components/dashboard/SubscriptionCardActive.tsx
Normal file
370
src/components/dashboard/SubscriptionCardActive.tsx
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { UseMutationResult } from '@tanstack/react-query';
|
||||||
|
import TrafficProgressBar from './TrafficProgressBar';
|
||||||
|
import Sparkline from './Sparkline';
|
||||||
|
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { getTrafficZone } from '../../utils/trafficZone';
|
||||||
|
import { formatTraffic } from '../../utils/formatTraffic';
|
||||||
|
import { getGlassColors } from '../../utils/glassTheme';
|
||||||
|
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||||
|
import type { Subscription } from '../../types';
|
||||||
|
|
||||||
|
interface SubscriptionCardActiveProps {
|
||||||
|
subscription: Subscription;
|
||||||
|
trafficData: {
|
||||||
|
traffic_used_gb: number;
|
||||||
|
traffic_used_percent: number;
|
||||||
|
is_unlimited: boolean;
|
||||||
|
} | null;
|
||||||
|
refreshTrafficMutation: UseMutationResult<unknown, unknown, void, unknown>;
|
||||||
|
trafficRefreshCooldown: number;
|
||||||
|
connectedDevices: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function SubscriptionCardActive({
|
||||||
|
subscription,
|
||||||
|
trafficData,
|
||||||
|
refreshTrafficMutation,
|
||||||
|
trafficRefreshCooldown,
|
||||||
|
connectedDevices,
|
||||||
|
}: SubscriptionCardActiveProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const g = getGlassColors(isDark);
|
||||||
|
|
||||||
|
const usedPercent = trafficData?.traffic_used_percent ?? subscription.traffic_used_percent;
|
||||||
|
const usedGb = trafficData?.traffic_used_gb ?? subscription.traffic_used_gb;
|
||||||
|
const isUnlimited = trafficData?.is_unlimited ?? subscription.traffic_limit_gb === 0;
|
||||||
|
const zone = useMemo(() => getTrafficZone(usedPercent), [usedPercent]);
|
||||||
|
const animatedPercent = useAnimatedNumber(usedPercent);
|
||||||
|
|
||||||
|
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
|
||||||
|
const daysLeft = subscription.days_left;
|
||||||
|
|
||||||
|
// Sparkline placeholder data (hidden until API provides daily usage)
|
||||||
|
const dailyUsage: number[] = [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-3xl backdrop-blur-xl"
|
||||||
|
style={{
|
||||||
|
background: g.cardBg,
|
||||||
|
border: subscription.is_trial
|
||||||
|
? '1px solid rgba(62,219,176,0.15)'
|
||||||
|
: `1px solid ${g.cardBorder}`,
|
||||||
|
padding: '28px 28px 24px',
|
||||||
|
boxShadow: g.shadow,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Trial shimmer border */}
|
||||||
|
{subscription.is_trial && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-[-1px] animate-trial-glow rounded-3xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Background glow */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
top: -60,
|
||||||
|
right: -60,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${zone.mainHex}${g.glowAlpha} 0%, transparent 70%)`,
|
||||||
|
transition: 'background 0.8s ease',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ─── Header ─── */}
|
||||||
|
<div className="mb-7 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
{/* Zone indicator */}
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
background: zone.mainHex,
|
||||||
|
boxShadow: `0 0 8px ${zone.mainHex}80`,
|
||||||
|
transition: 'all 0.6s ease',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="font-mono text-[11px] font-semibold uppercase tracking-widest"
|
||||||
|
style={{ color: zone.mainHex, transition: 'color 0.6s ease' }}
|
||||||
|
>
|
||||||
|
{isUnlimited ? t('dashboard.unlimited') : t(zone.labelKey)}
|
||||||
|
</span>
|
||||||
|
{subscription.is_trial && (
|
||||||
|
<span
|
||||||
|
className="inline-flex animate-trial-glow items-center gap-1 rounded-md px-2 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(62,219,176,0.15), rgba(62,219,176,0.06))',
|
||||||
|
border: '1px solid rgba(62,219,176,0.2)',
|
||||||
|
color: '#3EDBB0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{t('subscription.trialStatus')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="text-lg font-bold tracking-tight text-dark-50">
|
||||||
|
{t('dashboard.trafficUsageTitle')}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Big percentage / infinity */}
|
||||||
|
<div className="text-right">
|
||||||
|
{isUnlimited ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="font-display text-[28px] font-extrabold leading-none tracking-tight"
|
||||||
|
style={{ color: zone.mainHex }}
|
||||||
|
>
|
||||||
|
∞
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 font-mono text-[11px] text-dark-50/30">
|
||||||
|
{formatTraffic(usedGb)} {t('dashboard.usedSuffix')}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="font-display text-[38px] font-extrabold leading-none tracking-tight text-dark-50">
|
||||||
|
{animatedPercent.toFixed(0)}
|
||||||
|
<span className="ml-px text-lg font-medium text-dark-50/35">%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[11px] text-dark-50/30">
|
||||||
|
{formatTraffic(usedGb)} / {formatTraffic(subscription.traffic_limit_gb)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Progress Bar ─── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<TrafficProgressBar
|
||||||
|
usedGb={usedGb}
|
||||||
|
limitGb={subscription.traffic_limit_gb}
|
||||||
|
percent={usedPercent}
|
||||||
|
isUnlimited={isUnlimited}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Connect Device Button ─── */}
|
||||||
|
{subscription.subscription_url && (
|
||||||
|
<HoverBorderGradient
|
||||||
|
as="button"
|
||||||
|
accentColor={zone.mainHex}
|
||||||
|
onClick={() => navigate('/connection')}
|
||||||
|
className="mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300"
|
||||||
|
data-onboarding="connect-devices"
|
||||||
|
style={{ fontFamily: 'inherit' }}
|
||||||
|
>
|
||||||
|
{/* Monitor icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
||||||
|
style={{ background: `${zone.mainHex}12` }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={zone.mainHex}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||||
|
<path d="M12 17v4M8 21h8" />
|
||||||
|
<path d="M12 8v4M10 10h4" opacity="0.7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold tracking-tight text-dark-50">
|
||||||
|
{t('dashboard.connectDevice')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||||
|
{t('dashboard.devicesOfMax', {
|
||||||
|
used: connectedDevices,
|
||||||
|
max: subscription.device_limit,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device dots */}
|
||||||
|
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||||
|
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: i < connectedDevices ? zone.mainHex : g.textGhost,
|
||||||
|
boxShadow: i < connectedDevices ? `0 0 6px ${zone.mainHex}50` : 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</HoverBorderGradient>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Stats row: Tariff + Days Left ─── */}
|
||||||
|
<div className="mb-5 flex gap-2.5">
|
||||||
|
{/* Tariff badge — clickable */}
|
||||||
|
<Link
|
||||||
|
to="/subscription"
|
||||||
|
className="flex-1 rounded-[14px] p-3.5 transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${zone.mainHex}12, ${zone.mainHex}06)`,
|
||||||
|
border: `1px solid ${zone.mainHex}18`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mb-1.5 text-[10px] font-semibold uppercase tracking-wider opacity-70 transition-colors duration-500"
|
||||||
|
style={{ color: zone.mainHex }}
|
||||||
|
>
|
||||||
|
{t('dashboard.tariff')}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-bold leading-tight tracking-tight text-dark-50">
|
||||||
|
{subscription.tariff_name || t('subscription.currentPlan')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[10px] text-dark-50/30">
|
||||||
|
{t('dashboard.validUntil', { date: formattedDate })}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Days remaining */}
|
||||||
|
<div
|
||||||
|
className="flex-1 rounded-[14px] p-3.5 transition-colors duration-300"
|
||||||
|
style={{
|
||||||
|
background: g.innerBg,
|
||||||
|
border: daysLeft <= 3 ? '1px solid rgba(255,184,0,0.2)' : `1px solid ${g.innerBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-50/35">
|
||||||
|
<div
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-[7px] transition-colors duration-300"
|
||||||
|
style={{
|
||||||
|
background: daysLeft <= 3 ? 'rgba(255,184,0,0.1)' : g.hoverBg,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="13"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={daysLeft <= 3 ? '#FFB800' : g.textSecondary}
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{t('dashboard.remaining')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span
|
||||||
|
className="text-[22px] font-bold tracking-tight transition-colors duration-300"
|
||||||
|
style={{ color: daysLeft <= 3 ? '#FFB800' : g.text }}
|
||||||
|
>
|
||||||
|
{daysLeft}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-dark-50/25">
|
||||||
|
{t('subscription.daysShort')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Traffic Refresh ─── */}
|
||||||
|
<div className="mb-5 flex items-center justify-between px-0.5">
|
||||||
|
<button
|
||||||
|
onClick={() => refreshTrafficMutation.mutate()}
|
||||||
|
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
||||||
|
className="flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-medium text-dark-50/35 transition-colors hover:bg-dark-50/[0.05] hover:text-dark-50/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
aria-label={t('common.refresh')}
|
||||||
|
>
|
||||||
|
<RefreshIcon
|
||||||
|
className={`h-3 w-3 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
|
{trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
to="/subscription"
|
||||||
|
className="text-[11px] font-medium text-dark-50/25 transition-colors hover:text-dark-50/40"
|
||||||
|
>
|
||||||
|
{t('dashboard.viewSubscription')} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Sparkline ─── */}
|
||||||
|
{dailyUsage.length >= 2 && (
|
||||||
|
<div
|
||||||
|
className="rounded-[14px] p-3.5 pb-3"
|
||||||
|
style={{ background: g.innerBg, border: `1px solid ${g.innerBorder}` }}
|
||||||
|
>
|
||||||
|
<div className="mb-2.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider text-dark-50/40">
|
||||||
|
{t('dashboard.usageLast14Days')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] text-dark-50/25">
|
||||||
|
{t('dashboard.maxUsage', { amount: formatTraffic(Math.max(...dailyUsage)) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Sparkline data={dailyUsage} width={440} height={44} color={zone.mainHex} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/components/dashboard/SubscriptionCardExpired.tsx
Normal file
169
src/components/dashboard/SubscriptionCardExpired.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import type { Subscription } from '../../types';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { getGlassColors } from '../../utils/glassTheme';
|
||||||
|
|
||||||
|
interface SubscriptionCardExpiredProps {
|
||||||
|
subscription: Subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const g = getGlassColors(isDark);
|
||||||
|
|
||||||
|
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-3xl"
|
||||||
|
style={{
|
||||||
|
background: g.cardBg,
|
||||||
|
border: '1px solid rgba(255,70,70,0.12)',
|
||||||
|
boxShadow: g.shadow,
|
||||||
|
padding: '28px 28px 24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Red glow */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
top: -60,
|
||||||
|
right: -60,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'radial-gradient(circle, rgba(255,59,92,0.08) 0%, transparent 70%)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* Grid pattern */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.02]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '40px 40px',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-5 flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Clock icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[14px]"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,59,92,0.1)',
|
||||||
|
border: '1px solid rgba(255,59,92,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#FF3B5C"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 6v6l4 2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold tracking-tight text-dark-50">
|
||||||
|
{t('dashboard.expired.title')}
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-dark-50/35">
|
||||||
|
{subscription.is_trial
|
||||||
|
? t('dashboard.expired.trialSubtitle')
|
||||||
|
: t('dashboard.expired.paidSubtitle')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badge */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 rounded-full px-3 py-1 text-[11px] font-semibold"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,59,92,0.1)',
|
||||||
|
border: '1px solid rgba(255,59,92,0.2)',
|
||||||
|
color: '#FF3B5C',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{subscription.is_trial && (
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{subscription.is_trial ? t('subscription.trialStatus') : t('subscription.expired')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expired info grid */}
|
||||||
|
<div
|
||||||
|
className="mb-5 flex justify-around rounded-[14px] text-center"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,59,92,0.04)',
|
||||||
|
border: '1px solid rgba(255,59,92,0.08)',
|
||||||
|
padding: '16px 18px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: t('dashboard.expired.traffic'), value: '0 GB' },
|
||||||
|
{ label: t('dashboard.expired.devices'), value: '0' },
|
||||||
|
{ label: t('dashboard.expired.expiredDate'), value: formattedDate },
|
||||||
|
].map((item, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="mb-1 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-bold tracking-tight text-dark-50/50">{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-2.5">
|
||||||
|
<Link
|
||||||
|
to="/subscription"
|
||||||
|
state={{ scrollToExtend: true }}
|
||||||
|
className="flex flex-1 items-center justify-center rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||||
|
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('dashboard.expired.renew')}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/subscription"
|
||||||
|
className="flex items-center justify-center rounded-[14px] px-5 py-3.5 text-[15px] font-semibold tracking-tight text-dark-50/50 transition-colors duration-200"
|
||||||
|
style={{
|
||||||
|
background: g.innerBg,
|
||||||
|
border: `1px solid ${g.innerBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('dashboard.expired.tariffs')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
src/components/dashboard/TrafficProgressBar.tsx
Normal file
216
src/components/dashboard/TrafficProgressBar.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getTrafficZone } from '../../utils/trafficZone';
|
||||||
|
import { formatTraffic } from '../../utils/formatTraffic';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { getGlassColors } from '../../utils/glassTheme';
|
||||||
|
|
||||||
|
interface TrafficProgressBarProps {
|
||||||
|
usedGb: number;
|
||||||
|
limitGb: number;
|
||||||
|
percent: number;
|
||||||
|
isUnlimited: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THRESHOLDS = [50, 75, 90];
|
||||||
|
|
||||||
|
export default function TrafficProgressBar({
|
||||||
|
usedGb,
|
||||||
|
limitGb,
|
||||||
|
percent,
|
||||||
|
isUnlimited,
|
||||||
|
compact = false,
|
||||||
|
}: TrafficProgressBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const g = getGlassColors(isDark);
|
||||||
|
const zone = useMemo(() => getTrafficZone(percent), [percent]);
|
||||||
|
const clampedPercent = Math.min(percent, 100);
|
||||||
|
const barHeight = compact ? 8 : 14;
|
||||||
|
|
||||||
|
// Multi-segment gradient matching prototype
|
||||||
|
const fillGradient = useMemo(() => {
|
||||||
|
if (percent < 50) return `linear-gradient(90deg, #00C987, ${zone.mainHex})`;
|
||||||
|
if (percent < 75) return `linear-gradient(90deg, #00C987, #FFB800, ${zone.mainHex})`;
|
||||||
|
if (percent < 90) return `linear-gradient(90deg, #00C987, #FFB800, #FF6B35, ${zone.mainHex})`;
|
||||||
|
return 'linear-gradient(90deg, #00C987, #FFB800, #FF6B35, #FF3B5C)';
|
||||||
|
}, [percent, zone.mainHex]);
|
||||||
|
|
||||||
|
if (isUnlimited) {
|
||||||
|
return (
|
||||||
|
<div role="progressbar" aria-label={t('dashboard.unlimited')}>
|
||||||
|
{/* Unlimited flowing bar */}
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
height: barHeight,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: g.trackBg,
|
||||||
|
border: `1px solid ${zone.mainHex}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 animate-unlimited-flow"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(90deg, ${zone.mainHex}50, ${zone.mainHex}, ${zone.mainHex}50)`,
|
||||||
|
backgroundSize: '200% 100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 animate-traffic-shimmer"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.25) 50%, transparent 100%)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* Top highlight */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-0"
|
||||||
|
style={{
|
||||||
|
height: '50%',
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%)',
|
||||||
|
borderRadius: '10px 10px 0 0',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Below bar: label + usage */}
|
||||||
|
{!compact && (
|
||||||
|
<div className="mt-2 flex items-center justify-between px-0.5">
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-semibold"
|
||||||
|
style={{ color: zone.mainHex }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-1.5 w-1.5 animate-unlimited-pulse rounded-full"
|
||||||
|
style={{
|
||||||
|
background: zone.mainHex,
|
||||||
|
boxShadow: `0 0 8px ${zone.mainHex}`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{t('dashboard.unlimitedTraffic')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] text-dark-50/30">
|
||||||
|
{t('dashboard.usedTraffic', { amount: formatTraffic(usedGb) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={Math.round(clampedPercent)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={`${t('subscription.traffic')}: ${clampedPercent.toFixed(1)}%`}
|
||||||
|
>
|
||||||
|
{/* Track */}
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
height: barHeight,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: g.trackBg,
|
||||||
|
border: `1px solid ${g.trackBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Warning zone tint backgrounds */}
|
||||||
|
<div className="absolute inset-0 flex" aria-hidden="true">
|
||||||
|
<div style={{ flex: '50 0 0', background: 'transparent' }} />
|
||||||
|
<div style={{ flex: '25 0 0', background: 'rgba(255,184,0,0.03)' }} />
|
||||||
|
<div style={{ flex: '15 0 0', background: 'rgba(255,107,53,0.04)' }} />
|
||||||
|
<div style={{ flex: '10 0 0', background: 'rgba(255,59,92,0.05)' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fill bar */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 top-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: `${clampedPercent}%`,
|
||||||
|
borderRadius: 10,
|
||||||
|
transition: 'width 1.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Gradient fill */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
background: fillGradient,
|
||||||
|
transition: 'background 0.8s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* Shimmer overlay */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 animate-traffic-shimmer"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* Top highlight */}
|
||||||
|
<div
|
||||||
|
className="absolute left-0 right-0 top-0"
|
||||||
|
style={{
|
||||||
|
height: '50%',
|
||||||
|
background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)',
|
||||||
|
borderRadius: '10px 10px 0 0',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Threshold markers */}
|
||||||
|
{THRESHOLDS.map((threshold) => (
|
||||||
|
<div
|
||||||
|
key={threshold}
|
||||||
|
className="absolute bottom-0 top-0"
|
||||||
|
style={{
|
||||||
|
left: `${threshold}%`,
|
||||||
|
width: 1,
|
||||||
|
background: g.textGhost,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Glow at fill edge */}
|
||||||
|
{clampedPercent > 2 && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute"
|
||||||
|
style={{
|
||||||
|
top: -4,
|
||||||
|
bottom: -4,
|
||||||
|
left: `calc(${clampedPercent}% - 8px)`,
|
||||||
|
width: 16,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: `radial-gradient(circle, ${zone.mainHex}60, transparent)`,
|
||||||
|
filter: 'blur(4px)',
|
||||||
|
transition: 'left 1.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scale labels */}
|
||||||
|
{!compact && limitGb > 0 && (
|
||||||
|
<div
|
||||||
|
className="mt-1.5 flex justify-between px-0.5 font-mono text-[9px] font-medium text-dark-50/20"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{[0, 25, 50, 75, 100].map((v) => (
|
||||||
|
<span key={v}>{formatTraffic((limitGb * v) / 100)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
244
src/components/dashboard/TrialOfferCard.tsx
Normal file
244
src/components/dashboard/TrialOfferCard.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import { UseMutationResult } from '@tanstack/react-query';
|
||||||
|
import type { TrialInfo } from '../../types';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
import { getGlassColors } from '../../utils/glassTheme';
|
||||||
|
|
||||||
|
interface TrialOfferCardProps {
|
||||||
|
trialInfo: TrialInfo;
|
||||||
|
balanceKopeks: number;
|
||||||
|
balanceRubles: number;
|
||||||
|
activateTrialMutation: UseMutationResult<unknown, unknown, void, unknown>;
|
||||||
|
trialError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrialOfferCard({
|
||||||
|
trialInfo,
|
||||||
|
balanceKopeks,
|
||||||
|
balanceRubles,
|
||||||
|
activateTrialMutation,
|
||||||
|
trialError,
|
||||||
|
}: TrialOfferCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
|
const { isDark } = useTheme();
|
||||||
|
const g = getGlassColors(isDark);
|
||||||
|
const isFree = !trialInfo.requires_payment;
|
||||||
|
const canAfford = balanceKopeks >= trialInfo.price_kopeks;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-3xl text-center"
|
||||||
|
style={{
|
||||||
|
background: g.cardBg,
|
||||||
|
border: `1px solid ${g.cardBorder}`,
|
||||||
|
boxShadow: g.shadow,
|
||||||
|
padding: '32px 28px 28px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Animated glow background */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-1/2 -translate-x-1/2"
|
||||||
|
style={{
|
||||||
|
top: -100,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: isFree
|
||||||
|
? 'radial-gradient(circle, rgba(62,219,176,0.08) 0%, transparent 70%)'
|
||||||
|
: 'radial-gradient(circle, rgba(255,184,0,0.07) 0%, transparent 70%)',
|
||||||
|
transition: 'background 0.5s ease',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{/* Grid pattern */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-[0.025]"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||||
|
backgroundSize: '40px 40px',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className="relative mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl"
|
||||||
|
style={{
|
||||||
|
background: isFree
|
||||||
|
? 'linear-gradient(135deg, #1a3a30, #142824)'
|
||||||
|
: 'linear-gradient(135deg, #3a3020, #282418)',
|
||||||
|
border: isFree ? '1px solid rgba(62,219,176,0.2)' : '1px solid rgba(255,184,0,0.2)',
|
||||||
|
transition: 'all 0.5s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isFree ? (
|
||||||
|
<svg
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#3EDBB0"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="26"
|
||||||
|
height="26"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#FFB800"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{/* Glow effect */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-[-1px] animate-trial-glow rounded-2xl"
|
||||||
|
style={{
|
||||||
|
boxShadow: isFree ? '0 0 20px rgba(62,219,176,0.15)' : '0 0 20px rgba(255,184,0,0.12)',
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="mb-1.5 text-[22px] font-bold tracking-tight text-dark-50">
|
||||||
|
{isFree ? t('dashboard.trialOffer.freeTitle') : t('dashboard.trialOffer.paidTitle')}
|
||||||
|
</h2>
|
||||||
|
<p className="mb-5 text-sm text-dark-50/40">
|
||||||
|
{isFree ? t('dashboard.trialOffer.freeDesc') : t('dashboard.trialOffer.paidDesc')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Price tag for paid trial */}
|
||||||
|
{!isFree && trialInfo.price_rubles > 0 && (
|
||||||
|
<div
|
||||||
|
className="mb-5 inline-flex items-baseline gap-1 rounded-xl px-5 py-2"
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,184,0,0.08)',
|
||||||
|
border: '1px solid rgba(255,184,0,0.15)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-[32px] font-extrabold leading-none tracking-tight"
|
||||||
|
style={{ color: '#FFB800' }}
|
||||||
|
>
|
||||||
|
{trialInfo.price_rubles.toFixed(0)}
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-semibold opacity-70" style={{ color: '#FFB800' }}>
|
||||||
|
{currencySymbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trial stats */}
|
||||||
|
<div className="mb-7 flex justify-center gap-8">
|
||||||
|
{[
|
||||||
|
{ value: String(trialInfo.duration_days), label: t('subscription.trial.days') },
|
||||||
|
{
|
||||||
|
value: trialInfo.traffic_limit_gb === 0 ? '∞' : String(trialInfo.traffic_limit_gb),
|
||||||
|
label: t('common.units.gb'),
|
||||||
|
},
|
||||||
|
{ value: String(trialInfo.device_limit), label: t('subscription.trial.devices') },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<div key={i} className="text-center">
|
||||||
|
<div className="text-4xl font-extrabold leading-none tracking-tight text-dark-50">
|
||||||
|
{stat.value}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs font-medium text-dark-50/30">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance info for paid trial */}
|
||||||
|
{!isFree && trialInfo.price_rubles > 0 && (
|
||||||
|
<div
|
||||||
|
className="mb-4 space-y-2 rounded-xl p-4 text-left"
|
||||||
|
style={{ background: g.innerBg, border: `1px solid ${g.innerBorder}` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-dark-50/40">{t('balance.currentBalance')}</span>
|
||||||
|
<span
|
||||||
|
className={`font-display text-sm font-semibold ${canAfford ? 'text-success-400' : 'text-warning-400'}`}
|
||||||
|
>
|
||||||
|
{formatAmount(balanceRubles)} {currencySymbol}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!canAfford && (
|
||||||
|
<div className="text-xs text-warning-400">
|
||||||
|
{t('subscription.trial.insufficientBalance')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{trialError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-center text-sm text-error-400">
|
||||||
|
{trialError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
{!isFree && trialInfo.price_kopeks > 0 ? (
|
||||||
|
canAfford ? (
|
||||||
|
<button
|
||||||
|
onClick={() => !activateTrialMutation.isPending && activateTrialMutation.mutate()}
|
||||||
|
disabled={activateTrialMutation.isPending}
|
||||||
|
className="w-full rounded-[14px] py-4 text-base font-bold tracking-tight transition-all duration-300 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #FFB800, #FF8C42)',
|
||||||
|
color: '#1a1200',
|
||||||
|
boxShadow: '0 4px 20px rgba(255,184,0,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activateTrialMutation.isPending
|
||||||
|
? t('common.loading')
|
||||||
|
: t('subscription.trial.payAndActivate')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/balance"
|
||||||
|
className="block w-full rounded-[14px] py-4 text-center text-base font-bold tracking-tight transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #FFB800, #FF8C42)',
|
||||||
|
color: '#1a1200',
|
||||||
|
boxShadow: '0 4px 20px rgba(255,184,0,0.2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('subscription.trial.topUpToActivate')}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => !activateTrialMutation.isPending && activateTrialMutation.mutate()}
|
||||||
|
disabled={activateTrialMutation.isPending}
|
||||||
|
className="w-full rounded-[14px] py-4 text-base font-bold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(62,219,176,0.12) 0%, rgba(62,219,176,0.04) 100%)',
|
||||||
|
border: '1px solid rgba(62,219,176,0.25)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activateTrialMutation.isPending ? t('common.loading') : t('subscription.trial.activate')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import TicketNotificationBell from '@/components/TicketNotificationBell';
|
|||||||
|
|
||||||
import { MobileBottomNav } from './MobileBottomNav';
|
import { MobileBottomNav } from './MobileBottomNav';
|
||||||
import { AppHeader } from './AppHeader';
|
import { AppHeader } from './AppHeader';
|
||||||
import { Aurora } from './Aurora';
|
import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer';
|
||||||
|
|
||||||
// Desktop nav icons
|
// Desktop nav icons
|
||||||
const HomeIcon = ({ className }: { className?: string }) => (
|
const HomeIcon = ({ className }: { className?: string }) => (
|
||||||
@@ -279,7 +279,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Animated background */}
|
{/* Animated background */}
|
||||||
<Aurora />
|
<BackgroundRenderer />
|
||||||
|
|
||||||
{/* Global components */}
|
{/* Global components */}
|
||||||
<WebSocketNotifications />
|
<WebSocketNotifications />
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
|
||||||
import { brandingApi } from '@/api/branding';
|
|
||||||
import { themeColorsApi } from '@/api/themeColors';
|
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
|
||||||
import { DEFAULT_THEME_COLORS } from '@/types/theme';
|
|
||||||
|
|
||||||
const VERT = /* glsl */ `#version 300 es
|
|
||||||
in vec2 position;
|
|
||||||
void main() {
|
|
||||||
gl_Position = vec4(position, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const FRAG = /* glsl */ `#version 300 es
|
|
||||||
precision highp float;
|
|
||||||
|
|
||||||
uniform float uTime;
|
|
||||||
uniform float uAmplitude;
|
|
||||||
uniform vec3 uColorStops[3];
|
|
||||||
uniform vec2 uResolution;
|
|
||||||
uniform float uBlend;
|
|
||||||
|
|
||||||
out vec4 fragColor;
|
|
||||||
|
|
||||||
vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); }
|
|
||||||
|
|
||||||
float snoise(vec2 v) {
|
|
||||||
const vec4 C = vec4(0.211324865405187, 0.366025403784439,
|
|
||||||
-0.577350269189626, 0.024390243902439);
|
|
||||||
vec2 i = floor(v + dot(v, C.yy));
|
|
||||||
vec2 x0 = v - i + dot(i, C.xx);
|
|
||||||
vec2 i1;
|
|
||||||
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
|
||||||
vec4 x12 = x0.xyxy + C.xxzz;
|
|
||||||
x12.xy -= i1;
|
|
||||||
i = mod(i, 289.0);
|
|
||||||
|
|
||||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
|
|
||||||
+ i.x + vec3(0.0, i1.x, 1.0));
|
|
||||||
|
|
||||||
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy),
|
|
||||||
dot(x12.zw, x12.zw)), 0.0);
|
|
||||||
m = m * m;
|
|
||||||
m = m * m;
|
|
||||||
|
|
||||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
|
||||||
vec3 h = abs(x) - 0.5;
|
|
||||||
vec3 ox = floor(x + 0.5);
|
|
||||||
vec3 a0 = x - ox;
|
|
||||||
|
|
||||||
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
|
||||||
|
|
||||||
vec3 g;
|
|
||||||
g.x = a0.x * x0.x + h.x * x0.y;
|
|
||||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
|
||||||
return 130.0 * dot(m, g);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ColorStop {
|
|
||||||
vec3 color;
|
|
||||||
float position;
|
|
||||||
};
|
|
||||||
|
|
||||||
#define COLOR_RAMP(colors, factor, finalColor) { \
|
|
||||||
int index = 0; \
|
|
||||||
for (int i = 0; i < colors.length() - 1; i++) { \
|
|
||||||
ColorStop currentColor = cyclingColors[i]; \
|
|
||||||
bool isInBetween = cyclingColors[i].position <= factor; \
|
|
||||||
index = isInBetween ? i : index; \
|
|
||||||
} \
|
|
||||||
ColorStop currentColor = cyclingColors[index]; \
|
|
||||||
ColorStop nextColor = cyclingColors[index + 1]; \
|
|
||||||
float range = cyclingColors[index + 1].position - currentColor.position; \
|
|
||||||
float lerpFactor = (factor - currentColor.position) / range; \
|
|
||||||
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
vec2 uv = gl_FragCoord.xy / uResolution;
|
|
||||||
|
|
||||||
ColorStop cyclingColors[3];
|
|
||||||
cyclingColors[0] = ColorStop(uColorStops[0], 0.0);
|
|
||||||
cyclingColors[1] = ColorStop(uColorStops[1], 0.5);
|
|
||||||
cyclingColors[2] = ColorStop(uColorStops[2], 1.0);
|
|
||||||
|
|
||||||
float noiseValue = snoise(uv * uAmplitude + uTime) * 0.5 + 0.5;
|
|
||||||
|
|
||||||
vec3 rampColor;
|
|
||||||
COLOR_RAMP(cyclingColors, noiseValue, rampColor);
|
|
||||||
|
|
||||||
fragColor = vec4(rampColor, uBlend);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function hexToRgb(hex: string): [number, number, number] {
|
|
||||||
hex = hex.replace('#', '');
|
|
||||||
if (hex.length === 3) {
|
|
||||||
hex = hex
|
|
||||||
.split('')
|
|
||||||
.map((c) => c + c)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
|
||||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
|
||||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
|
||||||
return [r, g, b];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduce lightness of a hex color for subdued background blobs
|
|
||||||
function dimAccent(hex: string, factor = 0.45): string {
|
|
||||||
hex = hex.replace('#', '');
|
|
||||||
if (hex.length === 3) {
|
|
||||||
hex = hex
|
|
||||||
.split('')
|
|
||||||
.map((c) => c + c)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
const r = Math.round(parseInt(hex.substring(0, 2), 16) * factor);
|
|
||||||
const g = Math.round(parseInt(hex.substring(2, 4), 16) * factor);
|
|
||||||
const b = Math.round(parseInt(hex.substring(4, 6), 16) * factor);
|
|
||||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateColorStops(background: string, surface: string, accent: string): string[] {
|
|
||||||
return [background, surface, dimAccent(accent)];
|
|
||||||
}
|
|
||||||
|
|
||||||
let _webglAvailable: boolean | null = null;
|
|
||||||
|
|
||||||
function isWebglAvailable(): boolean {
|
|
||||||
if (_webglAvailable === null) {
|
|
||||||
try {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
|
|
||||||
_webglAvailable = !!gl;
|
|
||||||
if (gl) {
|
|
||||||
const loseCtx = gl.getExtension('WEBGL_lose_context');
|
|
||||||
if (loseCtx) loseCtx.loseContext();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
_webglAvailable = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _webglAvailable;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Aurora() {
|
|
||||||
if (!isWebglAvailable()) return null;
|
|
||||||
return <AuroraImpl />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuroraImpl() {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const animationFrameRef = useRef<number>(0);
|
|
||||||
const rendererRef = useRef<Renderer | null>(null);
|
|
||||||
const programRef = useRef<Program | null>(null);
|
|
||||||
|
|
||||||
// Fetch animation setting
|
|
||||||
const { data: animationSetting } = useQuery({
|
|
||||||
queryKey: ['animation-enabled'],
|
|
||||||
queryFn: brandingApi.getAnimationEnabled,
|
|
||||||
staleTime: 60000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isEnabled = animationSetting?.enabled ?? false;
|
|
||||||
|
|
||||||
// Subscribe reactively to theme-colors cache so Aurora re-renders on setQueryData
|
|
||||||
const { data: themeColors = DEFAULT_THEME_COLORS } = useQuery({
|
|
||||||
queryKey: ['theme-colors'],
|
|
||||||
queryFn: themeColorsApi.getColors,
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pick background and surface based on current theme
|
|
||||||
const { isDark } = useTheme();
|
|
||||||
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
|
|
||||||
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
|
|
||||||
|
|
||||||
// Refs for initial color values (WebGL context shouldn't recreate on color change)
|
|
||||||
const backgroundRef = useRef(background);
|
|
||||||
backgroundRef.current = background;
|
|
||||||
const surfaceRef = useRef(surface);
|
|
||||||
surfaceRef.current = surface;
|
|
||||||
const accentRef = useRef(themeColors.accent);
|
|
||||||
accentRef.current = themeColors.accent;
|
|
||||||
|
|
||||||
// Initialize WebGL context once (only depends on isEnabled)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEnabled || !containerRef.current) return;
|
|
||||||
|
|
||||||
// Check for reduced motion preference
|
|
||||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
||||||
if (prefersReducedMotion) return;
|
|
||||||
|
|
||||||
const container = containerRef.current;
|
|
||||||
const renderer = new Renderer({
|
|
||||||
alpha: true,
|
|
||||||
antialias: false,
|
|
||||||
powerPreference: 'low-power',
|
|
||||||
});
|
|
||||||
rendererRef.current = renderer;
|
|
||||||
const gl = renderer.gl;
|
|
||||||
gl.clearColor(0, 0, 0, 0);
|
|
||||||
container.appendChild(gl.canvas);
|
|
||||||
|
|
||||||
const geometry = new Triangle(gl);
|
|
||||||
|
|
||||||
const colorStops = generateColorStops(
|
|
||||||
backgroundRef.current,
|
|
||||||
surfaceRef.current,
|
|
||||||
accentRef.current,
|
|
||||||
);
|
|
||||||
const colorStopsArray = colorStops
|
|
||||||
.map((hex) => {
|
|
||||||
const c = new Color(hex);
|
|
||||||
return [c.r, c.g, c.b];
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
|
|
||||||
const program = new Program(gl, {
|
|
||||||
vertex: VERT,
|
|
||||||
fragment: FRAG,
|
|
||||||
uniforms: {
|
|
||||||
uTime: { value: 0 },
|
|
||||||
uAmplitude: { value: 1.0 },
|
|
||||||
uColorStops: { value: colorStopsArray },
|
|
||||||
uResolution: { value: [container.offsetWidth, container.offsetHeight] },
|
|
||||||
uBlend: { value: 1.0 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
programRef.current = program;
|
|
||||||
|
|
||||||
const mesh = new Mesh(gl, { geometry, program });
|
|
||||||
|
|
||||||
// Рендерим на 10% разрешения — шейдер обрабатывает в ~100 раз меньше пикселей.
|
|
||||||
// renderer.setSize задаёт и буфер, и CSS-размер канваса, поэтому после него
|
|
||||||
// принудительно возвращаем CSS на 100% — канвас растягивается браузером,
|
|
||||||
// а filter: blur() сглаживает артефакты масштабирования.
|
|
||||||
const RESOLUTION_SCALE = 0.1;
|
|
||||||
|
|
||||||
function resize() {
|
|
||||||
if (!containerRef.current || !rendererRef.current || !programRef.current) return;
|
|
||||||
const fullW = containerRef.current.offsetWidth;
|
|
||||||
const fullH = containerRef.current.offsetHeight;
|
|
||||||
const bufW = Math.max(Math.ceil(fullW * RESOLUTION_SCALE), 1);
|
|
||||||
const bufH = Math.max(Math.ceil(fullH * RESOLUTION_SCALE), 1);
|
|
||||||
rendererRef.current.setSize(bufW, bufH);
|
|
||||||
// OGL setSize ставит canvas.style.width/height = bufW/bufH px,
|
|
||||||
// перезаписываем на 100% чтобы CSS растянул маленький буфер на весь экран
|
|
||||||
const canvas = rendererRef.current.gl.canvas as HTMLCanvasElement;
|
|
||||||
canvas.style.width = '100%';
|
|
||||||
canvas.style.height = '100%';
|
|
||||||
programRef.current.uniforms.uResolution.value = [bufW, bufH];
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
resize();
|
|
||||||
|
|
||||||
let lastTime = 0;
|
|
||||||
const targetFPS = 10;
|
|
||||||
const frameInterval = 1000 / targetFPS;
|
|
||||||
const speed = 0.3;
|
|
||||||
|
|
||||||
function animate(currentTime: number) {
|
|
||||||
const delta = currentTime - lastTime;
|
|
||||||
if (delta < frameInterval) {
|
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastTime = currentTime - (delta % frameInterval);
|
|
||||||
|
|
||||||
if (programRef.current && rendererRef.current) {
|
|
||||||
programRef.current.uniforms.uTime.value += speed * 0.01;
|
|
||||||
rendererRef.current.render({ scene: mesh });
|
|
||||||
}
|
|
||||||
|
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleVisibilityChange() {
|
|
||||||
if (document.hidden) {
|
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
animationFrameRef.current = 0;
|
|
||||||
} else {
|
|
||||||
lastTime = 0;
|
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
|
|
||||||
animationFrameRef.current = requestAnimationFrame(animate);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
|
|
||||||
if (rendererRef.current) {
|
|
||||||
const glCtx = rendererRef.current.gl;
|
|
||||||
|
|
||||||
// Delete GPU resources
|
|
||||||
if (programRef.current) {
|
|
||||||
glCtx.deleteProgram(programRef.current.program);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force-release the WebGL context
|
|
||||||
const loseCtx = glCtx.getExtension('WEBGL_lose_context');
|
|
||||||
if (loseCtx) loseCtx.loseContext();
|
|
||||||
|
|
||||||
if (container.contains(glCtx.canvas)) {
|
|
||||||
container.removeChild(glCtx.canvas);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rendererRef.current = null;
|
|
||||||
programRef.current = null;
|
|
||||||
};
|
|
||||||
}, [isEnabled]);
|
|
||||||
|
|
||||||
// Update color uniforms reactively without recreating WebGL context
|
|
||||||
useEffect(() => {
|
|
||||||
if (!programRef.current) return;
|
|
||||||
const colorStops = generateColorStops(background, surface, themeColors.accent);
|
|
||||||
const colorStopsArray = colorStops
|
|
||||||
.map((hex) => {
|
|
||||||
const c = new Color(hex);
|
|
||||||
return [c.r, c.g, c.b];
|
|
||||||
})
|
|
||||||
.flat();
|
|
||||||
programRef.current.uniforms.uColorStops.value = colorStopsArray;
|
|
||||||
}, [themeColors.accent, background, surface]);
|
|
||||||
|
|
||||||
if (!isEnabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blur overlay color from accent (very subtle)
|
|
||||||
const [r, g, b] = hexToRgb(themeColors.accent);
|
|
||||||
const blurColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.03)`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* WebGL Aurora canvas — рендерится на ~10% разрешения, CSS растягивает обратно.
|
|
||||||
filter: blur() сглаживает артефакты масштабирования (дешевле backdrop-filter в ~10-20 раз,
|
|
||||||
т.к. блюрит только один элемент, а не все слои под ним). */}
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="pointer-events-none fixed inset-0 z-0"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
filter: 'blur(20px)',
|
|
||||||
WebkitFilter: 'blur(20px)',
|
|
||||||
backgroundColor: blurColor,
|
|
||||||
contain: 'strict',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,4 +2,3 @@ export { AppShell } from './AppShell';
|
|||||||
export { DesktopSidebar } from './DesktopSidebar';
|
export { DesktopSidebar } from './DesktopSidebar';
|
||||||
export { MobileBottomNav } from './MobileBottomNav';
|
export { MobileBottomNav } from './MobileBottomNav';
|
||||||
export { AppHeader } from './AppHeader';
|
export { AppHeader } from './AppHeader';
|
||||||
export { Aurora } from './Aurora';
|
|
||||||
|
|||||||
26
src/components/ui/backgrounds/aurora-background.tsx
Normal file
26
src/components/ui/backgrounds/aurora-background.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuroraBackground({ settings }: Props) {
|
||||||
|
const showRadialGradient = (settings.showRadialGradient as boolean) ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none absolute -inset-[10px] animate-aurora opacity-50 blur-[10px] will-change-transform',
|
||||||
|
showRadialGradient &&
|
||||||
|
'[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%)',
|
||||||
|
backgroundSize: '300%, 200%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
src/components/ui/backgrounds/background-beams-collision.tsx
Normal file
179
src/components/ui/backgrounds/background-beams-collision.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useRef, useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BeamOptions {
|
||||||
|
initialX: number;
|
||||||
|
translateX: number;
|
||||||
|
duration: number;
|
||||||
|
repeatDelay: number;
|
||||||
|
delay?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BEAMS: BeamOptions[] = [
|
||||||
|
{ initialX: 10, translateX: 10, duration: 7, repeatDelay: 3, delay: 2 },
|
||||||
|
{ initialX: 600, translateX: 600, duration: 3, repeatDelay: 3, delay: 4 },
|
||||||
|
{ initialX: 100, translateX: 100, duration: 7, repeatDelay: 7, className: 'h-6' },
|
||||||
|
{ initialX: 400, translateX: 400, duration: 5, repeatDelay: 14, delay: 4 },
|
||||||
|
{ initialX: 800, translateX: 800, duration: 11, repeatDelay: 2, className: 'h-20' },
|
||||||
|
{ initialX: 1000, translateX: 1000, duration: 4, repeatDelay: 2, className: 'h-12' },
|
||||||
|
{ initialX: 1200, translateX: 1200, duration: 6, repeatDelay: 4, delay: 2, className: 'h-6' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Explosion(props: React.HTMLProps<HTMLDivElement>) {
|
||||||
|
const spans = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
directionX: Math.floor(Math.random() * 80 - 40),
|
||||||
|
directionY: Math.floor(Math.random() * -50 - 10),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...props} className={cn('absolute z-50 h-2 w-2', props.className)}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
{spans.map((span) => (
|
||||||
|
<motion.span
|
||||||
|
key={span.id}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollisionMechanism({
|
||||||
|
containerRef,
|
||||||
|
parentRef,
|
||||||
|
beamOptions,
|
||||||
|
}: {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
parentRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
beamOptions: BeamOptions;
|
||||||
|
}) {
|
||||||
|
const beamRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [collision, setCollision] = useState<{
|
||||||
|
detected: boolean;
|
||||||
|
coordinates: { x: number; y: number } | null;
|
||||||
|
}>({ detected: false, coordinates: null });
|
||||||
|
const [beamKey, setBeamKey] = useState(0);
|
||||||
|
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkCollision = () => {
|
||||||
|
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
|
||||||
|
const beamRect = beamRef.current.getBoundingClientRect();
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
const parentRect = parentRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (beamRect.bottom >= containerRect.top) {
|
||||||
|
const relativeX = beamRect.left - parentRect.left + beamRect.width / 2;
|
||||||
|
const relativeY = beamRect.bottom - parentRect.top;
|
||||||
|
|
||||||
|
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
||||||
|
setCycleCollisionDetected(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(checkCollision, 50);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [cycleCollisionDetected, containerRef, parentRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (collision.detected && collision.coordinates) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setCollision({ detected: false, coordinates: null });
|
||||||
|
setCycleCollisionDetected(false);
|
||||||
|
}, 2000);
|
||||||
|
setTimeout(() => {
|
||||||
|
setBeamKey((prev) => prev + 1);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}, [collision]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
key={beamKey}
|
||||||
|
ref={beamRef}
|
||||||
|
animate="animate"
|
||||||
|
initial={{
|
||||||
|
translateY: '-200px',
|
||||||
|
translateX: `${beamOptions.initialX}px`,
|
||||||
|
rotate: 0,
|
||||||
|
}}
|
||||||
|
variants={{
|
||||||
|
animate: {
|
||||||
|
translateY: '1800px',
|
||||||
|
translateX: `${beamOptions.translateX}px`,
|
||||||
|
rotate: 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: beamOptions.duration,
|
||||||
|
repeat: Infinity,
|
||||||
|
repeatType: 'loop',
|
||||||
|
ease: 'linear',
|
||||||
|
delay: beamOptions.delay ?? 0,
|
||||||
|
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',
|
||||||
|
beamOptions.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<AnimatePresence>
|
||||||
|
{collision.detected && collision.coordinates && (
|
||||||
|
<Explosion
|
||||||
|
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
|
||||||
|
style={{
|
||||||
|
left: `${collision.coordinates.x}px`,
|
||||||
|
top: `${collision.coordinates.y}px`,
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
||||||
|
{BEAMS.map((beam) => (
|
||||||
|
<CollisionMechanism
|
||||||
|
key={`${beam.initialX}-beam`}
|
||||||
|
beamOptions={beam}
|
||||||
|
containerRef={containerRef}
|
||||||
|
parentRef={parentRef}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Bottom collision line */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 w-full"
|
||||||
|
style={{
|
||||||
|
boxShadow:
|
||||||
|
'0 0 24px rgba(34, 42, 53, 0.06), 0 1px 1px rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(34, 42, 53, 0.04), 0 0 4px rgba(34, 42, 53, 0.08), 0 16px 68px rgba(47, 48, 55, 0.05), 0 1px 0 rgba(255, 255, 255, 0.1) inset',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
src/components/ui/backgrounds/background-beams.tsx
Normal file
109
src/components/ui/backgrounds/background-beams.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = [
|
||||||
|
'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875',
|
||||||
|
'M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867',
|
||||||
|
'M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859',
|
||||||
|
'M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851',
|
||||||
|
'M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843',
|
||||||
|
'M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835',
|
||||||
|
'M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827',
|
||||||
|
'M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819',
|
||||||
|
'M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811',
|
||||||
|
'M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803',
|
||||||
|
'M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795',
|
||||||
|
'M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787',
|
||||||
|
'M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779',
|
||||||
|
'M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771',
|
||||||
|
'M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763',
|
||||||
|
'M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755',
|
||||||
|
'M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747',
|
||||||
|
'M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739',
|
||||||
|
'M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731',
|
||||||
|
'M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Static background paths (subset for the radial gradient overlay)
|
||||||
|
const bgPath =
|
||||||
|
'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803';
|
||||||
|
|
||||||
|
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<svg
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
viewBox="0 0 696 316"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
{/* Static background paths */}
|
||||||
|
<path d={bgPath} stroke="url(#paint0_radial)" strokeOpacity="0.05" strokeWidth="0.5" />
|
||||||
|
|
||||||
|
{/* Animated beam paths with moving gradients */}
|
||||||
|
{paths.map((path, index) => (
|
||||||
|
<motion.path
|
||||||
|
key={`path-${index}`}
|
||||||
|
d={path}
|
||||||
|
stroke={`url(#linearGradient-${index})`}
|
||||||
|
strokeOpacity="0.4"
|
||||||
|
strokeWidth="0.5"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
{/* Animated linear gradients that move along the paths */}
|
||||||
|
{paths.map((_, index) => (
|
||||||
|
<motion.linearGradient
|
||||||
|
id={`linearGradient-${index}`}
|
||||||
|
key={`gradient-${index}`}
|
||||||
|
initial={{
|
||||||
|
x1: '0%',
|
||||||
|
x2: '0%',
|
||||||
|
y1: '0%',
|
||||||
|
y2: '0%',
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
x1: ['0%', '100%'],
|
||||||
|
x2: ['0%', '95%'],
|
||||||
|
y1: ['0%', '100%'],
|
||||||
|
y2: ['0%', `${93 + Math.random() * 8}%`],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: Math.random() * 10 + 10,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: Math.random() * 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<stop stopColor="#18CCFC" stopOpacity="0" />
|
||||||
|
<stop stopColor="#18CCFC" />
|
||||||
|
<stop offset="32.5%" stopColor="#6344F5" />
|
||||||
|
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
|
||||||
|
</motion.linearGradient>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Radial gradient for static background */}
|
||||||
|
<radialGradient
|
||||||
|
id="paint0_radial"
|
||||||
|
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" />
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
72
src/components/ui/backgrounds/background-boxes.tsx
Normal file
72
src/components/ui/backgrounds/background-boxes.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
'#93c5fd',
|
||||||
|
'#f9a8d4',
|
||||||
|
'#86efac',
|
||||||
|
'#fde047',
|
||||||
|
'#fca5a5',
|
||||||
|
'#d8b4fe',
|
||||||
|
'#a5b4fc',
|
||||||
|
'#c4b5fd',
|
||||||
|
];
|
||||||
|
|
||||||
|
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 cells = useMemo(() => {
|
||||||
|
const result: { color: string; delay: number; duration: number }[] = [];
|
||||||
|
for (let i = 0; i < rows * cols; i++) {
|
||||||
|
result.push({
|
||||||
|
color:
|
||||||
|
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
||||||
|
delay: Math.random() * 8,
|
||||||
|
duration: 3 + Math.random() * 4,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [rows, cols, boxColor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-100%',
|
||||||
|
left: '-100%',
|
||||||
|
width: '300%',
|
||||||
|
height: '300%',
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||||
|
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||||
|
transform: 'skewX(-48deg) skewY(14deg) scale(0.675) translateZ(0)',
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells.map((cell, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
className="border border-slate-700/50"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: [0, 0.15, 0] }}
|
||||||
|
transition={{
|
||||||
|
duration: cell.duration,
|
||||||
|
delay: cell.delay,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
style={{ backgroundColor: cell.color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
127
src/components/ui/backgrounds/background-gradient-animation.tsx
Normal file
127
src/components/ui/backgrounds/background-gradient-animation.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { sanitizeColor } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgbString(hex: string): string {
|
||||||
|
hex = hex.replace('#', '');
|
||||||
|
if (hex.length === 3)
|
||||||
|
hex = hex
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('');
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
if (isNaN(r) || isNaN(g) || isNaN(b)) return '129, 140, 248';
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||||
|
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
|
||||||
|
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
|
||||||
|
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
||||||
|
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
||||||
|
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
||||||
|
const interactive = (settings.interactive as boolean) ?? true;
|
||||||
|
const size = (settings.size as string) ?? '80%';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!interactive || !interactiveRef.current) return;
|
||||||
|
|
||||||
|
let curX = 0;
|
||||||
|
let curY = 0;
|
||||||
|
let tgX = 0;
|
||||||
|
let tgY = 0;
|
||||||
|
let animId = 0;
|
||||||
|
|
||||||
|
const move = () => {
|
||||||
|
curX += (tgX - curX) / 20;
|
||||||
|
curY += (tgY - curY) / 20;
|
||||||
|
if (interactiveRef.current) {
|
||||||
|
interactiveRef.current.style.transform = `translate(${Math.round(curX)}px, ${Math.round(curY)}px)`;
|
||||||
|
}
|
||||||
|
animId = requestAnimationFrame(move);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouse = (e: MouseEvent) => {
|
||||||
|
tgX = e.clientX;
|
||||||
|
tgY = e.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', handleMouse);
|
||||||
|
animId = requestAnimationFrame(move);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', handleMouse);
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
};
|
||||||
|
}, [interactive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 overflow-hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--first-color': firstColor,
|
||||||
|
'--second-color': secondColor,
|
||||||
|
'--third-color': thirdColor,
|
||||||
|
'--fourth-color': fourthColor,
|
||||||
|
'--fifth-color': fifthColor,
|
||||||
|
'--size': size,
|
||||||
|
'--blending': 'hard-light',
|
||||||
|
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<svg className="hidden">
|
||||||
|
<defs>
|
||||||
|
<filter id="blurMe">
|
||||||
|
<feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
|
||||||
|
<feColorMatrix
|
||||||
|
in="blur"
|
||||||
|
mode="matrix"
|
||||||
|
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -8"
|
||||||
|
result="goo"
|
||||||
|
/>
|
||||||
|
<feBlend in="SourceGraphic" in2="goo" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0" style={{ filter: 'url(#blurMe) blur(40px)' }}>
|
||||||
|
{[
|
||||||
|
{ color: firstColor, anim: 'animate-move-vertical' },
|
||||||
|
{ color: secondColor, anim: 'animate-move-in-circle' },
|
||||||
|
{ color: thirdColor, anim: 'animate-move-in-circle-slow' },
|
||||||
|
{ color: fourthColor, anim: 'animate-move-horizontal' },
|
||||||
|
{ color: fifthColor, anim: 'animate-move-in-circle-fast' },
|
||||||
|
].map((blob, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-100 mix-blend-hard-light',
|
||||||
|
blob.anim,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(circle at center, rgba(${blob.color}, 0.8) 0, rgba(${blob.color}, 0) 50%) no-repeat`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{interactive && (
|
||||||
|
<div
|
||||||
|
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`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/ui/backgrounds/background-lines.tsx
Normal file
73
src/components/ui/backgrounds/background-lines.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { createNoise3D } from 'simplex-noise';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackgroundLines({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const lineCount = clampNumber(settings.lineCount, 5, 100, 40);
|
||||||
|
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
||||||
|
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
|
||||||
|
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const noise3D = createNoise3D();
|
||||||
|
let nt = 0;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||||
|
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
nt += speed;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const yBase = (i / lineCount) * canvas.height;
|
||||||
|
const opacity = 0.1 + 0.15 * Math.sin((i / lineCount) * Math.PI);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.strokeStyle = lineColor;
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.lineWidth = strokeWidth;
|
||||||
|
|
||||||
|
for (let x = 0; x < canvas.width; x += 4) {
|
||||||
|
const y = yBase + noise3D(x / 600, i * 0.3, nt) * 40;
|
||||||
|
if (x === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [lineCount, lineColor, speed, strokeWidth]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
}
|
||||||
69
src/components/ui/backgrounds/background-ripple.tsx
Normal file
69
src/components/ui/backgrounds/background-ripple.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackgroundRipple({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
|
||||||
|
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
|
||||||
|
const speed = clampNumber(settings.speed, 0.1, 5, 0.5);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||||
|
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ripples = Array.from({ length: rippleCount }, (_, i) => ({
|
||||||
|
phase: (i / rippleCount) * Math.PI * 2,
|
||||||
|
maxRadius: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const animate = (time: number) => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
const cx = canvas.width / 2;
|
||||||
|
const cy = canvas.height / 2;
|
||||||
|
const maxR = Math.sqrt(cx * cx + cy * cy);
|
||||||
|
|
||||||
|
for (let i = 0; i < ripples.length; i++) {
|
||||||
|
const t = ((time * speed) / 1000 + ripples[i].phase) % (Math.PI * 2);
|
||||||
|
const progress = t / (Math.PI * 2);
|
||||||
|
const radius = progress * maxR;
|
||||||
|
const opacity = 0.3 * (1 - progress);
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = rippleColor;
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [rippleColor, rippleCount, speed]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
}
|
||||||
34
src/components/ui/backgrounds/grid-background.tsx
Normal file
34
src/components/ui/backgrounds/grid-background.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GridBackground({ settings }: Props) {
|
||||||
|
const variant = (settings.variant as string) ?? 'grid';
|
||||||
|
const gridColor = sanitizeColor(settings.gridColor, 'rgba(255,255,255,0.05)');
|
||||||
|
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
|
||||||
|
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);
|
||||||
|
|
||||||
|
if (variant === 'dots') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `radial-gradient(circle, ${gridColor} ${dotSize}px, transparent ${dotSize}px)`,
|
||||||
|
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `linear-gradient(${gridColor} 1px, transparent 1px), linear-gradient(to right, ${gridColor} 1px, transparent 1px)`,
|
||||||
|
backgroundSize: `${gridSize}px ${gridSize}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/ui/backgrounds/meteors.tsx
Normal file
50
src/components/ui/backgrounds/meteors.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Meteors({ settings }: Props) {
|
||||||
|
const count = clampNumber(settings.count, 1, 50, 20);
|
||||||
|
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
|
||||||
|
|
||||||
|
const meteors = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
left: `${Math.random() * 100}%`,
|
||||||
|
delay: `${Math.random() * 5}s`,
|
||||||
|
duration: `${2 + Math.random() * 6}s`,
|
||||||
|
size: `${1 + Math.random()}px`,
|
||||||
|
})),
|
||||||
|
[count],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
{meteors.map((meteor) => (
|
||||||
|
<span
|
||||||
|
key={meteor.id}
|
||||||
|
className="absolute top-0 rotate-[215deg] animate-meteor-effect rounded-[9999px]"
|
||||||
|
style={{
|
||||||
|
left: meteor.left,
|
||||||
|
animationDelay: meteor.delay,
|
||||||
|
animationDuration: meteor.duration,
|
||||||
|
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`,
|
||||||
|
background: meteorColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to right, ${meteorColor}, transparent)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
523
src/components/ui/backgrounds/registry.ts
Normal file
523
src/components/ui/backgrounds/registry.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import { lazy, type ComponentType } from 'react';
|
||||||
|
import type { BackgroundType, BackgroundDefinition } from './types';
|
||||||
|
|
||||||
|
// Raw import functions for prefetching (doesn't create React components)
|
||||||
|
const backgroundImports: Record<Exclude<BackgroundType, 'none'>, () => Promise<unknown>> = {
|
||||||
|
aurora: () => import('./aurora-background'),
|
||||||
|
sparkles: () => import('./sparkles'),
|
||||||
|
vortex: () => import('./vortex'),
|
||||||
|
'shooting-stars': () => import('./shooting-stars'),
|
||||||
|
'background-beams': () => import('./background-beams'),
|
||||||
|
'background-beams-collision': () => import('./background-beams-collision'),
|
||||||
|
'gradient-animation': () => import('./background-gradient-animation'),
|
||||||
|
wavy: () => import('./wavy-background'),
|
||||||
|
'background-lines': () => import('./background-lines'),
|
||||||
|
boxes: () => import('./background-boxes'),
|
||||||
|
meteors: () => import('./meteors'),
|
||||||
|
grid: () => import('./grid-background'),
|
||||||
|
dots: () => import('./grid-background'),
|
||||||
|
spotlight: () => import('./spotlight-bg'),
|
||||||
|
ripple: () => import('./background-ripple'),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prefetch the JS chunk for a background type (call early to avoid lazy-load delay) */
|
||||||
|
export function prefetchBackground(type: BackgroundType): void {
|
||||||
|
if (type !== 'none' && backgroundImports[type]) {
|
||||||
|
backgroundImports[type]();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-loaded components — only the selected type is fetched
|
||||||
|
export const backgroundComponents: Record<
|
||||||
|
Exclude<BackgroundType, 'none'>,
|
||||||
|
ComponentType<{ settings: Record<string, unknown> }>
|
||||||
|
> = {
|
||||||
|
aurora: lazy(() => import('./aurora-background')),
|
||||||
|
sparkles: lazy(() => import('./sparkles')),
|
||||||
|
vortex: lazy(() => import('./vortex')),
|
||||||
|
'shooting-stars': lazy(() => import('./shooting-stars')),
|
||||||
|
'background-beams': lazy(() => import('./background-beams')),
|
||||||
|
'background-beams-collision': lazy(() => import('./background-beams-collision')),
|
||||||
|
'gradient-animation': lazy(() => import('./background-gradient-animation')),
|
||||||
|
wavy: lazy(() => import('./wavy-background')),
|
||||||
|
'background-lines': lazy(() => import('./background-lines')),
|
||||||
|
boxes: lazy(() => import('./background-boxes')),
|
||||||
|
meteors: lazy(() => import('./meteors')),
|
||||||
|
grid: lazy(() => import('./grid-background')),
|
||||||
|
dots: lazy(() => import('./grid-background')),
|
||||||
|
spotlight: lazy(() => import('./spotlight-bg')),
|
||||||
|
ripple: lazy(() => import('./background-ripple')),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registry of all background definitions with settings for the editor
|
||||||
|
// labelKey/descriptionKey are under "admin.backgrounds.*"
|
||||||
|
// setting label keys are under "admin.backgrounds.*"
|
||||||
|
export const backgroundRegistry: BackgroundDefinition[] = [
|
||||||
|
{
|
||||||
|
type: 'aurora',
|
||||||
|
labelKey: 'admin.backgrounds.aurora',
|
||||||
|
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: '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' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sparkles',
|
||||||
|
labelKey: 'admin.backgrounds.sparkles',
|
||||||
|
descriptionKey: 'admin.backgrounds.sparklesDesc',
|
||||||
|
category: 'canvas',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'particleDensity',
|
||||||
|
label: 'admin.backgrounds.density',
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 200,
|
||||||
|
step: 10,
|
||||||
|
default: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'particleColor',
|
||||||
|
label: 'admin.backgrounds.particleColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#ffffff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minSize',
|
||||||
|
label: 'admin.backgrounds.minSize',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.2,
|
||||||
|
max: 3,
|
||||||
|
step: 0.1,
|
||||||
|
default: 0.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxSize',
|
||||||
|
label: 'admin.backgrounds.maxSize',
|
||||||
|
type: 'number',
|
||||||
|
min: 1,
|
||||||
|
max: 5,
|
||||||
|
step: 0.1,
|
||||||
|
default: 1.4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'speed',
|
||||||
|
label: 'admin.backgrounds.speed',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.1,
|
||||||
|
max: 3,
|
||||||
|
step: 0.1,
|
||||||
|
default: 0.6,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'vortex',
|
||||||
|
labelKey: 'admin.backgrounds.vortex',
|
||||||
|
descriptionKey: 'admin.backgrounds.vortexDesc',
|
||||||
|
category: 'canvas',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'particleCount',
|
||||||
|
label: 'admin.backgrounds.particles',
|
||||||
|
type: 'number',
|
||||||
|
min: 50,
|
||||||
|
max: 1000,
|
||||||
|
step: 50,
|
||||||
|
default: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rangeY',
|
||||||
|
label: 'admin.backgrounds.rangeY',
|
||||||
|
type: 'number',
|
||||||
|
min: 50,
|
||||||
|
max: 300,
|
||||||
|
step: 10,
|
||||||
|
default: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'baseHue',
|
||||||
|
label: 'admin.backgrounds.hue',
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 360,
|
||||||
|
step: 10,
|
||||||
|
default: 220,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rangeSpeed',
|
||||||
|
label: 'admin.backgrounds.speed',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.5,
|
||||||
|
max: 5,
|
||||||
|
step: 0.5,
|
||||||
|
default: 1.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backgroundColor',
|
||||||
|
label: 'admin.backgrounds.bgColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#000000',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'shooting-stars',
|
||||||
|
labelKey: 'admin.backgrounds.shootingStars',
|
||||||
|
descriptionKey: 'admin.backgrounds.shootingStarsDesc',
|
||||||
|
category: 'canvas',
|
||||||
|
settings: [
|
||||||
|
{ key: 'starColor', label: 'admin.backgrounds.starColor', type: 'color', default: '#9E00FF' },
|
||||||
|
{
|
||||||
|
key: 'trailColor',
|
||||||
|
label: 'admin.backgrounds.trailColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#2EB9DF',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'starDensity',
|
||||||
|
label: 'admin.backgrounds.density',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.00005,
|
||||||
|
max: 0.0005,
|
||||||
|
step: 0.00005,
|
||||||
|
default: 0.00015,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minSpeed',
|
||||||
|
label: 'admin.backgrounds.minSpeed',
|
||||||
|
type: 'number',
|
||||||
|
min: 5,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxSpeed',
|
||||||
|
label: 'admin.backgrounds.maxSpeed',
|
||||||
|
type: 'number',
|
||||||
|
min: 15,
|
||||||
|
max: 60,
|
||||||
|
step: 1,
|
||||||
|
default: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'background-beams',
|
||||||
|
labelKey: 'admin.backgrounds.beams',
|
||||||
|
descriptionKey: 'admin.backgrounds.beamsDesc',
|
||||||
|
category: 'svg',
|
||||||
|
settings: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'background-beams-collision',
|
||||||
|
labelKey: 'admin.backgrounds.beamsCollision',
|
||||||
|
descriptionKey: 'admin.backgrounds.beamsCollisionDesc',
|
||||||
|
category: 'svg',
|
||||||
|
settings: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'gradient-animation',
|
||||||
|
labelKey: 'admin.backgrounds.gradientAnimation',
|
||||||
|
descriptionKey: 'admin.backgrounds.gradientAnimationDesc',
|
||||||
|
category: 'css',
|
||||||
|
settings: [
|
||||||
|
{ key: 'firstColor', label: 'admin.backgrounds.color1', type: 'color', default: '#1271FF' },
|
||||||
|
{ key: 'secondColor', label: 'admin.backgrounds.color2', type: 'color', default: '#DD4AFF' },
|
||||||
|
{ 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: 'interactive',
|
||||||
|
label: 'admin.backgrounds.interactive',
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'size',
|
||||||
|
label: 'admin.backgrounds.size',
|
||||||
|
type: 'select',
|
||||||
|
default: '80%',
|
||||||
|
options: [
|
||||||
|
{ label: '60%', value: '60%' },
|
||||||
|
{ label: '80%', value: '80%' },
|
||||||
|
{ label: '100%', value: '100%' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'wavy',
|
||||||
|
labelKey: 'admin.backgrounds.wavy',
|
||||||
|
descriptionKey: 'admin.backgrounds.wavyDesc',
|
||||||
|
category: 'canvas',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'speed',
|
||||||
|
label: 'admin.backgrounds.speed',
|
||||||
|
type: 'select',
|
||||||
|
default: 'fast',
|
||||||
|
options: [
|
||||||
|
{ label: 'admin.backgrounds.slow', value: 'slow' },
|
||||||
|
{ label: 'admin.backgrounds.fast', value: 'fast' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waveWidth',
|
||||||
|
label: 'admin.backgrounds.waveWidth',
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 100,
|
||||||
|
step: 5,
|
||||||
|
default: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'blur',
|
||||||
|
label: 'admin.backgrounds.blurAmount',
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
max: 30,
|
||||||
|
step: 1,
|
||||||
|
default: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'waveOpacity',
|
||||||
|
label: 'admin.backgrounds.waveOpacity',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.1,
|
||||||
|
max: 1,
|
||||||
|
step: 0.05,
|
||||||
|
default: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backgroundFill',
|
||||||
|
label: 'admin.backgrounds.bgColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#000000',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'background-lines',
|
||||||
|
labelKey: 'admin.backgrounds.lines',
|
||||||
|
descriptionKey: 'admin.backgrounds.linesDesc',
|
||||||
|
category: 'canvas',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'lineCount',
|
||||||
|
label: 'admin.backgrounds.count',
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 80,
|
||||||
|
step: 5,
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lineColor',
|
||||||
|
label: 'admin.backgrounds.particleColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#818cf8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'speed',
|
||||||
|
label: 'admin.backgrounds.speed',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.001,
|
||||||
|
max: 0.01,
|
||||||
|
step: 0.001,
|
||||||
|
default: 0.002,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'strokeWidth',
|
||||||
|
label: 'admin.backgrounds.waveWidth',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.5,
|
||||||
|
max: 3,
|
||||||
|
step: 0.5,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'boxes',
|
||||||
|
labelKey: 'admin.backgrounds.boxes',
|
||||||
|
descriptionKey: 'admin.backgrounds.boxesDesc',
|
||||||
|
category: 'css',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'rows',
|
||||||
|
label: 'admin.backgrounds.rows',
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 40,
|
||||||
|
step: 5,
|
||||||
|
default: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cols',
|
||||||
|
label: 'admin.backgrounds.cols',
|
||||||
|
type: 'number',
|
||||||
|
min: 5,
|
||||||
|
max: 25,
|
||||||
|
step: 1,
|
||||||
|
default: 12,
|
||||||
|
},
|
||||||
|
{ key: 'boxColor', label: 'admin.backgrounds.fillColor', type: 'color', default: '#818cf8' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'meteors',
|
||||||
|
labelKey: 'admin.backgrounds.meteors',
|
||||||
|
descriptionKey: 'admin.backgrounds.meteorsDesc',
|
||||||
|
category: 'css',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'count',
|
||||||
|
label: 'admin.backgrounds.count',
|
||||||
|
type: 'number',
|
||||||
|
min: 5,
|
||||||
|
max: 50,
|
||||||
|
step: 5,
|
||||||
|
default: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'meteorColor',
|
||||||
|
label: 'admin.backgrounds.particleColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#ffffff',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'grid',
|
||||||
|
labelKey: 'admin.backgrounds.grid',
|
||||||
|
descriptionKey: 'admin.backgrounds.gridDesc',
|
||||||
|
category: 'css',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'gridColor',
|
||||||
|
label: 'admin.backgrounds.fillColor',
|
||||||
|
type: 'color',
|
||||||
|
default: 'rgba(255,255,255,0.05)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gridSize',
|
||||||
|
label: 'admin.backgrounds.gridSize',
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 80,
|
||||||
|
step: 5,
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'dots',
|
||||||
|
labelKey: 'admin.backgrounds.dots',
|
||||||
|
descriptionKey: 'admin.backgrounds.dotsDesc',
|
||||||
|
category: 'css',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'variant',
|
||||||
|
label: 'admin.backgrounds.size',
|
||||||
|
type: 'select',
|
||||||
|
default: 'dots',
|
||||||
|
options: [{ label: 'admin.backgrounds.dots', value: 'dots' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gridColor',
|
||||||
|
label: 'admin.backgrounds.fillColor',
|
||||||
|
type: 'color',
|
||||||
|
default: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gridSize',
|
||||||
|
label: 'admin.backgrounds.gridSize',
|
||||||
|
type: 'number',
|
||||||
|
min: 10,
|
||||||
|
max: 60,
|
||||||
|
step: 5,
|
||||||
|
default: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dotSize',
|
||||||
|
label: 'admin.backgrounds.size',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.5,
|
||||||
|
max: 4,
|
||||||
|
step: 0.5,
|
||||||
|
default: 1.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'spotlight',
|
||||||
|
labelKey: 'admin.backgrounds.spotlight',
|
||||||
|
descriptionKey: 'admin.backgrounds.spotlightDesc',
|
||||||
|
category: 'css',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'spotlightColor',
|
||||||
|
label: 'admin.backgrounds.particleColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#818cf8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spotlightSize',
|
||||||
|
label: 'admin.backgrounds.size',
|
||||||
|
type: 'number',
|
||||||
|
min: 200,
|
||||||
|
max: 800,
|
||||||
|
step: 50,
|
||||||
|
default: 400,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'ripple',
|
||||||
|
labelKey: 'admin.backgrounds.ripple',
|
||||||
|
descriptionKey: 'admin.backgrounds.rippleDesc',
|
||||||
|
category: 'canvas',
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
key: 'rippleColor',
|
||||||
|
label: 'admin.backgrounds.particleColor',
|
||||||
|
type: 'color',
|
||||||
|
default: '#818cf8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'rippleCount',
|
||||||
|
label: 'admin.backgrounds.count',
|
||||||
|
type: 'number',
|
||||||
|
min: 3,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'speed',
|
||||||
|
label: 'admin.backgrounds.speed',
|
||||||
|
type: 'number',
|
||||||
|
min: 0.1,
|
||||||
|
max: 2,
|
||||||
|
step: 0.1,
|
||||||
|
default: 0.5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
141
src/components/ui/backgrounds/shooting-stars.tsx
Normal file
141
src/components/ui/backgrounds/shooting-stars.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Star {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
angle: number;
|
||||||
|
scale: number;
|
||||||
|
speed: number;
|
||||||
|
distance: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BgStar {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
radius: number;
|
||||||
|
opacity: number;
|
||||||
|
twinkleSpeed: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShootingStarsBackground({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
||||||
|
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let shootingStars: Star[] = [];
|
||||||
|
let bgStars: BgStar[] = [];
|
||||||
|
let lastShootingTime = 0;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||||
|
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||||
|
initBgStars();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initBgStars = () => {
|
||||||
|
const count = Math.floor(canvas.width * canvas.height * starDensity);
|
||||||
|
bgStars = Array.from({ length: count }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
radius: Math.random() * 1.2 + 0.3,
|
||||||
|
opacity: Math.random(),
|
||||||
|
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const spawnShootingStar = () => {
|
||||||
|
shootingStars.push({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height * 0.5,
|
||||||
|
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
|
||||||
|
scale: 0.5 + Math.random() * 0.5,
|
||||||
|
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
|
||||||
|
distance: 0,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = (time: number) => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// Background stars
|
||||||
|
for (const s of bgStars) {
|
||||||
|
let opacity = s.opacity;
|
||||||
|
if (s.twinkleSpeed) {
|
||||||
|
opacity = 0.5 + 0.5 * Math.sin((time / 1000) * s.twinkleSpeed * Math.PI * 2);
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(s.x, s.y, s.radius, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(255,255,255,${opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn shooting stars
|
||||||
|
if (time - lastShootingTime > 4200 + Math.random() * 4500) {
|
||||||
|
spawnShootingStar();
|
||||||
|
lastShootingTime = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw shooting stars
|
||||||
|
shootingStars = shootingStars.filter((star) => {
|
||||||
|
star.distance += star.speed;
|
||||||
|
star.opacity = Math.max(0, 1 - star.distance / 500);
|
||||||
|
|
||||||
|
if (star.opacity <= 0) return false;
|
||||||
|
|
||||||
|
const x2 = star.x + Math.cos(star.angle) * star.distance;
|
||||||
|
const y2 = star.y + Math.sin(star.angle) * star.distance;
|
||||||
|
const tailX = star.x + Math.cos(star.angle) * Math.max(0, star.distance - 80);
|
||||||
|
const tailY = star.y + Math.sin(star.angle) * Math.max(0, star.distance - 80);
|
||||||
|
|
||||||
|
const gradient = ctx.createLinearGradient(tailX, tailY, x2, y2);
|
||||||
|
gradient.addColorStop(0, 'transparent');
|
||||||
|
gradient.addColorStop(0.5, trailColor);
|
||||||
|
gradient.addColorStop(1, starColor);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = gradient;
|
||||||
|
ctx.lineWidth = star.scale * 2;
|
||||||
|
ctx.globalAlpha = star.opacity;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(tailX, tailY);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [starColor, trailColor, starDensity, minSpeed, maxSpeed]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
}
|
||||||
115
src/components/ui/backgrounds/sparkles.tsx
Normal file
115
src/components/ui/backgrounds/sparkles.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
size: number;
|
||||||
|
speedX: number;
|
||||||
|
speedY: number;
|
||||||
|
opacity: number;
|
||||||
|
opacityDirection: number;
|
||||||
|
opacitySpeed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sparkles({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationRef = useRef<number>(0);
|
||||||
|
const particlesRef = useRef<Particle[]>([]);
|
||||||
|
|
||||||
|
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
|
||||||
|
const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4);
|
||||||
|
const maxSize = clampNumber(settings.maxSize, 0.5, 10, 1.4);
|
||||||
|
const speed = clampNumber(settings.speed, 0.1, 10, 2);
|
||||||
|
const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF');
|
||||||
|
|
||||||
|
const hexToRgb = useCallback((hex: string) => {
|
||||||
|
hex = hex.replace('#', '');
|
||||||
|
if (hex.length === 3)
|
||||||
|
hex = hex
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('');
|
||||||
|
return {
|
||||||
|
r: parseInt(hex.substring(0, 2), 16),
|
||||||
|
g: parseInt(hex.substring(2, 4), 16),
|
||||||
|
b: parseInt(hex.substring(4, 6), 16),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||||
|
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||||
|
initParticles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initParticles = () => {
|
||||||
|
const area = canvas.width * canvas.height;
|
||||||
|
const count = Math.floor((area / 1000000) * particleDensity);
|
||||||
|
particlesRef.current = Array.from({ length: count }, () => ({
|
||||||
|
x: Math.random() * canvas.width,
|
||||||
|
y: Math.random() * canvas.height,
|
||||||
|
size: minSize + Math.random() * (maxSize - minSize),
|
||||||
|
speedX: (Math.random() - 0.5) * speed * 0.2,
|
||||||
|
speedY: (Math.random() - 0.5) * speed * 0.2,
|
||||||
|
opacity: Math.random(),
|
||||||
|
opacityDirection: Math.random() > 0.5 ? 1 : -1,
|
||||||
|
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rgb = hexToRgb(particleColor);
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (const p of particlesRef.current) {
|
||||||
|
p.x += p.speedX;
|
||||||
|
p.y += p.speedY;
|
||||||
|
p.opacity += p.opacityDirection * p.opacitySpeed;
|
||||||
|
|
||||||
|
if (p.opacity <= 0) {
|
||||||
|
p.opacity = 0;
|
||||||
|
p.opacityDirection = 1;
|
||||||
|
} else if (p.opacity >= 1) {
|
||||||
|
p.opacity = 1;
|
||||||
|
p.opacityDirection = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p.x < 0) p.x = canvas.width;
|
||||||
|
if (p.x > canvas.width) p.x = 0;
|
||||||
|
if (p.y < 0) p.y = canvas.height;
|
||||||
|
if (p.y > canvas.height) p.y = 0;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [particleDensity, minSize, maxSize, speed, particleColor, hexToRgb]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
}
|
||||||
54
src/components/ui/backgrounds/spotlight-bg.tsx
Normal file
54
src/components/ui/backgrounds/spotlight-bg.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpotlightBg({ settings }: Props) {
|
||||||
|
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
|
||||||
|
const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="absolute"
|
||||||
|
animate={{
|
||||||
|
x: ['-20%', '120%', '-20%'],
|
||||||
|
y: ['-10%', '80%', '-10%'],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 12,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: spotlightSize,
|
||||||
|
height: spotlightSize,
|
||||||
|
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
filter: 'blur(60px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
className="absolute"
|
||||||
|
animate={{
|
||||||
|
x: ['120%', '-20%', '120%'],
|
||||||
|
y: ['80%', '-10%', '80%'],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 15,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: 'easeInOut',
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: spotlightSize * 0.8,
|
||||||
|
height: spotlightSize * 0.8,
|
||||||
|
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
filter: 'blur(80px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/ui/backgrounds/types.ts
Normal file
73
src/components/ui/backgrounds/types.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
export type BackgroundType =
|
||||||
|
| 'aurora'
|
||||||
|
| 'sparkles'
|
||||||
|
| 'vortex'
|
||||||
|
| 'shooting-stars'
|
||||||
|
| 'background-beams'
|
||||||
|
| 'background-beams-collision'
|
||||||
|
| 'gradient-animation'
|
||||||
|
| 'wavy'
|
||||||
|
| 'background-lines'
|
||||||
|
| 'boxes'
|
||||||
|
| 'meteors'
|
||||||
|
| 'grid'
|
||||||
|
| 'dots'
|
||||||
|
| 'spotlight'
|
||||||
|
| 'ripple'
|
||||||
|
| 'none';
|
||||||
|
|
||||||
|
export interface AnimationConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
type: BackgroundType;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
opacity: number;
|
||||||
|
blur: number;
|
||||||
|
reducedOnMobile: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_ANIMATION_CONFIG: AnimationConfig = {
|
||||||
|
enabled: true,
|
||||||
|
type: 'aurora',
|
||||||
|
settings: {},
|
||||||
|
opacity: 1.0,
|
||||||
|
blur: 0,
|
||||||
|
reducedOnMobile: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sanitize color values to prevent CSS injection. Allows hex, rgb/rgba, hsl/hsla, and named colors. */
|
||||||
|
export function sanitizeColor(value: unknown, fallback: string): string {
|
||||||
|
if (typeof value !== 'string') return fallback;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
// Allow hex colors
|
||||||
|
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||||
|
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
|
||||||
|
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
|
||||||
|
// Allow CSS named colors (alphanumeric only)
|
||||||
|
if (/^[a-zA-Z]{3,30}$/.test(trimmed)) return trimmed;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a numeric value within bounds */
|
||||||
|
export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
||||||
|
const n = typeof value === 'number' ? value : fallback;
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingDefinition {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'number' | 'color' | 'boolean' | 'select';
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
default: unknown;
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackgroundDefinition {
|
||||||
|
type: BackgroundType;
|
||||||
|
labelKey: string;
|
||||||
|
descriptionKey: string;
|
||||||
|
category: 'css' | 'canvas' | 'svg';
|
||||||
|
settings: SettingDefinition[];
|
||||||
|
}
|
||||||
144
src/components/ui/backgrounds/vortex.tsx
Normal file
144
src/components/ui/backgrounds/vortex.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { createNoise3D } from 'simplex-noise';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VortexBackground({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const particleCount = clampNumber(settings.particleCount, 50, 2000, 500);
|
||||||
|
const rangeY = clampNumber(settings.rangeY, 10, 500, 100);
|
||||||
|
const baseHue = clampNumber(settings.baseHue, 0, 360, 220);
|
||||||
|
const rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5);
|
||||||
|
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const noise3D = createNoise3D();
|
||||||
|
const particlePropCount = 9;
|
||||||
|
const particlePropsLength = particleCount * particlePropCount;
|
||||||
|
let particleProps = new Float32Array(particlePropsLength);
|
||||||
|
let tick = 0;
|
||||||
|
let center: [number, number] = [0, 0];
|
||||||
|
|
||||||
|
const rand = (n: number) => n * Math.random();
|
||||||
|
const randRange = (n: number) => n - rand(2 * n);
|
||||||
|
const TAU = 2 * Math.PI;
|
||||||
|
const fadeInOut = (t: number, m: number) => {
|
||||||
|
const hm = 0.5 * m;
|
||||||
|
return Math.abs(((t + hm) % m) - hm) / hm;
|
||||||
|
};
|
||||||
|
const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||||
|
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||||
|
center = [0.5 * canvas.width, 0.5 * canvas.height];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initParticle = (i: number) => {
|
||||||
|
const x = rand(canvas.width);
|
||||||
|
const y = center[1] + randRange(rangeY);
|
||||||
|
const life = 0;
|
||||||
|
const ttl = 50 + rand(150);
|
||||||
|
const speed = rand(rangeSpeed);
|
||||||
|
const radius = 1 + rand(2);
|
||||||
|
const hue = baseHue + rand(100);
|
||||||
|
particleProps.set([x, y, 0, 0, life, ttl, speed, radius, hue], i);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initParticles = () => {
|
||||||
|
tick = 0;
|
||||||
|
particleProps = new Float32Array(particlePropsLength);
|
||||||
|
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||||
|
initParticle(i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawParticle = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
x2: number,
|
||||||
|
y2: number,
|
||||||
|
life: number,
|
||||||
|
ttl: number,
|
||||||
|
radius: number,
|
||||||
|
hue: number,
|
||||||
|
) => {
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineWidth = radius;
|
||||||
|
ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
ctx.lineTo(x2, y2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
tick++;
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.fillStyle = backgroundColor;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||||
|
const x = particleProps[i];
|
||||||
|
const y = particleProps[i + 1];
|
||||||
|
const n = noise3D(x * 0.00125, y * 0.00125, tick * 0.0005) * 3 * TAU;
|
||||||
|
const vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
|
||||||
|
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
||||||
|
const life = particleProps[i + 4];
|
||||||
|
const ttl = particleProps[i + 5];
|
||||||
|
const speed = particleProps[i + 6];
|
||||||
|
const x2 = x + vx * speed;
|
||||||
|
const y2 = y + vy * speed;
|
||||||
|
const radius = particleProps[i + 7];
|
||||||
|
const hue = particleProps[i + 8];
|
||||||
|
|
||||||
|
drawParticle(x, y, x2, y2, life, ttl, radius, hue);
|
||||||
|
|
||||||
|
particleProps[i] = x2;
|
||||||
|
particleProps[i + 1] = y2;
|
||||||
|
particleProps[i + 2] = vx;
|
||||||
|
particleProps[i + 3] = vy;
|
||||||
|
particleProps[i + 4] = life + 1;
|
||||||
|
|
||||||
|
if (x2 > canvas.width || x2 < 0 || y2 > canvas.height || y2 < 0 || life + 1 > ttl) {
|
||||||
|
initParticle(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glow effect
|
||||||
|
ctx.save();
|
||||||
|
ctx.filter = 'blur(8px) brightness(200%)';
|
||||||
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
|
ctx.drawImage(canvas, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
animationRef.current = requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
initParticles();
|
||||||
|
animationRef.current = requestAnimationFrame(draw);
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [particleCount, rangeY, baseHue, rangeSpeed, backgroundColor]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
}
|
||||||
76
src/components/ui/backgrounds/wavy-background.tsx
Normal file
76
src/components/ui/backgrounds/wavy-background.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { createNoise3D } from 'simplex-noise';
|
||||||
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WavyBackground({ settings }: Props) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const animationRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const speed = (settings.speed as string) ?? 'fast';
|
||||||
|
const waveWidth = clampNumber(settings.waveWidth, 5, 200, 50);
|
||||||
|
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'];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const noise3D = createNoise3D();
|
||||||
|
let nt = 0;
|
||||||
|
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
||||||
|
const waveCount = 5;
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||||
|
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||||
|
ctx.filter = `blur(${blur}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawWave = (_n: number) => {
|
||||||
|
nt += speedVal;
|
||||||
|
for (let i = 0; i < waveCount; i++) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.lineWidth = waveWidth;
|
||||||
|
ctx.strokeStyle = colors[i % colors.length];
|
||||||
|
ctx.globalAlpha = waveOpacity;
|
||||||
|
|
||||||
|
for (let x = 0; x < canvas.width; x += 5) {
|
||||||
|
const y = noise3D(x / 800, 0.3 * i, nt) * 100;
|
||||||
|
ctx.lineTo(x, y + canvas.height * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.fillStyle = backgroundFill;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
drawWave(5);
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
window.removeEventListener('resize', resize);
|
||||||
|
};
|
||||||
|
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
}
|
||||||
51
src/components/ui/hover-border-gradient.tsx
Normal file
51
src/components/ui/hover-border-gradient.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface HoverBorderGradientProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
/** Rendered HTML element. Default: 'div' */
|
||||||
|
as?: React.ElementType;
|
||||||
|
/** Hex color for the rotating gradient beam (e.g. '#00E5A0') */
|
||||||
|
accentColor?: string;
|
||||||
|
/** Full rotation duration in seconds. Default: 3 */
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated conic-gradient border that rotates around the element.
|
||||||
|
*
|
||||||
|
* Uses pure CSS `@property --border-angle` animation — no JS loops,
|
||||||
|
* GPU-friendly, works in Telegram Mini App WebView.
|
||||||
|
*
|
||||||
|
* Fallback: browsers without `@property` show a static gradient border.
|
||||||
|
*/
|
||||||
|
export function HoverBorderGradient({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
as: Tag = 'div',
|
||||||
|
accentColor,
|
||||||
|
duration,
|
||||||
|
style,
|
||||||
|
...props
|
||||||
|
}: HoverBorderGradientProps) {
|
||||||
|
const cssVars: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (accentColor) {
|
||||||
|
cssVars['--accent-color'] = accentColor;
|
||||||
|
// 30% opacity hex suffix for hover glow
|
||||||
|
cssVars['--accent-glow'] = accentColor + '4D';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration !== undefined) {
|
||||||
|
cssVars['--border-duration'] = `${duration}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
className={cn('hover-border-gradient', className)}
|
||||||
|
style={{ ...cssVars, ...style } as React.CSSProperties}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/hooks/useAnimatedNumber.ts
Normal file
34
src/hooks/useAnimatedNumber.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animates a number from its current value to a new target.
|
||||||
|
* Uses easeOutExpo easing matching the prototype.
|
||||||
|
*/
|
||||||
|
export function useAnimatedNumber(target: number, duration = 1200): number {
|
||||||
|
const [value, setValue] = useState(0);
|
||||||
|
const ref = useRef({ start: 0, startTime: 0, target: 0 });
|
||||||
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current.start = value;
|
||||||
|
ref.current.target = target;
|
||||||
|
ref.current.startTime = performance.now();
|
||||||
|
|
||||||
|
const animate = (now: number) => {
|
||||||
|
const elapsed = now - ref.current.startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
// easeOutExpo
|
||||||
|
const ease = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress);
|
||||||
|
const current = ref.current.start + (ref.current.target - ref.current.start) * ease;
|
||||||
|
setValue(current);
|
||||||
|
if (progress < 1) {
|
||||||
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(animate);
|
||||||
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
|
}, [target, duration]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
@@ -225,7 +225,52 @@
|
|||||||
"noActiveSubscription": "No active subscription",
|
"noActiveSubscription": "No active subscription",
|
||||||
"devicesUsed": "Devices: {{used}} of {{total}}",
|
"devicesUsed": "Devices: {{used}} of {{total}}",
|
||||||
"trafficUsed": "Traffic: {{used}} of {{total}} GB",
|
"trafficUsed": "Traffic: {{used}} of {{total}} GB",
|
||||||
"unlimitedTraffic": "Unlimited traffic"
|
"unlimitedTraffic": "Unlimited traffic",
|
||||||
|
"zone": {
|
||||||
|
"normal": "Normal",
|
||||||
|
"warning": "Moderate",
|
||||||
|
"danger": "High",
|
||||||
|
"critical": "Critical"
|
||||||
|
},
|
||||||
|
"connectDevice": "Connect Device",
|
||||||
|
"devicesConnected": "{{count}} connected",
|
||||||
|
"devicesOfMax": "{{used}} of {{max}} connected",
|
||||||
|
"devicesShort": "dev.",
|
||||||
|
"trafficUsage": "{{used}} / {{limit}} GB",
|
||||||
|
"trafficUsageTitle": "Traffic Usage",
|
||||||
|
"usedTraffic": "used {{amount}}",
|
||||||
|
"usedSuffix": "used",
|
||||||
|
"unlimited": "Unlimited",
|
||||||
|
"unlimitedTraffic": "Unlimited traffic",
|
||||||
|
"tariff": "Tariff",
|
||||||
|
"validUntil": "until {{date}}",
|
||||||
|
"remaining": "Remaining",
|
||||||
|
"daysRemaining": "Days left",
|
||||||
|
"usageLast14Days": "Usage last 14 days",
|
||||||
|
"maxUsage": "max {{amount}}",
|
||||||
|
"expired": {
|
||||||
|
"title": "Subscription Expired",
|
||||||
|
"trialTitle": "Trial Expired",
|
||||||
|
"trialSubtitle": "Trial period ended",
|
||||||
|
"paidSubtitle": "Subscription has expired",
|
||||||
|
"renew": "Renew Subscription",
|
||||||
|
"tariffs": "Tariffs",
|
||||||
|
"traffic": "Traffic",
|
||||||
|
"devices": "Devices",
|
||||||
|
"expiredDate": "Expired"
|
||||||
|
},
|
||||||
|
"trialOffer": {
|
||||||
|
"freeTitle": "Free Trial Available",
|
||||||
|
"paidTitle": "Trial Subscription",
|
||||||
|
"freeDesc": "Try our VPN for free — no commitment required",
|
||||||
|
"paidDesc": "Get started with a trial subscription"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"balance": "Balance",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"referrals": "Referrals",
|
||||||
|
"earnings": "Earnings"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Subscription",
|
"title": "Subscription",
|
||||||
@@ -261,6 +306,7 @@
|
|||||||
"buyDevices": "Buy Devices",
|
"buyDevices": "Buy Devices",
|
||||||
"renewalOptions": "Renewal Options",
|
"renewalOptions": "Renewal Options",
|
||||||
"days": "days",
|
"days": "days",
|
||||||
|
"daysShort": "d.",
|
||||||
"days_one": "{{count}} day",
|
"days_one": "{{count}} day",
|
||||||
"days_other": "{{count}} days",
|
"days_other": "{{count}} days",
|
||||||
"hours": "h",
|
"hours": "h",
|
||||||
@@ -550,6 +596,12 @@
|
|||||||
"freekassa": {
|
"freekassa": {
|
||||||
"description": "Pay via FreeKassa"
|
"description": "Pay via FreeKassa"
|
||||||
},
|
},
|
||||||
|
"freekassa_sbp": {
|
||||||
|
"description": "Pay via SBP (QR code)"
|
||||||
|
},
|
||||||
|
"freekassa_card": {
|
||||||
|
"description": "Pay with Russian bank card"
|
||||||
|
},
|
||||||
"tribute": {
|
"tribute": {
|
||||||
"description": "Pay with bank card via Tribute"
|
"description": "Pay with bank card via Tribute"
|
||||||
}
|
}
|
||||||
@@ -828,6 +880,96 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"backgrounds": {
|
||||||
|
"title": "Animated Background",
|
||||||
|
"description": "Choose a type and configure animation settings",
|
||||||
|
"selectType": "Background Type",
|
||||||
|
"preview": "Preview",
|
||||||
|
"settings": "Settings",
|
||||||
|
"save": "Save",
|
||||||
|
"saved": "Saved",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"enabled": "Background enabled",
|
||||||
|
"disabled": "Background disabled",
|
||||||
|
"reducedOnMobile": "Reduced mode on mobile",
|
||||||
|
"reducedOnMobileDesc": "Reduces particle count and disables interactivity",
|
||||||
|
"globalOpacity": "Opacity",
|
||||||
|
"globalBlur": "Blur",
|
||||||
|
"categoryCSS": "CSS",
|
||||||
|
"categoryCanvas": "Canvas",
|
||||||
|
"categorySVG": "SVG",
|
||||||
|
"aurora": "Aurora",
|
||||||
|
"auroraDesc": "Northern lights with smooth gradients",
|
||||||
|
"sparkles": "Sparkles",
|
||||||
|
"sparklesDesc": "Twinkling particles on the background",
|
||||||
|
"vortex": "Vortex",
|
||||||
|
"vortexDesc": "Spinning particle vortex",
|
||||||
|
"shootingStars": "Shooting Stars",
|
||||||
|
"shootingStarsDesc": "Falling stars on a starry sky",
|
||||||
|
"beams": "Beams",
|
||||||
|
"beamsDesc": "Light beams along SVG paths",
|
||||||
|
"beamsCollision": "Beams Collision",
|
||||||
|
"beamsCollisionDesc": "Exploding light beams",
|
||||||
|
"gradientAnimation": "Gradient Animation",
|
||||||
|
"gradientAnimationDesc": "Smoothly moving gradient blobs",
|
||||||
|
"wavy": "Wavy",
|
||||||
|
"wavyDesc": "Waves based on simplex noise",
|
||||||
|
"lines": "Lines",
|
||||||
|
"linesDesc": "Animated SVG waves",
|
||||||
|
"boxes": "Boxes",
|
||||||
|
"boxesDesc": "Grid of interactive blocks",
|
||||||
|
"meteors": "Meteors",
|
||||||
|
"meteorsDesc": "Flying meteors",
|
||||||
|
"grid": "Grid",
|
||||||
|
"gridDesc": "Minimalist grid pattern",
|
||||||
|
"dots": "Dots",
|
||||||
|
"dotsDesc": "Dot pattern",
|
||||||
|
"spotlight": "Spotlight",
|
||||||
|
"spotlightDesc": "Spotlight beam",
|
||||||
|
"noise": "Noise",
|
||||||
|
"noiseDesc": "Noise gradients with motion",
|
||||||
|
"ripple": "Ripple",
|
||||||
|
"rippleDesc": "Ripple wave effect",
|
||||||
|
"geminiEffect": "Gemini Effect",
|
||||||
|
"geminiEffectDesc": "SVG effect like Google Gemini",
|
||||||
|
"speed": "Speed",
|
||||||
|
"density": "Particle Density",
|
||||||
|
"particles": "Particle Count",
|
||||||
|
"count": "Count",
|
||||||
|
"hue": "Hue",
|
||||||
|
"rangeY": "Y Range",
|
||||||
|
"minSize": "Min Size",
|
||||||
|
"maxSize": "Max Size",
|
||||||
|
"minSpeed": "Min Speed",
|
||||||
|
"maxSpeed": "Max Speed",
|
||||||
|
"particleColor": "Particle Color",
|
||||||
|
"starColor": "Star Color",
|
||||||
|
"trailColor": "Trail Color",
|
||||||
|
"bgColor": "Background Color",
|
||||||
|
"fillColor": "Fill Color",
|
||||||
|
"color1": "Color 1",
|
||||||
|
"color2": "Color 2",
|
||||||
|
"color3": "Color 3",
|
||||||
|
"color4": "Color 4",
|
||||||
|
"color5": "Color 5",
|
||||||
|
"interactive": "Interactive (mouse)",
|
||||||
|
"size": "Size",
|
||||||
|
"radialGradient": "Radial Gradient",
|
||||||
|
"waveWidth": "Wave Width",
|
||||||
|
"blurAmount": "Blur",
|
||||||
|
"waveOpacity": "Wave Opacity",
|
||||||
|
"duration": "Duration",
|
||||||
|
"intensity": "Intensity",
|
||||||
|
"rows": "Rows",
|
||||||
|
"cols": "Columns",
|
||||||
|
"gridSize": "Grid Size",
|
||||||
|
"small": "Small",
|
||||||
|
"normal": "Normal",
|
||||||
|
"slow": "Slow",
|
||||||
|
"fast": "Fast",
|
||||||
|
"none": "No Background",
|
||||||
|
"noneDesc": "Disable animated background"
|
||||||
|
},
|
||||||
"groups": {
|
"groups": {
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"marketing": "Marketing",
|
"marketing": "Marketing",
|
||||||
|
|||||||
@@ -716,6 +716,96 @@
|
|||||||
"youWon": "شما برنده شدید"
|
"youWon": "شما برنده شدید"
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"backgrounds": {
|
||||||
|
"title": "پسزمینه متحرک",
|
||||||
|
"description": "نوع را انتخاب کنید و تنظیمات انیمیشن را پیکربندی کنید",
|
||||||
|
"selectType": "نوع پسزمینه",
|
||||||
|
"preview": "پیشنمایش",
|
||||||
|
"settings": "تنظیمات",
|
||||||
|
"save": "ذخیره",
|
||||||
|
"saved": "ذخیره شد",
|
||||||
|
"saving": "در حال ذخیره...",
|
||||||
|
"enabled": "پسزمینه فعال",
|
||||||
|
"disabled": "پسزمینه غیرفعال",
|
||||||
|
"reducedOnMobile": "حالت سبک در موبایل",
|
||||||
|
"reducedOnMobileDesc": "تعداد ذرات را کاهش داده و تعامل را غیرفعال میکند",
|
||||||
|
"globalOpacity": "شفافیت",
|
||||||
|
"globalBlur": "تاری",
|
||||||
|
"categoryCSS": "CSS",
|
||||||
|
"categoryCanvas": "Canvas",
|
||||||
|
"categorySVG": "SVG",
|
||||||
|
"aurora": "شفق قطبی",
|
||||||
|
"auroraDesc": "شفق شمالی با گرادیانهای نرم",
|
||||||
|
"sparkles": "درخشش",
|
||||||
|
"sparklesDesc": "ذرات درخشان در پسزمینه",
|
||||||
|
"vortex": "گرداب",
|
||||||
|
"vortexDesc": "گرداب چرخان ذرات",
|
||||||
|
"shootingStars": "ستارههای دنبالهدار",
|
||||||
|
"shootingStarsDesc": "ستارههای در حال سقوط در آسمان پرستاره",
|
||||||
|
"beams": "پرتوها",
|
||||||
|
"beamsDesc": "پرتوهای نوری در مسیرهای SVG",
|
||||||
|
"beamsCollision": "برخورد پرتوها",
|
||||||
|
"beamsCollisionDesc": "پرتوهای نوری منفجرشونده",
|
||||||
|
"gradientAnimation": "انیمیشن گرادیان",
|
||||||
|
"gradientAnimationDesc": "بلوبهای گرادیانی با حرکت نرم",
|
||||||
|
"wavy": "موجی",
|
||||||
|
"wavyDesc": "امواج مبتنی بر نویز",
|
||||||
|
"lines": "خطوط",
|
||||||
|
"linesDesc": "امواج SVG متحرک",
|
||||||
|
"boxes": "جعبهها",
|
||||||
|
"boxesDesc": "شبکهای از بلوکهای تعاملی",
|
||||||
|
"meteors": "شهابسنگ",
|
||||||
|
"meteorsDesc": "شهابسنگهای پرنده",
|
||||||
|
"grid": "شبکه",
|
||||||
|
"gridDesc": "الگوی شبکهای مینیمال",
|
||||||
|
"dots": "نقاط",
|
||||||
|
"dotsDesc": "الگوی نقطهای",
|
||||||
|
"spotlight": "نورافکن",
|
||||||
|
"spotlightDesc": "پرتو نورافکن",
|
||||||
|
"noise": "نویز",
|
||||||
|
"noiseDesc": "گرادیانهای نویزی با حرکت",
|
||||||
|
"ripple": "موج",
|
||||||
|
"rippleDesc": "اثر موج دایرهای",
|
||||||
|
"geminiEffect": "افکت Gemini",
|
||||||
|
"geminiEffectDesc": "افکت SVG مشابه Google Gemini",
|
||||||
|
"speed": "سرعت",
|
||||||
|
"density": "تراکم ذرات",
|
||||||
|
"particles": "تعداد ذرات",
|
||||||
|
"count": "تعداد",
|
||||||
|
"hue": "رنگمایه",
|
||||||
|
"rangeY": "محدوده Y",
|
||||||
|
"minSize": "حداقل اندازه",
|
||||||
|
"maxSize": "حداکثر اندازه",
|
||||||
|
"minSpeed": "حداقل سرعت",
|
||||||
|
"maxSpeed": "حداکثر سرعت",
|
||||||
|
"particleColor": "رنگ ذرات",
|
||||||
|
"starColor": "رنگ ستاره",
|
||||||
|
"trailColor": "رنگ دنباله",
|
||||||
|
"bgColor": "رنگ پسزمینه",
|
||||||
|
"fillColor": "رنگ پر کردن",
|
||||||
|
"color1": "رنگ ۱",
|
||||||
|
"color2": "رنگ ۲",
|
||||||
|
"color3": "رنگ ۳",
|
||||||
|
"color4": "رنگ ۴",
|
||||||
|
"color5": "رنگ ۵",
|
||||||
|
"interactive": "تعاملی (ماوس)",
|
||||||
|
"size": "اندازه",
|
||||||
|
"radialGradient": "گرادیان شعاعی",
|
||||||
|
"waveWidth": "عرض موج",
|
||||||
|
"blurAmount": "تاری",
|
||||||
|
"waveOpacity": "شفافیت موج",
|
||||||
|
"duration": "مدت زمان",
|
||||||
|
"intensity": "شدت",
|
||||||
|
"rows": "ردیفها",
|
||||||
|
"cols": "ستونها",
|
||||||
|
"gridSize": "اندازه شبکه",
|
||||||
|
"small": "کوچک",
|
||||||
|
"normal": "معمولی",
|
||||||
|
"slow": "آهسته",
|
||||||
|
"fast": "سریع",
|
||||||
|
"none": "بدون پسزمینه",
|
||||||
|
"noneDesc": "غیرفعال کردن پسزمینه متحرک"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"title": "مدیریت",
|
"title": "مدیریت",
|
||||||
"dashboard": "آمار",
|
"dashboard": "آمار",
|
||||||
|
|||||||
@@ -237,7 +237,52 @@
|
|||||||
"noActiveSubscription": "Нет активной подписки",
|
"noActiveSubscription": "Нет активной подписки",
|
||||||
"devicesUsed": "Устройств: {{used}} из {{total}}",
|
"devicesUsed": "Устройств: {{used}} из {{total}}",
|
||||||
"trafficUsed": "Трафик: {{used}} из {{total}} ГБ",
|
"trafficUsed": "Трафик: {{used}} из {{total}} ГБ",
|
||||||
"unlimitedTraffic": "Безлимитный трафик"
|
"unlimitedTraffic": "Безлимитный трафик",
|
||||||
|
"zone": {
|
||||||
|
"normal": "Норма",
|
||||||
|
"warning": "Умеренный",
|
||||||
|
"danger": "Высокий",
|
||||||
|
"critical": "Критический"
|
||||||
|
},
|
||||||
|
"connectDevice": "Подключить устройство",
|
||||||
|
"devicesConnected": "{{count}} подключено",
|
||||||
|
"devicesOfMax": "{{used}} из {{max}} подключено",
|
||||||
|
"devicesShort": "устр.",
|
||||||
|
"trafficUsage": "{{used}} / {{limit}} ГБ",
|
||||||
|
"trafficUsageTitle": "Расход трафика",
|
||||||
|
"usedTraffic": "использовано {{amount}}",
|
||||||
|
"usedSuffix": "израсходовано",
|
||||||
|
"unlimited": "Безлимит",
|
||||||
|
"unlimitedTraffic": "Безлимитный трафик",
|
||||||
|
"tariff": "Тариф",
|
||||||
|
"validUntil": "до {{date}}",
|
||||||
|
"remaining": "Осталось",
|
||||||
|
"daysRemaining": "Дней осталось",
|
||||||
|
"usageLast14Days": "Расход за 14 дней",
|
||||||
|
"maxUsage": "макс {{amount}}",
|
||||||
|
"expired": {
|
||||||
|
"title": "Подписка истекла",
|
||||||
|
"trialTitle": "Пробный период истёк",
|
||||||
|
"trialSubtitle": "Пробный период завершён",
|
||||||
|
"paidSubtitle": "Срок действия закончился",
|
||||||
|
"renew": "Продлить подписку",
|
||||||
|
"tariffs": "Тарифы",
|
||||||
|
"traffic": "Трафик",
|
||||||
|
"devices": "Устройства",
|
||||||
|
"expiredDate": "Истекла"
|
||||||
|
},
|
||||||
|
"trialOffer": {
|
||||||
|
"freeTitle": "Бесплатный пробный период",
|
||||||
|
"paidTitle": "Пробная подписка",
|
||||||
|
"freeDesc": "Попробуйте наш VPN бесплатно — без обязательств",
|
||||||
|
"paidDesc": "Начните с пробной подписки"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"balance": "Баланс",
|
||||||
|
"subscription": "Подписка",
|
||||||
|
"referrals": "Рефералы",
|
||||||
|
"earnings": "Заработок"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"subscription": {
|
"subscription": {
|
||||||
"title": "Подписка",
|
"title": "Подписка",
|
||||||
@@ -276,6 +321,7 @@
|
|||||||
"buyDevices": "Докупить устройства",
|
"buyDevices": "Докупить устройства",
|
||||||
"renewalOptions": "Варианты продления",
|
"renewalOptions": "Варианты продления",
|
||||||
"days": "дней",
|
"days": "дней",
|
||||||
|
"daysShort": "дн.",
|
||||||
"days_one": "{{count}} день",
|
"days_one": "{{count}} день",
|
||||||
"days_few": "{{count}} дня",
|
"days_few": "{{count}} дня",
|
||||||
"days_many": "{{count}} дней",
|
"days_many": "{{count}} дней",
|
||||||
@@ -578,6 +624,12 @@
|
|||||||
"freekassa": {
|
"freekassa": {
|
||||||
"description": "Оплата через FreeKassa"
|
"description": "Оплата через FreeKassa"
|
||||||
},
|
},
|
||||||
|
"freekassa_sbp": {
|
||||||
|
"description": "Оплата через СБП (QR код)"
|
||||||
|
},
|
||||||
|
"freekassa_card": {
|
||||||
|
"description": "Оплата картой РФ"
|
||||||
|
},
|
||||||
"tribute": {
|
"tribute": {
|
||||||
"description": "Оплата банковской картой через Tribute"
|
"description": "Оплата банковской картой через Tribute"
|
||||||
}
|
}
|
||||||
@@ -857,6 +909,96 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"backgrounds": {
|
||||||
|
"title": "Анимированный фон",
|
||||||
|
"description": "Выберите тип и настройте параметры анимации",
|
||||||
|
"selectType": "Тип фона",
|
||||||
|
"preview": "Предпросмотр",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"saved": "Сохранено",
|
||||||
|
"saving": "Сохранение...",
|
||||||
|
"enabled": "Фон включён",
|
||||||
|
"disabled": "Фон выключен",
|
||||||
|
"reducedOnMobile": "Облегчённый режим на мобильных",
|
||||||
|
"reducedOnMobileDesc": "Уменьшает количество частиц и отключает интерактивность",
|
||||||
|
"globalOpacity": "Прозрачность",
|
||||||
|
"globalBlur": "Размытие",
|
||||||
|
"categoryCSS": "CSS",
|
||||||
|
"categoryCanvas": "Canvas",
|
||||||
|
"categorySVG": "SVG",
|
||||||
|
"aurora": "Aurora",
|
||||||
|
"auroraDesc": "Северное сияние с плавными переливами",
|
||||||
|
"sparkles": "Sparkles",
|
||||||
|
"sparklesDesc": "Мерцающие частицы на фоне",
|
||||||
|
"vortex": "Vortex",
|
||||||
|
"vortexDesc": "Вращающийся водоворот частиц",
|
||||||
|
"shootingStars": "Shooting Stars",
|
||||||
|
"shootingStarsDesc": "Падающие звёзды на звёздном небе",
|
||||||
|
"beams": "Beams",
|
||||||
|
"beamsDesc": "Световые лучи по SVG-путям",
|
||||||
|
"beamsCollision": "Beams Collision",
|
||||||
|
"beamsCollisionDesc": "Взрывающиеся световые лучи",
|
||||||
|
"gradientAnimation": "Gradient Animation",
|
||||||
|
"gradientAnimationDesc": "Плавно движущиеся градиентные блобы",
|
||||||
|
"wavy": "Wavy",
|
||||||
|
"wavyDesc": "Волны на основе simplex noise",
|
||||||
|
"lines": "Lines",
|
||||||
|
"linesDesc": "Анимированные SVG-волны",
|
||||||
|
"boxes": "Boxes",
|
||||||
|
"boxesDesc": "Сетка интерактивных блоков",
|
||||||
|
"meteors": "Meteors",
|
||||||
|
"meteorsDesc": "Летящие метеоры",
|
||||||
|
"grid": "Grid",
|
||||||
|
"gridDesc": "Минималистичная сетка",
|
||||||
|
"dots": "Dots",
|
||||||
|
"dotsDesc": "Точечный паттерн",
|
||||||
|
"spotlight": "Spotlight",
|
||||||
|
"spotlightDesc": "Прожекторный луч",
|
||||||
|
"noise": "Noise",
|
||||||
|
"noiseDesc": "Шумовые градиенты с движением",
|
||||||
|
"ripple": "Ripple",
|
||||||
|
"rippleDesc": "Волновой эффект при клике",
|
||||||
|
"geminiEffect": "Gemini Effect",
|
||||||
|
"geminiEffectDesc": "SVG-эффект как на Google Gemini",
|
||||||
|
"speed": "Скорость",
|
||||||
|
"density": "Плотность частиц",
|
||||||
|
"particles": "Количество частиц",
|
||||||
|
"count": "Количество",
|
||||||
|
"hue": "Оттенок",
|
||||||
|
"rangeY": "Диапазон Y",
|
||||||
|
"minSize": "Мин. размер",
|
||||||
|
"maxSize": "Макс. размер",
|
||||||
|
"minSpeed": "Мин. скорость",
|
||||||
|
"maxSpeed": "Макс. скорость",
|
||||||
|
"particleColor": "Цвет частиц",
|
||||||
|
"starColor": "Цвет звёзд",
|
||||||
|
"trailColor": "Цвет следа",
|
||||||
|
"bgColor": "Цвет фона",
|
||||||
|
"fillColor": "Цвет заливки",
|
||||||
|
"color1": "Цвет 1",
|
||||||
|
"color2": "Цвет 2",
|
||||||
|
"color3": "Цвет 3",
|
||||||
|
"color4": "Цвет 4",
|
||||||
|
"color5": "Цвет 5",
|
||||||
|
"interactive": "Интерактивность (мышь)",
|
||||||
|
"size": "Размер",
|
||||||
|
"radialGradient": "Радиальный градиент",
|
||||||
|
"waveWidth": "Ширина волны",
|
||||||
|
"blurAmount": "Размытие",
|
||||||
|
"waveOpacity": "Прозрачность волн",
|
||||||
|
"duration": "Длительность",
|
||||||
|
"intensity": "Интенсивность",
|
||||||
|
"rows": "Строки",
|
||||||
|
"cols": "Столбцы",
|
||||||
|
"gridSize": "Размер сетки",
|
||||||
|
"small": "Мелкая",
|
||||||
|
"normal": "Обычная",
|
||||||
|
"slow": "Медленно",
|
||||||
|
"fast": "Быстро",
|
||||||
|
"none": "Без фона",
|
||||||
|
"noneDesc": "Отключить анимированный фон"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"title": "Админка",
|
"title": "Админка",
|
||||||
"dashboard": "Статистика",
|
"dashboard": "Статистика",
|
||||||
|
|||||||
@@ -716,6 +716,96 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"admin": {
|
"admin": {
|
||||||
|
"backgrounds": {
|
||||||
|
"title": "动画背景",
|
||||||
|
"description": "选择类型并配置动画参数",
|
||||||
|
"selectType": "背景类型",
|
||||||
|
"preview": "预览",
|
||||||
|
"settings": "设置",
|
||||||
|
"save": "保存",
|
||||||
|
"saved": "已保存",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"enabled": "背景已启用",
|
||||||
|
"disabled": "背景已禁用",
|
||||||
|
"reducedOnMobile": "移动端轻量模式",
|
||||||
|
"reducedOnMobileDesc": "减少粒子数量并禁用交互",
|
||||||
|
"globalOpacity": "透明度",
|
||||||
|
"globalBlur": "模糊",
|
||||||
|
"categoryCSS": "CSS",
|
||||||
|
"categoryCanvas": "Canvas",
|
||||||
|
"categorySVG": "SVG",
|
||||||
|
"aurora": "极光",
|
||||||
|
"auroraDesc": "柔和渐变的北极光",
|
||||||
|
"sparkles": "闪烁",
|
||||||
|
"sparklesDesc": "背景中闪烁的粒子",
|
||||||
|
"vortex": "漩涡",
|
||||||
|
"vortexDesc": "旋转的粒子漩涡",
|
||||||
|
"shootingStars": "流星",
|
||||||
|
"shootingStarsDesc": "星空中的流星",
|
||||||
|
"beams": "光束",
|
||||||
|
"beamsDesc": "沿SVG路径的光线",
|
||||||
|
"beamsCollision": "碰撞光束",
|
||||||
|
"beamsCollisionDesc": "爆炸的光束",
|
||||||
|
"gradientAnimation": "渐变动画",
|
||||||
|
"gradientAnimationDesc": "平滑移动的渐变色块",
|
||||||
|
"wavy": "波浪",
|
||||||
|
"wavyDesc": "基于噪声的波浪",
|
||||||
|
"lines": "线条",
|
||||||
|
"linesDesc": "动画SVG波浪",
|
||||||
|
"boxes": "方块",
|
||||||
|
"boxesDesc": "交互式方块网格",
|
||||||
|
"meteors": "流星雨",
|
||||||
|
"meteorsDesc": "飞行的流星",
|
||||||
|
"grid": "网格",
|
||||||
|
"gridDesc": "极简网格图案",
|
||||||
|
"dots": "圆点",
|
||||||
|
"dotsDesc": "圆点图案",
|
||||||
|
"spotlight": "聚光灯",
|
||||||
|
"spotlightDesc": "聚光灯光束",
|
||||||
|
"noise": "噪声",
|
||||||
|
"noiseDesc": "带运动的噪声渐变",
|
||||||
|
"ripple": "涟漪",
|
||||||
|
"rippleDesc": "涟漪波纹效果",
|
||||||
|
"geminiEffect": "Gemini效果",
|
||||||
|
"geminiEffectDesc": "类似Google Gemini的SVG效果",
|
||||||
|
"speed": "速度",
|
||||||
|
"density": "粒子密度",
|
||||||
|
"particles": "粒子数量",
|
||||||
|
"count": "数量",
|
||||||
|
"hue": "色调",
|
||||||
|
"rangeY": "Y范围",
|
||||||
|
"minSize": "最小尺寸",
|
||||||
|
"maxSize": "最大尺寸",
|
||||||
|
"minSpeed": "最小速度",
|
||||||
|
"maxSpeed": "最大速度",
|
||||||
|
"particleColor": "粒子颜色",
|
||||||
|
"starColor": "星星颜色",
|
||||||
|
"trailColor": "尾迹颜色",
|
||||||
|
"bgColor": "背景颜色",
|
||||||
|
"fillColor": "填充颜色",
|
||||||
|
"color1": "颜色1",
|
||||||
|
"color2": "颜色2",
|
||||||
|
"color3": "颜色3",
|
||||||
|
"color4": "颜色4",
|
||||||
|
"color5": "颜色5",
|
||||||
|
"interactive": "交互(鼠标)",
|
||||||
|
"size": "大小",
|
||||||
|
"radialGradient": "径向渐变",
|
||||||
|
"waveWidth": "波浪宽度",
|
||||||
|
"blurAmount": "模糊",
|
||||||
|
"waveOpacity": "波浪透明度",
|
||||||
|
"duration": "持续时间",
|
||||||
|
"intensity": "强度",
|
||||||
|
"rows": "行",
|
||||||
|
"cols": "列",
|
||||||
|
"gridSize": "网格大小",
|
||||||
|
"small": "小",
|
||||||
|
"normal": "普通",
|
||||||
|
"slow": "慢",
|
||||||
|
"fast": "快",
|
||||||
|
"none": "无背景",
|
||||||
|
"noneDesc": "禁用动画背景"
|
||||||
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"title": "管理",
|
"title": "管理",
|
||||||
"dashboard": "统计",
|
"dashboard": "统计",
|
||||||
|
|||||||
@@ -92,8 +92,12 @@ if (!alreadyInitialized) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preload logo from cache immediately on page load
|
// Preload logo from cache — defer to idle time so it doesn't compete with LCP
|
||||||
initLogoPreload();
|
if ('requestIdleCallback' in window) {
|
||||||
|
requestIdleCallback(() => initLogoPreload());
|
||||||
|
} else {
|
||||||
|
setTimeout(initLogoPreload, 100);
|
||||||
|
}
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ const METHOD_LABELS: Record<string, string> = {
|
|||||||
platega: 'Platega',
|
platega: 'Platega',
|
||||||
wata: 'WATA',
|
wata: 'WATA',
|
||||||
freekassa: 'Freekassa',
|
freekassa: 'Freekassa',
|
||||||
|
freekassa_sbp: 'Freekassa СБП',
|
||||||
|
freekassa_card: 'Freekassa Карта',
|
||||||
cloudpayments: 'CloudPayments',
|
cloudpayments: 'CloudPayments',
|
||||||
kassa_ai: 'Kassa AI',
|
kassa_ai: 'Kassa AI',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { useBlockingStore } from '../store/blocking';
|
import { useBlockingStore } from '../store/blocking';
|
||||||
@@ -10,53 +10,27 @@ import { balanceApi } from '../api/balance';
|
|||||||
import { wheelApi } from '../api/wheel';
|
import { wheelApi } from '../api/wheel';
|
||||||
import Onboarding, { useOnboarding } from '../components/Onboarding';
|
import Onboarding, { useOnboarding } from '../components/Onboarding';
|
||||||
import PromoOffersSection from '../components/PromoOffersSection';
|
import PromoOffersSection from '../components/PromoOffersSection';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActive';
|
||||||
|
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
|
||||||
|
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
|
||||||
|
import StatsGrid from '../components/dashboard/StatsGrid';
|
||||||
import { API } from '../config/constants';
|
import { API } from '../config/constants';
|
||||||
|
|
||||||
// Icons
|
|
||||||
const ArrowRightIcon = () => (
|
|
||||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const SparklesIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ChevronRightIcon = () => (
|
const ChevronRightIcon = () => (
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
|
|
||||||
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const refreshUser = useAuthStore((state) => state.refreshUser);
|
const refreshUser = useAuthStore((state) => state.refreshUser);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
|
||||||
const [trialError, setTrialError] = useState<string | null>(null);
|
|
||||||
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
|
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||||
const blockingType = useBlockingStore((state) => state.blockingType);
|
const blockingType = useBlockingStore((state) => state.blockingType);
|
||||||
|
const [trialError, setTrialError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Refresh user data on mount
|
// Refresh user data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,7 +53,6 @@ export default function Dashboard() {
|
|||||||
refetchOnMount: 'always',
|
refetchOnMount: 'always',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract subscription from response (null if no subscription)
|
|
||||||
const subscription = subscriptionResponse?.subscription ?? null;
|
const subscription = subscriptionResponse?.subscription ?? null;
|
||||||
|
|
||||||
const { data: trialInfo, isLoading: trialLoading } = useQuery({
|
const { data: trialInfo, isLoading: trialLoading } = useQuery({
|
||||||
@@ -88,16 +61,22 @@ export default function Dashboard() {
|
|||||||
enabled: !subscription && !subLoading,
|
enabled: !subscription && !subLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: devicesData } = useQuery({
|
||||||
|
queryKey: ['devices'],
|
||||||
|
queryFn: subscriptionApi.getDevices,
|
||||||
|
enabled: !!subscription,
|
||||||
|
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: referralInfo, isLoading: refLoading } = useQuery({
|
const { data: referralInfo, isLoading: refLoading } = useQuery({
|
||||||
queryKey: ['referral-info'],
|
queryKey: ['referral-info'],
|
||||||
queryFn: referralApi.getReferralInfo,
|
queryFn: referralApi.getReferralInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch wheel config to show banner if enabled
|
|
||||||
const { data: wheelConfig } = useQuery({
|
const { data: wheelConfig } = useQuery({
|
||||||
queryKey: ['wheel-config'],
|
queryKey: ['wheel-config'],
|
||||||
queryFn: wheelApi.getConfig,
|
queryFn: wheelApi.getConfig,
|
||||||
staleTime: 60000, // 1 minute
|
staleTime: 60000,
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,7 +110,6 @@ export default function Dashboard() {
|
|||||||
traffic_used_percent: data.traffic_used_percent,
|
traffic_used_percent: data.traffic_used_percent,
|
||||||
is_unlimited: data.is_unlimited,
|
is_unlimited: data.is_unlimited,
|
||||||
});
|
});
|
||||||
// Save last refresh timestamp to localStorage
|
|
||||||
localStorage.setItem('traffic_refresh_ts', Date.now().toString());
|
localStorage.setItem('traffic_refresh_ts', Date.now().toString());
|
||||||
if (data.rate_limited && data.retry_after_seconds) {
|
if (data.rate_limited && data.retry_after_seconds) {
|
||||||
setTrafficRefreshCooldown(data.retry_after_seconds);
|
setTrafficRefreshCooldown(data.retry_after_seconds);
|
||||||
@@ -159,10 +137,9 @@ export default function Dashboard() {
|
|||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [trafficRefreshCooldown]);
|
}, [trafficRefreshCooldown]);
|
||||||
|
|
||||||
// Track if we've already triggered auto-refresh this session
|
// Auto-refresh traffic on mount (with 30s caching)
|
||||||
const hasAutoRefreshed = useRef(false);
|
const hasAutoRefreshed = useRef(false);
|
||||||
|
|
||||||
// Auto-refresh traffic on mount (with 30s caching)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subscription) return;
|
if (!subscription) return;
|
||||||
if (hasAutoRefreshed.current) return;
|
if (hasAutoRefreshed.current) return;
|
||||||
@@ -184,20 +161,16 @@ export default function Dashboard() {
|
|||||||
refreshTrafficMutation.mutate();
|
refreshTrafficMutation.mutate();
|
||||||
}, [subscription, refreshTrafficMutation]);
|
}, [subscription, refreshTrafficMutation]);
|
||||||
|
|
||||||
// User has no subscription if API returns has_subscription: false
|
|
||||||
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
|
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
|
||||||
|
|
||||||
// Show onboarding for new users after data loads
|
// Show onboarding for new users after data loads
|
||||||
// Skip if a blocking screen is active (channel subscription, maintenance, etc.)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
|
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
|
||||||
// Small delay to ensure DOM is ready
|
|
||||||
const timer = setTimeout(() => setShowOnboarding(true), 500);
|
const timer = setTimeout(() => setShowOnboarding(true), 500);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isOnboardingCompleted, subLoading, refLoading, blockingType]);
|
}, [isOnboardingCompleted, subLoading, refLoading, blockingType]);
|
||||||
|
|
||||||
// Define onboarding steps based on available data
|
|
||||||
const onboardingSteps = useMemo(() => {
|
const onboardingSteps = useMemo(() => {
|
||||||
type Placement = 'top' | 'bottom' | 'left' | 'right';
|
type Placement = 'top' | 'bottom' | 'left' | 'right';
|
||||||
const steps: Array<{
|
const steps: Array<{
|
||||||
@@ -218,15 +191,8 @@ export default function Dashboard() {
|
|||||||
description: t('onboarding.steps.balance.description'),
|
description: t('onboarding.steps.balance.description'),
|
||||||
placement: 'bottom',
|
placement: 'bottom',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
target: 'subscription-status',
|
|
||||||
title: t('onboarding.steps.subscription.title'),
|
|
||||||
description: t('onboarding.steps.subscription.description'),
|
|
||||||
placement: 'bottom',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add connect devices step only if subscription exists
|
|
||||||
if (subscription?.subscription_url) {
|
if (subscription?.subscription_url) {
|
||||||
steps.splice(1, 0, {
|
steps.splice(1, 0, {
|
||||||
target: 'connect-devices',
|
target: 'connect-devices',
|
||||||
@@ -244,13 +210,6 @@ export default function Dashboard() {
|
|||||||
completeOnboarding();
|
completeOnboarding();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate traffic percentage color
|
|
||||||
const getTrafficColor = (percent: number) => {
|
|
||||||
if (percent > API.TRAFFIC_CRITICAL_PERCENT) return 'bg-error-500';
|
|
||||||
if (percent > API.TRAFFIC_WARN_PERCENT) return 'bg-warning-500';
|
|
||||||
return 'bg-success-500';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -261,370 +220,59 @@ export default function Dashboard() {
|
|||||||
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
<p className="mt-1 text-dark-400">{t('dashboard.yourSubscription')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Subscription Status - Main Card */}
|
{/* Subscription Status Card */}
|
||||||
{subLoading ? (
|
{subLoading ? (
|
||||||
<div className="bento-card">
|
<div className="bento-card">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="skeleton h-6 w-24" />
|
<div className="skeleton h-5 w-20" />
|
||||||
<div className="skeleton h-6 w-16 rounded-full" />
|
<div className="skeleton h-6 w-16 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
<div className="skeleton mb-3 h-10 w-32" />
|
||||||
{[...Array(4)].map((_, i) => (
|
<div className="skeleton mb-3 h-4 w-40" />
|
||||||
<div key={i}>
|
<div className="skeleton h-3 w-full rounded-full" />
|
||||||
<div className="skeleton mb-2 h-4 w-20" />
|
<div className="mt-5">
|
||||||
<div className="skeleton h-5 w-24" />
|
<div className="skeleton h-12 w-full rounded-xl" />
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="skeleton h-2 w-full rounded-full" />
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
|
||||||
<div className="skeleton h-10 w-full rounded-xl" />
|
|
||||||
<div className="skeleton h-10 w-full rounded-xl" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : subscription?.is_expired ? (
|
||||||
|
<SubscriptionCardExpired subscription={subscription} />
|
||||||
) : subscription ? (
|
) : subscription ? (
|
||||||
<div
|
<SubscriptionCardActive
|
||||||
className={`bento-card ${subscription.is_trial ? 'border-warning-500/30 bg-gradient-to-br from-warning-500/5 to-transparent' : ''}`}
|
subscription={subscription}
|
||||||
>
|
trafficData={trafficData}
|
||||||
<div className="mb-6 flex items-center justify-between">
|
refreshTrafficMutation={refreshTrafficMutation}
|
||||||
<div>
|
trafficRefreshCooldown={trafficRefreshCooldown}
|
||||||
<h2 className="text-lg font-semibold text-dark-100">{t('subscription.status')}</h2>
|
connectedDevices={devicesData?.total ?? 0}
|
||||||
{subscription.tariff_name && (
|
|
||||||
<div className="mt-1 text-sm text-accent-400">{subscription.tariff_name}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
subscription.is_trial
|
|
||||||
? 'badge-warning'
|
|
||||||
: subscription.is_active
|
|
||||||
? 'badge-success'
|
|
||||||
: 'badge-error'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{subscription.is_trial
|
|
||||||
? t('subscription.trialStatus')
|
|
||||||
: subscription.is_active
|
|
||||||
? t('subscription.active')
|
|
||||||
: t('subscription.expired')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trial Info Banner */}
|
|
||||||
{subscription.is_trial && subscription.is_active && (
|
|
||||||
<div className="mb-6 rounded-xl border border-warning-500/30 bg-warning-500/10 p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-warning-500/20 text-xl">
|
|
||||||
<SparklesIcon />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-warning-300">
|
|
||||||
{t('subscription.trialBanner.title')}
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-sm text-dark-400">
|
|
||||||
{t('subscription.trialBanner.description', { days: subscription.days_left })}
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
to="/subscription"
|
|
||||||
state={{ scrollToExtend: true }}
|
|
||||||
className="mt-3 inline-flex items-center gap-1.5 text-sm font-medium text-warning-400 transition-colors hover:text-warning-300"
|
|
||||||
>
|
|
||||||
{t('subscription.trialBanner.upgrade')}
|
|
||||||
<ArrowRightIcon />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.expiresAt')}</div>
|
|
||||||
<div className="font-medium text-dark-100">
|
|
||||||
{new Date(subscription.end_date).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 flex items-center gap-2">
|
|
||||||
<span className="text-sm text-dark-500">{t('subscription.traffic')}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => refreshTrafficMutation.mutate()}
|
|
||||||
disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0}
|
|
||||||
className="rounded-full p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-accent-400 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
title={
|
|
||||||
trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RefreshIcon
|
|
||||||
className={`h-3.5 w-3.5 ${refreshTrafficMutation.isPending ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="font-medium text-dark-100">
|
|
||||||
{(trafficData?.traffic_used_gb ?? subscription.traffic_used_gb).toFixed(1)} /{' '}
|
|
||||||
{subscription.traffic_limit_gb || '∞'} GB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.devices')}</div>
|
|
||||||
<div className="font-medium text-dark-100">{subscription.device_limit}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
|
|
||||||
<div className="font-medium text-dark-100">
|
|
||||||
{subscription.days_left > 0
|
|
||||||
? t('subscription.days', { count: subscription.days_left })
|
|
||||||
: `${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Traffic Progress */}
|
|
||||||
{subscription.traffic_limit_gb > 0 && (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="mb-2 flex justify-between text-sm">
|
|
||||||
<span className="text-dark-400">{t('subscription.trafficUsed')}</span>
|
|
||||||
<span className="text-dark-300">
|
|
||||||
{(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent).toFixed(
|
|
||||||
1,
|
|
||||||
)}
|
|
||||||
%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="progress-bar">
|
|
||||||
<div
|
|
||||||
className={`progress-fill ${getTrafficColor(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent)}`}
|
|
||||||
style={{
|
|
||||||
width: `${Math.min(trafficData?.traffic_used_percent ?? subscription.traffic_used_percent, 100)}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`mt-6 grid gap-3 ${subscription.subscription_url ? 'grid-cols-2' : 'grid-cols-1'}`}
|
|
||||||
>
|
|
||||||
<Link to="/subscription" className="btn-primary py-2.5 text-center text-sm">
|
|
||||||
{t('dashboard.viewSubscription')}
|
|
||||||
</Link>
|
|
||||||
{subscription.subscription_url && (
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/connection')}
|
|
||||||
className="btn-secondary py-2.5 text-sm"
|
|
||||||
data-onboarding="connect-devices"
|
|
||||||
>
|
|
||||||
{t('subscription.getConfig')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Promo Offers - показываем сразу после карточки подписки */}
|
|
||||||
<PromoOffersSection />
|
|
||||||
|
|
||||||
{/* Stats Grid */}
|
|
||||||
<div className="bento-grid">
|
|
||||||
{/* Balance */}
|
|
||||||
<Link to="/balance" className="bento-card-hover group" data-onboarding="balance">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm text-dark-400">{t('balance.currentBalance')}</span>
|
|
||||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
||||||
<ArrowRightIcon />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="stat-value text-accent-400">
|
|
||||||
{formatAmount(balanceData?.balance_rubles || 0)}
|
|
||||||
<span className="ml-1 text-lg text-dark-400">{currencySymbol}</span>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Subscription */}
|
|
||||||
<Link
|
|
||||||
to="/subscription"
|
|
||||||
className="bento-card-hover group"
|
|
||||||
data-onboarding="subscription-status"
|
|
||||||
>
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm text-dark-400">{t('subscription.title')}</span>
|
|
||||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
||||||
<ArrowRightIcon />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{subLoading ? (
|
|
||||||
<div className="skeleton h-8 w-24" />
|
|
||||||
) : subscription ? (
|
|
||||||
<div className="stat-value">
|
|
||||||
{subscription.days_left > 0 ? (
|
|
||||||
<>
|
|
||||||
{subscription.days_left}
|
|
||||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.days')}</span>
|
|
||||||
</>
|
|
||||||
) : subscription.hours_left > 0 ? (
|
|
||||||
<>
|
|
||||||
{subscription.hours_left}
|
|
||||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.hours')}</span>
|
|
||||||
</>
|
|
||||||
) : subscription.minutes_left > 0 ? (
|
|
||||||
<>
|
|
||||||
{subscription.minutes_left}
|
|
||||||
<span className="ml-1 text-lg text-dark-400">{t('subscription.minutes')}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="stat-value text-error-400">{t('subscription.inactive')}</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Referrals */}
|
|
||||||
<Link to="/referral" className="bento-card-hover group">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm text-dark-400">{t('referral.stats.totalReferrals')}</span>
|
|
||||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
||||||
<ArrowRightIcon />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{refLoading ? (
|
|
||||||
<div className="skeleton h-8 w-16" />
|
|
||||||
) : (
|
|
||||||
<div className="stat-value">{referralInfo?.total_referrals || 0}</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Earnings */}
|
|
||||||
<Link to="/referral" className="bento-card-hover group">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<span className="text-sm text-dark-400">{t('referral.stats.totalEarnings')}</span>
|
|
||||||
<span className="text-dark-600 transition-colors group-hover:text-accent-400">
|
|
||||||
<ArrowRightIcon />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{refLoading ? (
|
|
||||||
<div className="skeleton h-8 w-20" />
|
|
||||||
) : (
|
|
||||||
<div className="stat-value text-success-400">
|
|
||||||
{formatPositive(referralInfo?.total_earnings_rubles || 0)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Trial Activation */}
|
{/* Trial Activation */}
|
||||||
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
{hasNoSubscription && !trialLoading && trialInfo?.is_available && (
|
||||||
<div className="bento-card-glow border-accent-500/30 bg-gradient-to-br from-accent-500/5 to-transparent">
|
<TrialOfferCard
|
||||||
<div className="flex items-start gap-4">
|
trialInfo={trialInfo}
|
||||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
|
balanceKopeks={balanceData?.balance_kopeks || 0}
|
||||||
<SparklesIcon />
|
balanceRubles={balanceData?.balance_rubles || 0}
|
||||||
</div>
|
activateTrialMutation={activateTrialMutation}
|
||||||
<div className="flex-1">
|
trialError={trialError}
|
||||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
/>
|
||||||
{trialInfo.requires_payment
|
|
||||||
? t('subscription.trial.titlePaid', 'Trial Subscription')
|
|
||||||
: t('subscription.trial.title', 'Free Trial')}
|
|
||||||
</h3>
|
|
||||||
<p className="mb-4 text-sm text-dark-400">
|
|
||||||
{t('subscription.trial.description', 'Try our VPN service for free!')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mb-6 flex gap-6">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-accent-400">
|
|
||||||
{trialInfo.duration_days}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-dark-500">{t('subscription.trial.days')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-accent-400">
|
|
||||||
{trialInfo.traffic_limit_gb || '∞'}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-dark-500">GB</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-2xl font-bold text-accent-400">{trialInfo.device_limit}</div>
|
|
||||||
<div className="text-xs text-dark-500">{t('subscription.trial.devices')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{trialInfo.requires_payment && trialInfo.price_rubles > 0 && (
|
|
||||||
<div className="mb-4 space-y-2 rounded-xl bg-dark-800/50 p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-dark-400">
|
|
||||||
{t('subscription.trial.price', 'Activation price')}:
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-semibold text-accent-400">
|
|
||||||
{trialInfo.price_rubles.toFixed(2)} {currencySymbol}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-dark-400">
|
|
||||||
{t('balance.currentBalance', 'Your balance')}:
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-lg font-semibold ${(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? 'text-success-400' : 'text-warning-400'}`}
|
|
||||||
>
|
|
||||||
{formatAmount(balanceData?.balance_rubles || 0)} {currencySymbol}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{(balanceData?.balance_kopeks || 0) < trialInfo.price_kopeks && (
|
|
||||||
<div className="pt-1 text-xs text-warning-400">
|
|
||||||
{t(
|
|
||||||
'subscription.trial.insufficientBalance',
|
|
||||||
'Top up your balance to activate',
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{trialError && (
|
{/* Promo Offers */}
|
||||||
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
<PromoOffersSection />
|
||||||
{trialError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{trialInfo.requires_payment && trialInfo.price_kopeks > 0 ? (
|
{/* Stats Grid */}
|
||||||
(balanceData?.balance_kopeks || 0) >= trialInfo.price_kopeks ? (
|
<StatsGrid
|
||||||
<button
|
balanceRubles={balanceData?.balance_rubles || 0}
|
||||||
onClick={() => activateTrialMutation.mutate()}
|
subscription={subscription}
|
||||||
disabled={activateTrialMutation.isPending}
|
referralCount={referralInfo?.total_referrals || 0}
|
||||||
className="btn-primary w-full"
|
earningsRubles={referralInfo?.total_earnings_rubles || 0}
|
||||||
>
|
refLoading={refLoading}
|
||||||
{activateTrialMutation.isPending
|
/>
|
||||||
? t('common.loading', 'Loading...')
|
|
||||||
: t('subscription.trial.payAndActivate', 'Pay from Balance & Activate')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<Link to="/balance" className="btn-primary block w-full text-center">
|
|
||||||
{t('subscription.trial.topUpToActivate', 'Top Up Balance')}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => activateTrialMutation.mutate()}
|
|
||||||
disabled={activateTrialMutation.isPending}
|
|
||||||
className="btn-primary w-full"
|
|
||||||
>
|
|
||||||
{activateTrialMutation.isPending
|
|
||||||
? t('common.loading', 'Loading...')
|
|
||||||
: t('subscription.trial.activate', 'Activate Free Trial')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Fortune Wheel Banner */}
|
{/* Fortune Wheel Banner */}
|
||||||
{wheelConfig?.is_enabled && (
|
{wheelConfig?.is_enabled && (
|
||||||
<Link to="/wheel" className="bento-card-hover group flex items-center justify-between">
|
<Link to="/wheel" className="bento-card-hover group flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* Emoji */}
|
|
||||||
<span className="text-3xl">🎰</span>
|
<span className="text-3xl">🎰</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-base font-semibold text-dark-100">{t('wheel.banner.title')}</h3>
|
<h3 className="text-base font-semibold text-dark-100">{t('wheel.banner.title')}</h3>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,63 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap');
|
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Animated gradient border — @property enables CSS-only angle animation */
|
||||||
|
@property --border-angle {
|
||||||
|
syntax: '<angle>';
|
||||||
|
initial-value: 0deg;
|
||||||
|
inherits: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-border-gradient {
|
||||||
|
--_bg: var(--border-inner-bg, rgb(var(--color-dark-900)));
|
||||||
|
--_accent: var(--accent-color, rgb(var(--color-accent-400)));
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--_bg), var(--_bg)) padding-box,
|
||||||
|
conic-gradient(
|
||||||
|
from var(--border-angle),
|
||||||
|
var(--_accent) 0%,
|
||||||
|
transparent 25%,
|
||||||
|
transparent 75%,
|
||||||
|
var(--_accent) 100%
|
||||||
|
)
|
||||||
|
border-box;
|
||||||
|
animation: border-rotate var(--border-duration, 3s) linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-border-gradient:hover,
|
||||||
|
.hover-border-gradient:active {
|
||||||
|
box-shadow: 0 0 20px var(--accent-glow, rgba(var(--color-accent-400), 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
.light .hover-border-gradient {
|
||||||
|
--_bg: var(--border-inner-bg, rgb(var(--color-champagne-100)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hover-border-gradient {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes border-rotate {
|
||||||
|
to {
|
||||||
|
--border-angle: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aceternity UI background CSS variables */
|
||||||
|
:root {
|
||||||
|
--blue-500: #3b82f6;
|
||||||
|
--indigo-300: #a5b4fc;
|
||||||
|
--blue-300: #93c5fd;
|
||||||
|
--violet-200: #ddd6fe;
|
||||||
|
--blue-400: #60a5fa;
|
||||||
|
--black: #000000;
|
||||||
|
--white: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
@@ -1445,174 +1499,6 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== ANIMATED BACKGROUND (GPU Optimized) ========== */
|
|
||||||
.wave-bg-container {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
contain: strict; /* Performance: isolate repaints */
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-blob {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
filter: blur(60px);
|
|
||||||
opacity: 0.5;
|
|
||||||
transform: translateZ(0);
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Smaller sizes for better performance */
|
|
||||||
.wave-blob-1 {
|
|
||||||
width: 400px;
|
|
||||||
height: 400px;
|
|
||||||
background: radial-gradient(circle, rgba(var(--color-accent-800), 0.6) 0%, transparent 70%);
|
|
||||||
top: -15%;
|
|
||||||
left: -10%;
|
|
||||||
animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-blob-2 {
|
|
||||||
width: 350px;
|
|
||||||
height: 350px;
|
|
||||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.6) 0%, transparent 70%);
|
|
||||||
bottom: -10%;
|
|
||||||
right: -10%;
|
|
||||||
animation: wave2 25s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-blob-3 {
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
background: radial-gradient(circle, rgba(168, 85, 247, 0.4) 0%, transparent 70%);
|
|
||||||
top: 40%;
|
|
||||||
left: 40%;
|
|
||||||
animation: wave3 30s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-blob-4 {
|
|
||||||
width: 250px;
|
|
||||||
height: 250px;
|
|
||||||
background: radial-gradient(circle, rgba(236, 72, 153, 0.35) 0%, transparent 70%);
|
|
||||||
bottom: 25%;
|
|
||||||
left: 15%;
|
|
||||||
animation: wave4 22s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Simplified keyframes - fewer steps = better performance */
|
|
||||||
@keyframes wave1 {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate3d(8%, 12%, 0) scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wave2 {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate3d(-10%, -8%, 0) scale(1.08);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wave3 {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(-50%, -50%, 0) scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate3d(-45%, -55%, 0) scale(1.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wave4 {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate3d(12%, -8%, 0) scale(1.12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Light theme adjustments */
|
|
||||||
.light .wave-blob {
|
|
||||||
opacity: 0.35;
|
|
||||||
filter: blur(80px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.light .wave-blob-1 {
|
|
||||||
background: radial-gradient(circle, rgba(var(--color-accent-800), 0.7) 0%, transparent 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: brighter and faster animations */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.wave-blob {
|
|
||||||
opacity: 0.7;
|
|
||||||
filter: blur(50px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-blob-1 {
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
background: radial-gradient(circle, rgba(var(--color-accent-800), 0.8) 0%, transparent 70%);
|
|
||||||
animation: wave1-mobile 12s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wave-blob-2 {
|
|
||||||
width: 280px;
|
|
||||||
height: 280px;
|
|
||||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%);
|
|
||||||
animation: wave2-mobile 15s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wave1-mobile {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
transform: translate3d(15%, 20%, 0) scale(1.1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate3d(5%, 35%, 0) scale(1.15);
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
transform: translate3d(20%, 10%, 0) scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes wave2-mobile {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translate3d(0, 0, 0) scale(1);
|
|
||||||
}
|
|
||||||
25% {
|
|
||||||
transform: translate3d(-20%, -15%, 0) scale(1.15);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translate3d(-10%, -30%, 0) scale(1.1);
|
|
||||||
}
|
|
||||||
75% {
|
|
||||||
transform: translate3d(-25%, -5%, 0) scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable animations for reduced motion preference */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.wave-blob {
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User preference: reduce motion */
|
/* User preference: reduce motion */
|
||||||
.reduce-motion,
|
.reduce-motion,
|
||||||
.reduce-motion * {
|
.reduce-motion * {
|
||||||
|
|||||||
9
src/utils/formatTraffic.ts
Normal file
9
src/utils/formatTraffic.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Format traffic amount with appropriate unit (MB/GB/TB).
|
||||||
|
* Matches the prototype's formatBytes logic.
|
||||||
|
*/
|
||||||
|
export function formatTraffic(gb: number): string {
|
||||||
|
if (gb >= 1000) return `${(gb / 1000).toFixed(1)} TB`;
|
||||||
|
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||||||
|
return `${(gb * 1024).toFixed(0)} MB`;
|
||||||
|
}
|
||||||
43
src/utils/glassTheme.ts
Normal file
43
src/utils/glassTheme.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Theme-aware glass morphism color tokens.
|
||||||
|
* Provides consistent colors for the glassmorphic card components
|
||||||
|
* that work on both dark and light backgrounds.
|
||||||
|
*/
|
||||||
|
export function getGlassColors(isDark: boolean) {
|
||||||
|
return {
|
||||||
|
// Card container
|
||||||
|
cardBg: isDark
|
||||||
|
? 'linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)'
|
||||||
|
: 'linear-gradient(145deg, rgba(255,255,255,0.85) 0%, rgba(255,255,255,0.6) 100%)',
|
||||||
|
cardBorder: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)',
|
||||||
|
|
||||||
|
// Inner sections (cards within cards)
|
||||||
|
innerBg: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)',
|
||||||
|
innerBorder: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)',
|
||||||
|
|
||||||
|
// Hover states
|
||||||
|
hoverBg: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)',
|
||||||
|
hoverBorder: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.1)',
|
||||||
|
|
||||||
|
// Text
|
||||||
|
text: isDark ? '#fff' : '#1a1a2e',
|
||||||
|
textSecondary: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.5)',
|
||||||
|
textMuted: isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.35)',
|
||||||
|
textFaint: isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)',
|
||||||
|
textGhost: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
|
||||||
|
|
||||||
|
// Progress bar track
|
||||||
|
trackBg: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.06)',
|
||||||
|
trackBorder: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)',
|
||||||
|
|
||||||
|
// Code blocks
|
||||||
|
codeBg: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.04)',
|
||||||
|
codeBorder: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.06)',
|
||||||
|
|
||||||
|
// Glow effects — reduced in light mode
|
||||||
|
glowAlpha: isDark ? '15' : '08',
|
||||||
|
|
||||||
|
// Shadows for light mode depth
|
||||||
|
shadow: isDark ? 'none' : '0 2px 12px rgba(0,0,0,0.06)',
|
||||||
|
};
|
||||||
|
}
|
||||||
62
src/utils/trafficZone.ts
Normal file
62
src/utils/trafficZone.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export type TrafficZone = 'normal' | 'warning' | 'danger' | 'critical';
|
||||||
|
|
||||||
|
interface TrafficZoneResult {
|
||||||
|
zone: TrafficZone;
|
||||||
|
textClass: string;
|
||||||
|
dotClass: string;
|
||||||
|
glowColor: string;
|
||||||
|
labelKey: string;
|
||||||
|
gradientFrom: string;
|
||||||
|
gradientTo: string;
|
||||||
|
/** Hex color for inline styles */
|
||||||
|
mainHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZONES: Record<TrafficZone, Omit<TrafficZoneResult, 'zone'>> = {
|
||||||
|
normal: {
|
||||||
|
textClass: 'text-success-400',
|
||||||
|
dotClass: 'bg-success-400',
|
||||||
|
glowColor: 'rgba(var(--color-success-500), 0.5)',
|
||||||
|
labelKey: 'dashboard.zone.normal',
|
||||||
|
gradientFrom: 'rgb(var(--color-success-500))',
|
||||||
|
gradientTo: 'rgb(var(--color-success-400))',
|
||||||
|
mainHex: '#00E5A0',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
textClass: 'text-warning-400',
|
||||||
|
dotClass: 'bg-warning-400',
|
||||||
|
glowColor: 'rgba(var(--color-warning-500), 0.5)',
|
||||||
|
labelKey: 'dashboard.zone.warning',
|
||||||
|
gradientFrom: 'rgb(var(--color-warning-500))',
|
||||||
|
gradientTo: 'rgb(var(--color-warning-400))',
|
||||||
|
mainHex: '#FFB800',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
textClass: 'text-warning-300',
|
||||||
|
dotClass: 'bg-warning-300',
|
||||||
|
glowColor: 'rgba(var(--color-warning-400), 0.5)',
|
||||||
|
labelKey: 'dashboard.zone.danger',
|
||||||
|
gradientFrom: 'rgb(var(--color-warning-600))',
|
||||||
|
gradientTo: 'rgb(var(--color-warning-400))',
|
||||||
|
mainHex: '#FF6B35',
|
||||||
|
},
|
||||||
|
critical: {
|
||||||
|
textClass: 'text-error-400',
|
||||||
|
dotClass: 'bg-error-400',
|
||||||
|
glowColor: 'rgba(var(--color-error-500), 0.5)',
|
||||||
|
labelKey: 'dashboard.zone.critical',
|
||||||
|
gradientFrom: 'rgb(var(--color-error-500))',
|
||||||
|
gradientTo: 'rgb(var(--color-error-400))',
|
||||||
|
mainHex: '#FF3B5C',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTrafficZone(percent: number): TrafficZoneResult {
|
||||||
|
let zone: TrafficZone;
|
||||||
|
if (percent >= 90) zone = 'critical';
|
||||||
|
else if (percent >= 75) zone = 'danger';
|
||||||
|
else if (percent >= 50) zone = 'warning';
|
||||||
|
else zone = 'normal';
|
||||||
|
|
||||||
|
return { zone, ...ZONES[zone] };
|
||||||
|
}
|
||||||
@@ -112,6 +112,8 @@ export default {
|
|||||||
'Roboto',
|
'Roboto',
|
||||||
'sans-serif',
|
'sans-serif',
|
||||||
],
|
],
|
||||||
|
display: ['Outfit', 'Manrope', 'system-ui', 'sans-serif'],
|
||||||
|
mono: ['IBM Plex Mono', 'ui-monospace', 'monospace'],
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
bento: '24px',
|
bento: '24px',
|
||||||
@@ -164,6 +166,20 @@ export default {
|
|||||||
float: 'float 3s ease-in-out infinite',
|
float: 'float 3s ease-in-out infinite',
|
||||||
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
|
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
|
||||||
spotlight: 'spotlight 2s ease-in-out infinite',
|
spotlight: 'spotlight 2s ease-in-out infinite',
|
||||||
|
// Aceternity UI background animations
|
||||||
|
aurora: 'aurora 60s linear infinite',
|
||||||
|
'meteor-effect': 'meteor 5s linear infinite',
|
||||||
|
'move-vertical': 'moveVertical 30s ease infinite',
|
||||||
|
'move-in-circle': 'moveInCircle 20s reverse infinite',
|
||||||
|
'move-in-circle-slow': 'moveInCircle 40s linear infinite',
|
||||||
|
'move-horizontal': 'moveHorizontal 40s ease infinite',
|
||||||
|
'move-in-circle-fast': 'moveInCircle 20s ease infinite',
|
||||||
|
'spotlight-ace': 'spotlightAce 2s ease 0.75s 1 forwards',
|
||||||
|
// Dashboard traffic animations
|
||||||
|
'traffic-shimmer': 'trafficShimmer 2s ease-in-out infinite',
|
||||||
|
'unlimited-flow': 'unlimitedFlow 3s ease-in-out infinite',
|
||||||
|
'unlimited-pulse': 'unlimitedPulse 2s ease-in-out infinite',
|
||||||
|
'trial-glow': 'trialGlow 3s ease-in-out infinite',
|
||||||
},
|
},
|
||||||
keyframes: {
|
keyframes: {
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
@@ -213,6 +229,53 @@ export default {
|
|||||||
'0 0 0 8px rgba(var(--color-accent-500), 0.2), 0 0 40px rgba(var(--color-accent-500), 0.5)',
|
'0 0 0 8px rgba(var(--color-accent-500), 0.2), 0 0 40px rgba(var(--color-accent-500), 0.5)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Aceternity UI keyframes
|
||||||
|
aurora: {
|
||||||
|
from: { backgroundPosition: '50% 50%, 50% 50%' },
|
||||||
|
to: { backgroundPosition: '350% 50%, 350% 50%' },
|
||||||
|
},
|
||||||
|
meteor: {
|
||||||
|
'0%': { transform: 'rotate(215deg) translateX(0)', opacity: '1' },
|
||||||
|
'70%': { opacity: '1' },
|
||||||
|
'100%': { transform: 'rotate(215deg) translateX(-500px)', opacity: '0' },
|
||||||
|
},
|
||||||
|
moveVertical: {
|
||||||
|
'0%': { transform: 'translateY(-50%)' },
|
||||||
|
'50%': { transform: 'translateY(50%)' },
|
||||||
|
'100%': { transform: 'translateY(-50%)' },
|
||||||
|
},
|
||||||
|
moveInCircle: {
|
||||||
|
'0%': { transform: 'rotate(0deg)' },
|
||||||
|
'50%': { transform: 'rotate(180deg)' },
|
||||||
|
'100%': { transform: 'rotate(360deg)' },
|
||||||
|
},
|
||||||
|
moveHorizontal: {
|
||||||
|
'0%': { transform: 'translateX(-50%) translateY(-10%)' },
|
||||||
|
'50%': { transform: 'translateX(50%) translateY(10%)' },
|
||||||
|
'100%': { transform: 'translateX(-50%) translateY(-10%)' },
|
||||||
|
},
|
||||||
|
spotlightAce: {
|
||||||
|
'0%': { opacity: '0', transform: 'translate(-72%, -62%) scale(0.5)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translate(-50%, -40%) scale(1)' },
|
||||||
|
},
|
||||||
|
// Dashboard traffic keyframes
|
||||||
|
trafficShimmer: {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(200%)' },
|
||||||
|
},
|
||||||
|
unlimitedFlow: {
|
||||||
|
'0%': { backgroundPosition: '0% 50%' },
|
||||||
|
'50%': { backgroundPosition: '100% 50%' },
|
||||||
|
'100%': { backgroundPosition: '0% 50%' },
|
||||||
|
},
|
||||||
|
unlimitedPulse: {
|
||||||
|
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
'50%': { opacity: '0.5', transform: 'scale(0.7)' },
|
||||||
|
},
|
||||||
|
trialGlow: {
|
||||||
|
'0%, 100%': { boxShadow: '0 0 15px rgba(62, 219, 176, 0.06)' },
|
||||||
|
'50%': { boxShadow: '0 0 30px rgba(62, 219, 176, 0.12)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
transitionTimingFunction: {
|
transitionTimingFunction: {
|
||||||
smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
|||||||
Reference in New Issue
Block a user