Merge remote-tracking branch 'origin/dev_nikita' into dev_nikita

This commit is contained in:
firewookie
2026-03-10 15:29:14 +05:00
77 changed files with 13169 additions and 1380 deletions

View File

@@ -116,6 +116,8 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
setSuccessMessage(result.message);
setClaimingId(null);
setTimeout(() => setSuccessMessage(null), 5000);

View File

@@ -1,42 +1,162 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useAuthStore } from '../store/auth';
import { useNavigate } from 'react-router';
interface TelegramLoginButtonProps {
botUsername: string;
referralCode?: string;
}
export default function TelegramLoginButton({
botUsername,
referralCode,
}: TelegramLoginButtonProps) {
export default function TelegramLoginButton({ referralCode }: TelegramLoginButtonProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const [oidcLoading, setOidcLoading] = useState(false);
const [oidcError, setOidcError] = useState('');
const [scriptLoaded, setScriptLoaded] = useState(false);
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
const { data: widgetConfig } = useQuery<TelegramWidgetConfig>({
queryKey: ['telegram-widget-config'],
queryFn: brandingApi.getTelegramWidgetConfig,
staleTime: 60000,
});
const botUsername =
widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id);
// OIDC callback handler (ref pattern to avoid stale closures and unnecessary re-inits)
const handleOIDCCallbackRef =
useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
const mountedRef = useRef(true);
// Load widget script
useEffect(() => {
if (!containerRef.current || !botUsername) return;
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// Clear previous widget using safe DOM API
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild);
handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
if (!mountedRef.current) return;
if (data.error || !data.id_token) {
setOidcError(data.error || t('auth.loginFailed'));
setOidcLoading(false);
return;
}
try {
setOidcLoading(true);
setOidcError('');
await loginWithTelegramOIDC(data.id_token);
if (mountedRef.current) navigate('/');
} catch (err: unknown) {
if (!mountedRef.current) return;
let message = t('common.error');
if (err && typeof err === 'object' && 'response' in err) {
const resp = (err as { response?: { data?: { detail?: string } } }).response;
if (resp?.data?.detail) message = resp.data.detail;
}
setOidcError(message);
} finally {
if (mountedRef.current) setOidcLoading(false);
}
};
// Load OIDC script and init
useEffect(() => {
if (!isOIDC || !widgetConfig?.oidc_client_id) return;
const scriptId = 'telegram-login-oidc-script';
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
const initTelegramLogin = () => {
if (window.Telegram?.Login) {
window.Telegram.Login.init(
{
client_id: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id,
request_access: widgetConfig.request_access ? ['write'] : undefined,
lang: document.documentElement.lang || 'en',
},
(data) => handleOIDCCallbackRef.current?.(data),
);
}
};
if (!script) {
script = document.createElement('script');
script.id = scriptId;
script.src = 'https://oauth.telegram.org/js/telegram-login.js?3';
script.async = true;
script.onload = () => {
setScriptLoaded(true);
initTelegramLogin();
};
script.onerror = () => {
setOidcError(t('auth.loginFailed'));
};
document.head.appendChild(script);
} else {
// Script already loaded, just re-init
setScriptLoaded(true);
initTelegramLogin();
}
}, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
// Legacy widget effect (only when NOT OIDC)
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
useEffect(() => {
if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
const container = containerRef.current;
while (container.firstChild) {
container.removeChild(container.firstChild);
}
// Get current URL for redirect
const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
const callbackName = '__onTelegramWidgetAuth';
(window as unknown as Record<string, unknown>)[callbackName] = async (
user: Record<string, unknown>,
) => {
try {
await loginWithTelegramWidget({
id: user.id as number,
first_name: user.first_name as string,
last_name: (user.last_name as string) || undefined,
username: (user.username as string) || undefined,
photo_url: (user.photo_url as string) || undefined,
auth_date: user.auth_date as number,
hash: user.hash as string,
});
navigate('/');
} catch {
// Error handled by auth store
}
};
// Create script element for Telegram Login Widget
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.src = 'https://telegram.org/js/telegram-widget.js?23';
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-radius', '8');
script.setAttribute('data-auth-url', redirectUrl);
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-size', widgetConfig.size);
script.setAttribute('data-radius', String(widgetConfig.radius));
script.setAttribute('data-userpic', String(widgetConfig.userpic));
script.setAttribute('data-onauth', `${callbackName}(user)`);
if (widgetConfig.request_access) {
script.setAttribute('data-request-access', 'write');
}
script.async = true;
containerRef.current.appendChild(script);
}, [botUsername]);
container.appendChild(script);
return () => {
delete (window as unknown as Record<string, unknown>)[callbackName];
while (container.firstChild) {
container.removeChild(container.firstChild);
}
};
}, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate]);
if (!botUsername || botUsername === 'your_bot') {
return (
@@ -48,10 +168,35 @@ export default function TelegramLoginButton({
return (
<div className="flex flex-col items-center space-y-4">
{/* Telegram Widget will be inserted here */}
<div ref={containerRef} className="flex justify-center" />
{/* OIDC mode: custom button that opens popup */}
{isOIDC ? (
<div className="flex flex-col items-center space-y-2">
<button
type="button"
onClick={() => {
setOidcError('');
setOidcLoading(true);
if (window.Telegram?.Login) {
window.Telegram.Login.open();
} else {
setOidcLoading(false);
}
}}
disabled={oidcLoading || !scriptLoaded}
className="inline-flex items-center gap-2 rounded-lg bg-[#54a9eb] px-6 py-3 text-sm font-medium text-white shadow-sm transition-colors hover:bg-[#4a96d2] disabled:opacity-50"
>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
{oidcLoading ? t('common.loading') : t('auth.loginWithTelegram')}
</button>
{oidcError && <p className="text-xs text-red-500">{oidcError}</p>}
</div>
) : (
/* Legacy widget mode: iframe-based widget */
<div ref={containerRef} className="flex justify-center" />
)}
{/* Fallback link for mobile */}
<div className="text-center">
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
<a

View File

@@ -41,6 +41,7 @@ export default function WebSocketNotifications() {
});
// Refresh data
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -67,6 +68,7 @@ export default function WebSocketNotifications() {
duration: 5000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -82,6 +84,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -95,6 +98,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -151,6 +155,7 @@ export default function WebSocketNotifications() {
duration: 5000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -180,6 +185,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -195,6 +201,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;
@@ -220,6 +227,7 @@ export default function WebSocketNotifications() {
});
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
return;
}
@@ -329,6 +337,7 @@ export default function WebSocketNotifications() {
duration: 8000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['referral-stats'] });
refreshUser();
return;
@@ -375,6 +384,7 @@ export default function WebSocketNotifications() {
duration: 6000,
});
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['transactions'] });
refreshUser();
return;

View File

