mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Merge branch 'BEDOLAGA-DEV:main' into feature/riopay
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
304
src/components/admin/BackgroundConfigEditor.tsx
Normal file
304
src/components/admin/BackgroundConfigEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
src/components/admin/LocaleTabs.tsx
Normal file
74
src/components/admin/LocaleTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/admin/LocalizedInput.tsx
Normal file
70
src/components/admin/LocalizedInput.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
891
src/components/admin/MenuEditorTab.tsx
Normal file
891
src/components/admin/MenuEditorTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/admin/SortableFeatureItem.tsx
Normal file
89
src/components/admin/SortableFeatureItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal file
251
src/components/admin/SortableSelectedMethodCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,8 @@ export const MENU_SECTIONS: MenuSection[] = [
|
||||
'INTERFACE_SUBSCRIPTION',
|
||||
'CONNECT_BUTTON',
|
||||
'MINIAPP',
|
||||
'TELEGRAM_WIDGET',
|
||||
'TELEGRAM_OIDC',
|
||||
'HAPP',
|
||||
'SKIP',
|
||||
'ADDITIONAL',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
80
src/components/dashboard/PendingGiftCard.tsx
Normal file
80
src/components/dashboard/PendingGiftCard.tsx
Normal 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">
|
||||
“{gift.gift_message}”
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activate button */}
|
||||
<Link
|
||||
to={`/buy/success/${gift.token}?activate=1`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
69
src/components/icons/LandingIcons.tsx
Normal file
69
src/components/icons/LandingIcons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -16,6 +16,7 @@ export {
|
||||
InfoIcon,
|
||||
CogIcon,
|
||||
WheelIcon,
|
||||
GiftIcon,
|
||||
SearchIcon,
|
||||
PlusIcon,
|
||||
ArrowRightIcon,
|
||||
|
||||
37
src/components/ui/AnimatedCheckmark.tsx
Normal file
37
src/components/ui/AnimatedCheckmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/AnimatedCrossmark.tsx
Normal file
37
src/components/ui/AnimatedCrossmark.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/components/ui/Spinner.tsx
Normal file
17
src/components/ui/Spinner.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user