@@ -4,6 +4,7 @@ import { BackIcon } from './icons';
interface AdminBackButtonProps {
to?: string;
replace?: boolean;
className?: string;
}
@@ -11,7 +12,7 @@ interface AdminBackButtonProps {
* Back button for admin pages.
* Hidden in Telegram Mini App since native back button is used instead.
*/
export function AdminBackButton({ to = '/admin', className }: AdminBackButtonProps) {
export function AdminBackButton({ to = '/admin', replace, className }: AdminBackButtonProps) {
const { platform } = usePlatform();
// In Telegram Mini App, we use native back button
@@ -22,6 +23,7 @@ export function AdminBackButton({ to = '/admin', className }: AdminBackButtonPro
return (
<Link
to={to}
replace={replace}
className={
className ||
'flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600'

View File

@@ -0,0 +1,304 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { backgroundRegistry } from '@/components/ui/backgrounds/registry';
import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview';
import type {
AnimationConfig,
BackgroundType,
SettingDefinition,
} from '@/components/ui/backgrounds/types';
import { Toggle } from './Toggle';
import { cn } from '@/lib/utils';
function SettingField({
def,
value,
onChange,
t,
}: {
def: SettingDefinition;
value: unknown;
onChange: (val: unknown) => void;
t: (key: string) => string;
}) {
if (def.type === 'number') {
const numVal = (value as number) ?? (def.default as number);
const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal);
return (
<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);
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;
}
interface BackgroundConfigEditorProps {
value: AnimationConfig;
onChange: (config: AnimationConfig) => void;
}
export function BackgroundConfigEditor({ value: config, onChange }: BackgroundConfigEditorProps) {
const { t } = useTranslation();
const updateConfig = (patch: Partial<AnimationConfig>) => {
onChange({ ...config, ...patch });
};
const updateSetting = (key: string, val: unknown) => {
onChange({ ...config, settings: { ...config.settings, [key]: val } });
};
const handleTypeChange = (type: BackgroundType) => {
const def = backgroundRegistry.find((d) => d.type === type);
const defaults: Record<string, unknown> = {};
if (def) {
for (const s of def.settings) {
defaults[s.key] = s.default;
}
}
onChange({ ...config, type, settings: defaults });
};
const currentDef = useMemo(
() => backgroundRegistry.find((d) => d.type === config.type),
[config.type],
);
const categories = useMemo(() => {
const cats = new Map<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: Number(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>
</>
)}
</div>
);
}

View File

@@ -1,111 +1,18 @@
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi } from '@/api/branding';
import { backgroundRegistry } from '@/components/ui/backgrounds/registry';
import { BackgroundPreview } from '@/components/backgrounds/BackgroundPreview';
import { setCachedAnimationConfig } from '@/components/backgrounds/BackgroundRenderer';
import type {
AnimationConfig,
BackgroundType,
SettingDefinition,
} from '@/components/ui/backgrounds/types';
import type { AnimationConfig } from '@/components/ui/backgrounds/types';
import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
import { Toggle } from './Toggle';
import { BackgroundConfigEditor } from './BackgroundConfigEditor';
import { cn } from '@/lib/utils';
function SettingField({
def,
value,
onChange,
t,
}: {
def: SettingDefinition;
value: unknown;
onChange: (val: unknown) => void;
t: (key: string) => string;
}) {
if (def.type === 'number') {
const numVal = (value as number) ?? (def.default as number);
const displayVal = numVal < 0.01 ? numVal.toExponential(1) : String(numVal);
return (
<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);
@@ -137,40 +44,9 @@ export function BackgroundEditor() {
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 handleChange = useCallback((newConfig: AnimationConfig) => {
setLocalConfig(newConfig);
}, []);
const handleSave = () => {
saveMutation.mutate(config);
@@ -179,183 +55,9 @@ export function BackgroundEditor() {
const isDirty = localConfig !== null;
const showSaveButton = isDirty || saveStatus === 'saved' || saveStatus === 'saving';
const currentDef = useMemo(
() => backgroundRegistry.find((d) => d.type === config.type),
[config.type],
);
const categories = useMemo(() => {
const cats = new Map<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>
</>
)}
<BackgroundConfigEditor value={config} onChange={handleChange} />
{/* Save button */}
{showSaveButton && (

View File

@@ -35,6 +35,11 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
queryFn: brandingApi.getEmailAuthEnabled,
});
const { data: giftSettings } = useQuery({
queryKey: ['gift-enabled'],
queryFn: brandingApi.getGiftEnabled,
});
// Mutations
const updateBrandingMutation = useMutation({
mutationFn: brandingApi.updateName,
@@ -76,6 +81,13 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
},
});
const updateGiftMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateGiftEnabled(enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['gift-enabled'] });
},
});
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -225,6 +237,18 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
disabled={updateEmailAuthMutation.isPending}
/>
</div>
<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.giftEnabled')}</span>
<p className="text-sm text-dark-400">{t('admin.settings.giftEnabledDesc')}</p>
</div>
<Toggle
checked={giftSettings?.enabled ?? false}
onChange={() => updateGiftMutation.mutate(!(giftSettings?.enabled ?? false))}
disabled={updateGiftMutation.isPending}
/>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,74 @@
import { useTranslation } from 'react-i18next';
import {
SUPPORTED_LOCALES,
LOCALE_META,
type SupportedLocale,
type LocaleDict,
} from '../../api/landings';
import { cn } from '../../lib/utils';
interface LocaleTabsProps {
activeLocale: SupportedLocale;
onChange: (locale: SupportedLocale) => void;
/** Pass locale dicts to show a green dot indicator when content exists */
contentIndicators?: LocaleDict[];
className?: string;
}
/**
* Horizontal locale tab bar for the admin landing editor.
* Shows a green dot on tabs that have content filled in.
*/
export function LocaleTabs({
activeLocale,
onChange,
contentIndicators,
className,
}: LocaleTabsProps) {
const { t } = useTranslation();
const hasContent = (locale: SupportedLocale): boolean => {
if (!contentIndicators || contentIndicators.length === 0) return false;
return contentIndicators.some((dict) => {
const value = dict[locale];
return typeof value === 'string' && value.trim().length > 0;
});
};
return (
<div className={cn('space-y-1', className)}>
<div className="flex items-center gap-1.5">
{SUPPORTED_LOCALES.map((locale) => {
const meta = LOCALE_META[locale];
const isActive = locale === activeLocale;
const filled = hasContent(locale);
const isRtl = meta.rtl;
return (
<button
key={locale}
type="button"
onClick={() => onChange(locale)}
dir={isRtl ? 'rtl' : 'ltr'}
className={cn(
'relative flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-all',
isActive
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300',
)}
aria-label={`${t('admin.landings.localeTab')}: ${meta.name}`}
aria-pressed={isActive}
>
<span>{meta.flag}</span>
<span>{meta.name}</span>
{filled && !isActive && (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-success-500" />
)}
</button>
);
})}
</div>
<p className="text-xs text-dark-500">{t('admin.landings.localeHint')}</p>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { LOCALE_META, type LocaleDict, type SupportedLocale } from '../../api/landings';
import { cn } from '../../lib/utils';
interface LocalizedInputProps {
value: LocaleDict;
onChange: (value: LocaleDict) => void;
locale: SupportedLocale;
placeholder?: string;
multiline?: boolean;
rows?: number;
maxLength?: number;
id?: string;
className?: string;
}
/**
* An input (or textarea) that edits a single locale key within a LocaleDict.
* Shows `value[locale]` and updates the dict immutably on change.
*/
export function LocalizedInput({
value,
onChange,
locale,
placeholder,
multiline = false,
rows = 2,
maxLength,
id,
className,
}: LocalizedInputProps) {
const currentValue = value[locale] ?? '';
const isRtl = LOCALE_META[locale].rtl;
const handleChange = (newText: string) => {
onChange({ ...value, [locale]: newText });
};
const inputClasses = cn(
'w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500',
className,
);
if (multiline) {
return (
<textarea
id={id}
dir={isRtl ? 'rtl' : 'ltr'}
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
rows={rows}
maxLength={maxLength}
className={inputClasses}
/>
);
}
return (
<input
id={id}
type="text"
dir={isRtl ? 'rtl' : 'ltr'}
value={currentValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={placeholder}
maxLength={maxLength}
className={inputClasses}
/>
);
}

View File

@@ -0,0 +1,891 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
menuLayoutApi,
type MenuConfig,
type MenuRowConfig,
type MenuButtonConfig,
BUILTIN_SECTIONS,
BOT_LOCALES,
STYLE_OPTIONS,
} from '../../api/menuLayout';
import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify';
// ============ Icons ============
const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const PlusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
const LinkIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.54a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.03 8.591"
/>
</svg>
);
const ArrowUpIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
);
const ArrowDownIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
// ============ Helpers ============
function generateId(): string {
return `custom_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
function generateRowId(): string {
return `row_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
}
function configsEqual(a: MenuConfig, b: MenuConfig): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
const DEFAULT_CONFIG: MenuConfig = { rows: [] };
// ============ MaxPerRowSelector ============
interface MaxPerRowSelectorProps {
value: number;
onChange: (value: number) => void;
}
function MaxPerRowSelector({ value, onChange }: MaxPerRowSelectorProps) {
return (
<div className="flex gap-1">
{[1, 2, 3].map((n) => (
<button
key={n}
onClick={() => onChange(n)}
className={`flex h-7 w-7 items-center justify-center rounded-lg text-xs font-semibold transition-all ${
value === n
? 'bg-accent-500 text-white'
: 'bg-dark-700/50 text-dark-400 hover:bg-dark-600 hover:text-dark-300'
}`}
>
{n}
</button>
))}
</div>
);
}
// ============ ButtonChip ============
interface ButtonChipProps {
button: MenuButtonConfig;
isExpanded: boolean;
onToggleExpand: () => void;
onUpdate: (updates: Partial<MenuButtonConfig>) => void;
onRemove: () => void;
onMoveUp: (() => void) | null;
onMoveDown: (() => void) | null;
isBuiltin: boolean;
}
function ButtonChip({
button,
isExpanded,
onToggleExpand,
onUpdate,
onRemove,
onMoveUp,
onMoveDown,
isBuiltin,
}: ButtonChipProps) {
const { t } = useTranslation();
const displayName =
button.labels.ru ||
button.labels.en ||
(isBuiltin ? t(`admin.buttons.sections.${button.id}`) : button.id);
const styleOption = STYLE_OPTIONS.find((s) => s.value === button.style);
const colorDotClass = styleOption?.colorClass || 'bg-dark-500';
return (
<div
className={`overflow-hidden rounded-xl border transition-colors ${
button.enabled
? 'border-dark-700/50 bg-dark-800/50'
: 'border-dark-700/30 bg-dark-800/30 opacity-60'
}`}
>
{/* Collapsed header */}
<div className="flex items-center gap-2 px-3 py-2.5">
<div className="flex shrink-0 flex-col">
<button
onClick={onMoveUp ?? undefined}
disabled={!onMoveUp}
aria-label={t('admin.menuEditor.moveUp')}
className={`rounded-lg p-1.5 transition-colors ${
onMoveUp
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
: 'cursor-default text-dark-700'
}`}
>
<ArrowUpIcon />
</button>
<button
onClick={onMoveDown ?? undefined}
disabled={!onMoveDown}
aria-label={t('admin.menuEditor.moveDown')}
className={`rounded-lg p-1.5 transition-colors ${
onMoveDown
? 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
: 'cursor-default text-dark-700'
}`}
>
<ArrowDownIcon />
</button>
</div>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${colorDotClass}`} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-dark-100">
{displayName}
</span>
{!isBuiltin && (
<span className="text-dark-500" title="URL">
<LinkIcon />
</span>
)}
<Toggle checked={button.enabled} onChange={() => onUpdate({ enabled: !button.enabled })} />
<button
onClick={onToggleExpand}
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
>
<ChevronIcon expanded={isExpanded} />
</button>
{!isBuiltin && (
<button
onClick={onRemove}
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<TrashIcon />
</button>
)}
</div>
{/* Expanded body */}
{isExpanded && (
<div className="space-y-3 border-t border-dark-700/30 px-3 py-3">
{/* Color selector */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.buttons.color')}
</label>
<div className="flex flex-wrap gap-1.5">
{STYLE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ style: opt.value })}
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
button.style === opt.value
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${opt.colorClass}`} />
{t(`admin.buttons.styles.${opt.value}`)}
</button>
))}
</div>
</div>
{/* Emoji ID */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.buttons.emojiId')}
</label>
<input
type="text"
value={button.icon_custom_emoji_id}
onChange={(e) => onUpdate({ icon_custom_emoji_id: e.target.value })}
placeholder={t('admin.buttons.emojiPlaceholder')}
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
</div>
{/* URL input + open mode (custom buttons only) */}
{!isBuiltin && (
<>
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">URL</label>
<input
type="url"
value={button.url || ''}
onChange={(e) => onUpdate({ url: e.target.value || null })}
placeholder="https://..."
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.menuEditor.openIn')}
</label>
<div className="flex gap-1.5">
{(['external', 'webapp'] as const).map((mode) => (
<button
key={mode}
onClick={() => onUpdate({ open_in: mode })}
className={`flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium transition-all ${
button.open_in === mode
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 bg-dark-700/50 text-dark-300 hover:border-dark-500'
}`}
>
{t(`admin.menuEditor.openMode.${mode}`)}
</button>
))}
</div>
</div>
</>
)}
{/* Localized labels */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.buttons.customLabels')}
</label>
<div className="space-y-2">
{BOT_LOCALES.map((locale) => (
<div key={locale} className="flex items-center gap-2">
<span className="w-7 shrink-0 text-center text-[10px] font-semibold uppercase text-dark-500">
{locale}
</span>
<input
type="text"
value={button.labels[locale] || ''}
onChange={(e) =>
onUpdate({
labels: { ...button.labels, [locale]: e.target.value },
})
}
placeholder={t('admin.menuEditor.buttonTextPlaceholder')}
maxLength={100}
className="w-full rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-1.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
/>
</div>
))}
<p className="text-[10px] text-dark-500">{t('admin.menuEditor.customLabelsHint')}</p>
</div>
</div>
</div>
)}
</div>
);
}
// ============ SortableRow ============
interface SortableRowProps {
row: MenuRowConfig;
rowIndex: number;
expandedButtons: Set<string>;
usedBuiltinIds: Set<string>;
onToggleExpand: (buttonId: string) => void;
onUpdateRow: (rowId: string, updates: Partial<MenuRowConfig>) => void;
onRemoveRow: (rowId: string) => void;
onUpdateButton: (rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => void;
onRemoveButton: (rowId: string, buttonId: string) => void;
onAddBuiltin: (rowId: string, sectionId: string) => void;
onAddCustom: (rowId: string) => void;
onReorderButton: (rowId: string, buttonIndex: number, direction: 'up' | 'down') => void;
}
function SortableRow({
row,
rowIndex,
expandedButtons,
usedBuiltinIds,
onToggleExpand,
onUpdateRow,
onRemoveRow,
onUpdateButton,
onRemoveButton,
onAddBuiltin,
onAddCustom,
onReorderButton,
}: SortableRowProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: row.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
const allBuiltin = row.buttons.every((b) => b.type === 'builtin');
return (
<div
ref={setNodeRef}
style={style}
className={`overflow-hidden rounded-2xl border bg-dark-800/50 transition-all ${
isDragging ? 'border-accent-500/50 shadow-xl shadow-accent-500/20' : 'border-dark-700/50'
}`}
>
{/* Row header */}
<div className="flex items-center gap-3 border-b border-dark-700/30 px-4 py-3">
<button
{...attributes}
{...listeners}
className="flex-shrink-0 cursor-grab touch-none rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
title={t('admin.menuEditor.dragToReorder')}
>
<GripIcon />
</button>
<span className="text-sm font-semibold text-dark-200">
{t('admin.menuEditor.row')} {rowIndex + 1}
</span>
<div className="flex-1" />
<MaxPerRowSelector
value={row.max_per_row}
onChange={(value) => onUpdateRow(row.id, { max_per_row: value })}
/>
{!allBuiltin && (
<button
onClick={() => onRemoveRow(row.id)}
className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-red-500/10 hover:text-red-400"
>
<TrashIcon />
</button>
)}
</div>
{/* Row body */}
<div className="space-y-2 p-3">
{row.buttons.map((button, btnIndex) => (
<ButtonChip
key={button.id}
button={button}
isExpanded={expandedButtons.has(button.id)}
onToggleExpand={() => onToggleExpand(button.id)}
onUpdate={(updates) => onUpdateButton(row.id, button.id, updates)}
onRemove={() => onRemoveButton(row.id, button.id)}
onMoveUp={btnIndex > 0 ? () => onReorderButton(row.id, btnIndex, 'up') : null}
onMoveDown={
btnIndex < row.buttons.length - 1
? () => onReorderButton(row.id, btnIndex, 'down')
: null
}
isBuiltin={button.type === 'builtin'}
/>
))}
{/* Inline add button panel */}
<InlineAddPanel
rowId={row.id}
usedBuiltinIds={usedBuiltinIds}
onAddBuiltin={onAddBuiltin}
onAddCustom={onAddCustom}
/>
</div>
</div>
);
}
// ============ InlineAddPanel ============
interface InlineAddPanelProps {
rowId: string;
usedBuiltinIds: Set<string>;
onAddBuiltin: (rowId: string, sectionId: string) => void;
onAddCustom: (rowId: string) => void;
}
function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: InlineAddPanelProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const availableBuiltins = BUILTIN_SECTIONS.filter((id) => !usedBuiltinIds.has(id));
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-dark-700/50 py-2.5 text-sm text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
>
<PlusIcon />
{t('admin.menuEditor.addButton')}
</button>
);
}
return (
<div className="space-y-1 rounded-xl border border-dark-700/50 bg-dark-900/30 p-2">
{availableBuiltins.length > 0 && (
<>
<p className="px-2 pb-0.5 text-xs font-medium text-dark-500">
{t('admin.menuEditor.builtinButtons')}
</p>
{availableBuiltins.map((id) => (
<button
key={id}
onClick={() => {
onAddBuiltin(rowId, id);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
>
{t(`admin.buttons.sections.${id}`)}
</button>
))}
<div className="my-1 border-t border-dark-700/30" />
</>
)}
<button
onClick={() => {
onAddCustom(rowId);
setIsOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50"
>
<LinkIcon />
{t('admin.menuEditor.addUrlButton')}
</button>
<button
onClick={() => setIsOpen(false)}
className="flex w-full items-center justify-center rounded-lg py-1.5 text-xs text-dark-500 transition-colors hover:text-dark-400"
>
{t('common.cancel')}
</button>
</div>
);
}
// ============ MenuEditorTab ============
export function MenuEditorTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const notify = useNotify();
// Fetch config
const {
data: serverConfig,
isLoading,
isError,
} = useQuery({
queryKey: ['menu-layout'],
queryFn: menuLayoutApi.getConfig,
});
// Draft state
const [draftConfig, setDraftConfig] = useState<MenuConfig>(DEFAULT_CONFIG);
const [expandedButtons, setExpandedButtons] = useState<Set<string>>(new Set());
const savedConfigRef = useRef<MenuConfig>(DEFAULT_CONFIG);
const draftConfigRef = useRef(draftConfig);
draftConfigRef.current = draftConfig;
// Sync server data to draft (same pattern as ButtonsTab)
useEffect(() => {
if (serverConfig) {
if (
configsEqual(savedConfigRef.current, draftConfigRef.current) ||
configsEqual(savedConfigRef.current, DEFAULT_CONFIG)
) {
setDraftConfig(serverConfig);
savedConfigRef.current = serverConfig;
}
}
}, [serverConfig]);
const hasUnsavedChanges = !configsEqual(draftConfig, savedConfigRef.current);
// Mutations
const updateMutation = useMutation({
mutationFn: menuLayoutApi.updateConfig,
onSuccess: (data) => {
savedConfigRef.current = data;
setDraftConfig(data);
queryClient.setQueryData(['menu-layout'], data);
},
onError: () => {
notify.error(t('common.error'));
},
});
const resetMutation = useMutation({
mutationFn: menuLayoutApi.resetConfig,
onSuccess: (data) => {
savedConfigRef.current = data;
setDraftConfig(data);
queryClient.setQueryData(['menu-layout'], data);
},
onError: () => {
notify.error(t('common.error'));
},
});
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setDraftConfig((prev) => {
const oldIndex = prev.rows.findIndex((r) => r.id === active.id);
const newIndex = prev.rows.findIndex((r) => r.id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return { ...prev, rows: arrayMove(prev.rows, oldIndex, newIndex) };
});
}
}, []);
// Row CRUD
const updateRow = useCallback((rowId: string, updates: Partial<MenuRowConfig>) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) => (r.id === rowId ? { ...r, ...updates } : r)),
}));
}, []);
const removeRow = useCallback((rowId: string) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.filter((r) => r.id !== rowId),
}));
}, []);
const addRow = useCallback(() => {
setDraftConfig((prev) => ({
...prev,
rows: [
...prev.rows,
{
id: generateRowId(),
max_per_row: 2,
buttons: [],
},
],
}));
}, []);
// Button CRUD
const updateButton = useCallback(
(rowId: string, buttonId: string, updates: Partial<MenuButtonConfig>) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId
? {
...r,
buttons: r.buttons.map((b) => (b.id === buttonId ? { ...b, ...updates } : b)),
}
: r,
),
}));
},
[],
);
const removeButton = useCallback((rowId: string, buttonId: string) => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId ? { ...r, buttons: r.buttons.filter((b) => b.id !== buttonId) } : r,
),
}));
}, []);
const addBuiltinButton = useCallback((rowId: string, sectionId: string) => {
const newButton: MenuButtonConfig = {
id: sectionId,
type: 'builtin',
style: 'default',
icon_custom_emoji_id: '',
enabled: true,
labels: {},
url: null,
open_in: 'external',
};
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
),
}));
}, []);
const addCustomButton = useCallback((rowId: string) => {
const newButton: MenuButtonConfig = {
id: generateId(),
type: 'custom',
style: 'default',
icon_custom_emoji_id: '',
enabled: true,
labels: {},
url: '',
open_in: 'external',
};
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) =>
r.id === rowId ? { ...r, buttons: [...r.buttons, newButton] } : r,
),
}));
}, []);
const reorderButton = useCallback(
(rowId: string, buttonIndex: number, direction: 'up' | 'down') => {
setDraftConfig((prev) => ({
...prev,
rows: prev.rows.map((r) => {
if (r.id !== rowId) return r;
const newIndex = direction === 'up' ? buttonIndex - 1 : buttonIndex + 1;
if (newIndex < 0 || newIndex >= r.buttons.length) return r;
return { ...r, buttons: arrayMove(r.buttons, buttonIndex, newIndex) };
}),
}));
},
[],
);
// Expand/collapse
const toggleExpand = useCallback((buttonId: string) => {
setExpandedButtons((prev) => {
const next = new Set(prev);
if (next.has(buttonId)) {
next.delete(buttonId);
} else {
next.add(buttonId);
}
return next;
});
}, []);
// Collect used builtin IDs across all rows
const usedBuiltinIds = useMemo(
() =>
new Set(
draftConfig.rows.flatMap((r) =>
r.buttons.filter((b) => b.type === 'builtin').map((b) => b.id),
),
),
[draftConfig.rows],
);
// Handlers
const handleSave = useCallback(() => {
// Validate custom buttons have valid URLs
const currentDraft = draftConfigRef.current;
for (const row of currentDraft.rows) {
for (const btn of row.buttons) {
if (btn.type === 'custom') {
if (!btn.url || (!btn.url.startsWith('http://') && !btn.url.startsWith('https://'))) {
notify.error(t('admin.menuEditor.invalidUrl'));
return;
}
}
}
}
updateMutation.mutate(currentDraft);
}, [updateMutation, notify, t]);
const handleCancel = useCallback(() => {
setDraftConfig(savedConfigRef.current);
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-dark-400">
<svg className="mr-2 h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
{t('common.loading')}
</div>
);
}
if (isError) {
return (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{t('common.error')}
</div>
);
}
return (
<div className="space-y-4">
{/* Drag hint */}
<div className="flex items-center gap-2 text-sm text-dark-500">
<GripIcon />
{t('admin.menuEditor.dragHint')}
</div>
{/* Rows */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={draftConfig.rows.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{draftConfig.rows.map((row, index) => (
<SortableRow
key={row.id}
row={row}
rowIndex={index}
expandedButtons={expandedButtons}
usedBuiltinIds={usedBuiltinIds}
onToggleExpand={toggleExpand}
onUpdateRow={updateRow}
onRemoveRow={removeRow}
onUpdateButton={updateButton}
onRemoveButton={removeButton}
onAddBuiltin={addBuiltinButton}
onAddCustom={addCustomButton}
onReorderButton={reorderButton}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Add row */}
<button
onClick={addRow}
className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-dark-700/50 py-4 text-sm font-medium text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
>
<PlusIcon />
{t('admin.menuEditor.addRow')}
</button>
{/* Save / Cancel */}
{hasUnsavedChanges && (
<div className="flex flex-wrap items-center gap-3">
<button
onClick={handleSave}
disabled={updateMutation.isPending}
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{updateMutation.isPending ? t('common.saving') : t('common.save')}
</button>
<button
onClick={handleCancel}
disabled={updateMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
</div>
)}
{/* Reset */}
<div className="flex justify-end">
<button
onClick={() => {
if (window.confirm(t('admin.menuEditor.resetConfirm'))) {
resetMutation.mutate();
}
}}
disabled={resetMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('admin.buttons.resetAll')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useTranslation } from 'react-i18next';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '../../lib/utils';
import { GripIcon, TrashIcon } from '../icons/LandingIcons';
import { LocalizedInput } from './LocalizedInput';
import type { AdminLandingFeature, LocaleDict, SupportedLocale } from '../../api/landings';
export type FeatureWithId = AdminLandingFeature & { _id: string };
interface SortableFeatureProps {
feature: FeatureWithId;
index: number;
locale: SupportedLocale;
onUpdateIcon: (index: number, value: string) => void;
onUpdateLocalized: (index: number, field: 'title' | 'description', value: LocaleDict) => void;
onRemove: (index: number) => void;
}
export function SortableFeatureItem({
feature,
index,
locale,
onUpdateIcon,
onUpdateLocalized,
onRemove,
}: SortableFeatureProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: feature._id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'flex items-start gap-2 rounded-lg border p-3',
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
)}
>
<button
{...attributes}
{...listeners}
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
>
<GripIcon />
</button>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={feature.icon}
onChange={(e) => onUpdateIcon(index, e.target.value)}
placeholder={t('admin.landings.featureIcon')}
className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
/>
<LocalizedInput
value={feature.title}
onChange={(v) => onUpdateLocalized(index, 'title', v)}
locale={locale}
placeholder={t('admin.landings.featureTitle')}
className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<LocalizedInput
value={feature.description}
onChange={(v) => onUpdateLocalized(index, 'description', v)}
locale={locale}
placeholder={t('admin.landings.featureDesc')}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<button
onClick={() => onRemove(index)}
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
>
<TrashIcon />
</button>
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { cn } from '../../lib/utils';
import { GripIcon, TrashIcon } from '../icons/LandingIcons';
import type { AdminLandingPaymentMethod, EditableMethodField } from '../../api/landings';
import type { PaymentMethodSubOptionInfo } from '../../types';
export type MethodWithId = AdminLandingPaymentMethod & { _id: string };
const ChevronDownIcon = ({ open }: { open: boolean }) => (
<svg
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
interface SortableSelectedMethodProps {
method: MethodWithId;
availableSubOptions: PaymentMethodSubOptionInfo[] | null;
onUpdate: (methodId: string, field: EditableMethodField, value: string | number | null) => void;
onSubOptionsChange: (methodId: string, subOptions: Record<string, boolean>) => void;
onRemove: (methodId: string) => void;
}
export function SortableSelectedMethodCard({
method,
availableSubOptions,
onUpdate,
onSubOptionsChange,
onRemove,
}: SortableSelectedMethodProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: method._id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border',
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50',
)}
>
<div className="flex items-center gap-2 px-3 py-2">
<button
{...attributes}
{...listeners}
className="flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
>
<GripIcon />
</button>
<button onClick={() => setExpanded((v) => !v)} className="min-w-0 flex-1 text-start">
<span className="truncate text-sm text-dark-100">{method.display_name}</span>
</button>
<button
onClick={() => setExpanded((v) => !v)}
className="flex-shrink-0 text-dark-500 hover:text-dark-300"
>
<ChevronDownIcon open={expanded} />
</button>
<button
onClick={() => onRemove(method.method_id)}
className="flex-shrink-0 text-dark-500 hover:text-error-400"
>
<TrashIcon />
</button>
</div>
{expanded && (
<div className="space-y-3 border-t border-dark-700 px-3 py-3">
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodDisplayName', 'Display name')}
</label>
<input
type="text"
value={method.display_name}
onChange={(e) => onUpdate(method.method_id, 'display_name', e.target.value)}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodDescription', 'Description')}
</label>
<input
type="text"
value={method.description ?? ''}
onChange={(e) => onUpdate(method.method_id, 'description', e.target.value || null)}
placeholder={t('admin.landings.methodDescPlaceholder', 'Optional description')}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodIconUrl', 'Icon URL')}
</label>
<input
type="text"
value={method.icon_url ?? ''}
onChange={(e) => onUpdate(method.method_id, 'icon_url', e.target.value || null)}
placeholder="https://..."
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodMinAmount', 'Min amount (kopeks)')}
</label>
<input
type="number"
min={0}
step={1}
value={method.min_amount_kopeks ?? ''}
onChange={(e) =>
onUpdate(
method.method_id,
'min_amount_kopeks',
e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null,
)
}
placeholder="—"
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodMaxAmount', 'Max amount (kopeks)')}
</label>
<input
type="number"
min={0}
step={1}
value={method.max_amount_kopeks ?? ''}
onChange={(e) =>
onUpdate(
method.method_id,
'max_amount_kopeks',
e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null,
)
}
placeholder="—"
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodCurrency', 'Currency')}
</label>
<input
type="text"
value={method.currency ?? ''}
onChange={(e) => onUpdate(method.method_id, 'currency', e.target.value || null)}
placeholder="RUB"
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-dark-500">
{t('admin.landings.methodReturnUrl', 'Return URL after payment')}
</label>
<input
type="text"
value={method.return_url ?? ''}
onChange={(e) => onUpdate(method.method_id, 'return_url', e.target.value || null)}
placeholder={t(
'admin.landings.methodReturnUrlPlaceholder',
'Default: cabinet success page. Use {token} for purchase token',
)}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
{availableSubOptions && availableSubOptions.length > 0 && (
<div>
<label className="mb-1.5 block text-xs text-dark-500">
{t('admin.landings.methodSubOptions', 'Payment sub-options')}
</label>
<div className="flex flex-wrap gap-2">
{availableSubOptions.map((opt) => {
// Missing keys treated as enabled (opt-out model)
const enabled = method.sub_options?.[opt.id] !== false;
return (
<button
key={opt.id}
type="button"
role="checkbox"
aria-checked={enabled}
onClick={() => {
const current =
method.sub_options ??
Object.fromEntries(availableSubOptions.map((o) => [o.id, true]));
onSubOptionsChange(method.method_id, { ...current, [opt.id]: !enabled });
}}
className={cn(
'flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs transition-colors',
enabled
? 'border-accent-500/30 bg-accent-500/10 text-accent-300'
: 'border-dark-700 bg-dark-800 text-dark-500',
)}
>
<div
aria-hidden="true"
className={cn(
'flex h-3.5 w-3.5 items-center justify-center rounded',
enabled
? 'bg-accent-500 text-white'
: 'border border-dark-600 bg-dark-700',
)}
>
{enabled && (
<svg
className="h-2.5 w-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
{opt.name}
</button>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -67,6 +67,8 @@ export const MENU_SECTIONS: MenuSection[] = [
'INTERFACE_SUBSCRIPTION',
'CONNECT_BUTTON',
'MINIAPP',
'TELEGRAM_WIDGET',
'TELEGRAM_OIDC',
'HAPP',
'SKIP',
'ADDITIONAL',

View File

@@ -12,6 +12,8 @@ export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';
export * from './LocaleTabs';
export * from './LocalizedInput';
// Constants and utils
export * from './constants';

View File

@@ -96,52 +96,35 @@ function reduceMobileSettings(settings: Record<string, unknown>): Record<string,
return reduced;
}
export function BackgroundRenderer() {
/** Renders the background animation into a portal at document.body */
function RenderBackground({ config }: { config: AnimationConfig }) {
const prefersReducedMotion = useMemo(
() => window.matchMedia('(prefers-reduced-motion: reduce)').matches,
[],
);
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const raw = await brandingApi.getAnimationConfig();
// Validate and clamp API response same as localStorage
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
setCachedConfig(result);
return result;
},
initialData: getCachedConfig() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 30_000,
});
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
if (!effectiveConfig.enabled || effectiveConfig.type === 'none' || prefersReducedMotion) {
if (!config.enabled || config.type === 'none' || prefersReducedMotion) {
return null;
}
const bgType = effectiveConfig.type as Exclude<BackgroundType, 'none'>;
const bgType = config.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;
config.reducedOnMobile && isMobile ? reduceMobileSettings(config.settings) : config.settings;
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
const effectiveBlur = isMobile ? Math.min(config.blur, 4) : config.blur;
return createPortal(
<div
className="pointer-events-none fixed inset-0"
style={{
zIndex: -2,
opacity: effectiveConfig.opacity,
opacity: config.opacity,
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
contain: 'strict',
backfaceVisibility: 'hidden',
@@ -154,3 +137,29 @@ export function BackgroundRenderer() {
document.body,
);
}
/** BackgroundRenderer that fetches config from the branding API (for authenticated app shell) */
export function BackgroundRenderer() {
const { data: config } = useQuery({
queryKey: ['animation-config'],
queryFn: async () => {
const raw = await brandingApi.getAnimationConfig();
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
setCachedConfig(result);
return result;
},
initialData: getCachedConfig() ?? undefined,
initialDataUpdatedAt: 0,
staleTime: 30_000,
});
const effectiveConfig = config ?? DEFAULT_ANIMATION_CONFIG;
return <RenderBackground config={effectiveConfig} />;
}
/** StaticBackgroundRenderer that uses a provided config (for public landing pages) */
export function StaticBackgroundRenderer({ config }: { config: AnimationConfig }) {
const validated = useMemo(() => validateConfig(config), [config]);
if (!validated) return null;
return <RenderBackground config={validated} />;
}

View File

@@ -10,7 +10,7 @@ function safeOpenUrl(url: string | undefined | null): void {
try {
const parsed = new URL(url);
if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
window.open(url, '_blank', 'noopener,noreferrer');
window.open(url, '_blank', 'noopener');
}
} catch {
// invalid URL, do nothing

View File

@@ -0,0 +1,80 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import type { PendingGift } from '../../api/gift';
interface PendingGiftCardProps {
gifts: PendingGift[];
className?: string;
}
export default function PendingGiftCard({ gifts, className }: PendingGiftCardProps) {
const { t } = useTranslation();
if (gifts.length === 0) return null;
return (
<div className={className ?? 'space-y-3'}>
{gifts.map((gift) => (
<motion.div
key={gift.token}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="relative overflow-hidden rounded-2xl border border-accent-500/30 bg-gradient-to-r from-accent-500/10 via-purple-500/10 to-accent-500/10 p-5"
>
{/* Subtle glow effect */}
<div className="absolute -right-8 -top-8 h-24 w-24 rounded-full bg-accent-500/10 blur-2xl" />
<div className="relative flex items-start gap-4">
{/* Gift icon */}
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
<svg
className="h-6 w-6 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-dark-50">{t('gift.pending.title')}</h3>
<p className="mt-0.5 text-xs text-dark-300">
{gift.tariff_name && (
<span>
{gift.tariff_name} {gift.period_days} {t('gift.days')}
</span>
)}
{gift.sender_display && (
<span className="ml-1 text-dark-400">
{t('gift.pending.from', { sender: gift.sender_display })}
</span>
)}
</p>
{gift.gift_message && (
<p className="mt-1.5 line-clamp-2 text-xs italic text-dark-400">
&ldquo;{gift.gift_message}&rdquo;
</p>
)}
</div>
{/* Activate button */}
<Link
to={`/gift?tab=activate&code=${gift.token}`}
className="shrink-0 rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-400"
>
{t('gift.pending.activate')}
</Link>
</div>
</motion.div>
))}
</div>
);
}

View File

@@ -236,15 +236,24 @@ export default function SubscriptionCardActive({
{t('dashboard.connectDevice')}
</div>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('dashboard.devicesOfMax', {
used: connectedDevices,
max: subscription.device_limit,
})}
{subscription.device_limit === 0
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
: t('dashboard.devicesOfMax', {
used: connectedDevices,
max: subscription.device_limit,
})}
</div>
</div>
{/* Device indicator */}
{subscription.device_limit <= 10 ? (
{subscription.device_limit === 0 ? (
<div
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
aria-hidden="true"
>
</div>
) : subscription.device_limit <= 10 ? (
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
{Array.from({ length: subscription.device_limit }, (_, i) => (
<div

View File

@@ -1,23 +1,100 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router';
import { Link, useNavigate, useLocation } from 'react-router';
import { useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import type { Subscription } from '../../types';
import { subscriptionApi } from '../../api/subscription';
import { useTheme } from '../../hooks/useTheme';
import { useCurrency } from '../../hooks/useCurrency';
import { useHapticFeedback } from '../../platform/hooks/useHaptic';
import { getGlassColors } from '../../utils/glassTheme';
import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers';
interface SubscriptionCardExpiredProps {
subscription: Subscription;
balanceKopeks?: number;
balanceRubles?: number;
className?: string;
}
export default function SubscriptionCardExpired({ subscription }: SubscriptionCardExpiredProps) {
export default function SubscriptionCardExpired({
subscription,
balanceKopeks = 0,
balanceRubles = 0,
className,
}: SubscriptionCardExpiredProps) {
const { t } = useTranslation();
const { isDark } = useTheme();
const g = getGlassColors(isDark);
const queryClient = useQueryClient();
const navigate = useNavigate();
const location = useLocation();
const { formatAmount, currencySymbol } = useCurrency();
const haptic = useHapticFeedback();
const [isRenewing, setIsRenewing] = useState(false);
const [renewError, setRenewError] = useState<string | null>(null);
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
// Detect daily subscription (disabled or expired)
const isDaily = subscription.is_daily;
const isDisabledDaily = subscription.status === 'disabled' && isDaily;
// For daily subs, check if balance covers daily price; otherwise 100 kopeks minimum
const dailyPrice = subscription.daily_price_kopeks ?? 0;
const hasBalance = isDaily ? balanceKopeks >= dailyPrice && dailyPrice > 0 : balanceKopeks >= 100;
const handleQuickRenew = async () => {
setIsRenewing(true);
setRenewError(null);
haptic.buttonPressHeavy();
try {
if (isDisabledDaily) {
// Resume daily subscription via toggle pause endpoint
await subscriptionApi.togglePause();
} else if (isDaily && subscription.tariff_id) {
// Expired daily tariff — purchase for 1 day
await subscriptionApi.purchaseTariff(subscription.tariff_id, 1);
} else {
await subscriptionApi.renewSubscription(30);
}
haptic.success();
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
} catch (err: unknown) {
haptic.error();
const insufficientData = getInsufficientBalanceError(err);
if (insufficientData) {
setRenewError(t('dashboard.expired.insufficientFunds'));
} else if (err instanceof AxiosError) {
const detail = err.response?.data?.detail;
if (typeof detail === 'string') {
setRenewError(detail);
} else {
setRenewError(t('dashboard.expired.renewError'));
}
} else {
setRenewError(t('dashboard.expired.renewError'));
}
} finally {
setIsRenewing(false);
}
};
const handleTopUp = () => {
haptic.buttonPress();
const params = new URLSearchParams();
params.set('returnTo', location.pathname);
navigate(`/balance/top-up?${params.toString()}`);
};
return (
<div
className="relative overflow-hidden rounded-3xl"
className={`relative overflow-hidden rounded-3xl ${className ?? ''}`}
style={{
background: g.cardBg,
border: isDark ? '1px solid rgba(255,70,70,0.12)' : '1px solid rgba(255,59,92,0.2)',
@@ -81,39 +158,121 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
</svg>
</div>
<h2 className="text-lg font-bold tracking-tight text-dark-50">
{subscription.is_trial ? t('dashboard.expired.trialTitle') : t('dashboard.expired.title')}
{isDisabledDaily
? t('dashboard.suspended.title')
: subscription.is_trial
? t('dashboard.expired.trialTitle')
: t('dashboard.expired.title')}
</h2>
</div>
{/* Expired date */}
{/* Expired date + Balance row */}
<div
className="mb-5 flex items-center justify-center rounded-[14px]"
className="mb-5 flex items-center justify-between rounded-[14px]"
style={{
background: 'rgba(255,59,92,0.04)',
border: '1px solid rgba(255,59,92,0.08)',
padding: '14px 18px',
}}
>
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.expiredDate')}
<div className="flex items-center">
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.expiredDate')}
</div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
{formattedDate}
</div>
</div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
{formattedDate}
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.balance')}
</span>
<span
className={`text-sm font-semibold ${hasBalance ? 'text-success-400' : 'text-dark-50/30'}`}
>
{formatAmount(balanceRubles)} {currencySymbol}
</span>
</div>
</div>
{/* Renew error */}
{renewError && (
<div
className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-center text-sm text-error-400"
role="alert"
>
{renewError}
</div>
)}
{/* Action buttons */}
<div className="flex gap-2.5">
<Link
to="/subscription/purchase"
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>
{/* Quick Renew or Top Up button */}
{hasBalance ? (
<button
type="button"
onClick={handleQuickRenew}
disabled={isRenewing}
className="flex flex-1 items-center justify-center gap-2 rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
style={{
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
boxShadow: '0 4px 20px rgba(255,59,92,0.2)',
}}
>
{isRenewing ? (
<span
className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
aria-hidden="true"
/>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
)}
{isRenewing
? t('common.loading')
: isDisabledDaily
? t('dashboard.suspended.resume')
: t('dashboard.expired.quickRenew')}
</button>
) : (
<button
type="button"
onClick={handleTopUp}
className="flex flex-1 items-center justify-center gap-2 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)',
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('dashboard.expired.topUp')}
</button>
)}
{/* Renew (go to purchase page) */}
<Link
to="/subscription/purchase"
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"

View File

@@ -175,7 +175,10 @@ export default function TrialOfferCard({
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') },
{
value: trialInfo.device_limit === 0 ? '∞' : 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">

View File

@@ -0,0 +1,69 @@
import { cn } from '../../lib/utils';
interface IconProps {
className?: string;
}
export function BackIcon({ className }: IconProps) {
return (
<svg
className={cn('h-5 w-5 text-dark-400', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
}
export function PlusIcon({ className }: IconProps) {
return (
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
}
export function TrashIcon({ className }: IconProps) {
return (
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
}
export function GripIcon({ className }: IconProps) {
return (
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
}

View File

@@ -35,6 +35,7 @@ import {
InfoIcon,
CogIcon,
WheelIcon,
GiftIcon,
MenuIcon,
CloseIcon,
SunIcon,
@@ -60,6 +61,7 @@ interface AppHeaderProps {
referralEnabled?: boolean;
hasContests?: boolean;
hasPolls?: boolean;
giftEnabled?: boolean;
}
export function AppHeader({
@@ -75,6 +77,7 @@ export function AppHeader({
referralEnabled,
hasContests,
hasPolls,
giftEnabled,
}: AppHeaderProps) {
const { t } = useTranslation();
const location = useLocation();
@@ -164,6 +167,7 @@ export function AppHeader({
...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []),
...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []),
...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []),
...(giftEnabled ? [{ path: '/gift', label: t('nav.gift'), icon: GiftIcon }] : []),
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
];

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -20,7 +20,7 @@ import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import TicketNotificationBell from '@/components/TicketNotificationBell';
import { SubscriptionIcon } from '@/components/icons';
import { SubscriptionIcon, GiftIcon } from '@/components/icons';
import { MobileBottomNav } from './MobileBottomNav';
import { AppHeader } from './AppHeader';
@@ -203,7 +203,7 @@ export function AppShell({ children }: AppShellProps) {
// Extracted hooks
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
useScrollRestoration();
// Theme toggle visibility
@@ -269,6 +269,31 @@ export function AppShell({ children }: AppShellProps) {
haptic.impact('light');
};
// Desktop nav scroll fade indicators
const navRef = useRef<HTMLElement>(null);
const [navCanScrollLeft, setNavCanScrollLeft] = useState(false);
const [navCanScrollRight, setNavCanScrollRight] = useState(false);
const updateNavScroll = useCallback(() => {
const el = navRef.current;
if (!el) return;
setNavCanScrollLeft(el.scrollLeft > 2);
setNavCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2);
}, []);
useEffect(() => {
const el = navRef.current;
if (!el) return;
updateNavScroll();
el.addEventListener('scroll', updateNavScroll, { passive: true });
const ro = new ResizeObserver(updateNavScroll);
ro.observe(el);
return () => {
el.removeEventListener('scroll', updateNavScroll);
ro.disconnect();
};
}, [updateNavScroll]);
// Calculate header height based on fullscreen mode (only on mobile Telegram)
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
@@ -317,58 +342,89 @@ export function AppShell({ children }: AppShellProps) {
</Link>
{/* Center Navigation */}
<nav className="flex items-center justify-center gap-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path)
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
))}
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('/referral')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<UsersIcon className="h-4 w-4" />
<span>{t('nav.referral')}</span>
</Link>
)}
{isAdmin && (
<>
{/* Separator before admin */}
<div className="mx-2 h-5 w-px bg-dark-700" />
<div className="relative min-w-0">
{/* Left fade */}
<div
className={cn(
'pointer-events-none absolute bottom-0 left-0 top-0 z-10 w-6 bg-gradient-to-r from-dark-950/95 to-transparent transition-opacity duration-200',
navCanScrollLeft ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Right fade */}
<div
className={cn(
'pointer-events-none absolute bottom-0 right-0 top-0 z-10 w-6 bg-gradient-to-l from-dark-950/95 to-transparent transition-opacity duration-200',
navCanScrollRight ? 'opacity-100' : 'opacity-0',
)}
/>
<nav ref={navRef} className="scrollbar-hide flex items-center gap-1 overflow-x-auto">
{desktopNavItems.map((item) => (
<Link
to="/admin"
key={item.path}
to={item.path}
onClick={handleNavClick}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive(item.path)
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<ShieldIcon className="h-4 w-4" />
<span>{t('admin.nav.title')}</span>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
</>
)}
</nav>
))}
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
className={cn(
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('/referral')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<UsersIcon className="h-4 w-4" />
<span>{t('nav.referral')}</span>
</Link>
)}
{giftEnabled && (
<Link
to="/gift"
onClick={handleNavClick}
className={cn(
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive('/gift')
? 'bg-dark-800 text-dark-50'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200',
)}
>
<GiftIcon className="h-4 w-4" />
<span>{t('nav.gift')}</span>
</Link>
)}
{isAdmin && (
<>
{/* Separator before admin */}
<div className="mx-2 h-5 w-px shrink-0 bg-dark-700" />
<Link
to="/admin"
onClick={handleNavClick}
className={cn(
'flex shrink-0 items-center gap-2 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors',
location.pathname.startsWith('/admin')
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400',
)}
>
<ShieldIcon className="h-4 w-4" />
<span>{t('admin.nav.title')}</span>
</Link>
</>
)}
</nav>
</div>
{/* Right side actions */}
<div className="flex items-center justify-end gap-2">
@@ -415,6 +471,7 @@ export function AppShell({ children }: AppShellProps) {
referralEnabled={referralEnabled}
hasContests={hasContests}
hasPolls={hasPolls}
giftEnabled={giftEnabled}
/>
{/* Desktop spacer */}

View File

@@ -16,6 +16,7 @@ export {
InfoIcon,
CogIcon,
WheelIcon,
GiftIcon,
SearchIcon,
PlusIcon,
ArrowRightIcon,

View File

@@ -0,0 +1,37 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function AnimatedCheckmark({ className }: { className?: string }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className={cn(
'flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10',
className,
)}
>
<motion.svg
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="h-10 w-10 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
aria-hidden="true"
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
);
}

View File

@@ -0,0 +1,37 @@
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
export function AnimatedCrossmark({ className }: { className?: string }) {
return (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className={cn(
'flex h-20 w-20 items-center justify-center rounded-full bg-error-500/10',
className,
)}
>
<motion.svg
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
className="h-10 w-10 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
);
}

View File

@@ -0,0 +1,17 @@
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
export function Spinner({ className }: { className?: string }) {
const { t } = useTranslation();
return (
<div
role="status"
aria-label={t('common.loading')}
className={cn(
'h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500',
className,
)}
/>
);
}