diff --git a/src/App.tsx b/src/App.tsx index f7ace2c..46e0ea8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -110,6 +110,7 @@ const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit')); const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog')); const AdminLandings = lazy(() => import('./pages/AdminLandings')); const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor')); +const AdminLandingStats = lazy(() => import('./pages/AdminLandingStats')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); @@ -542,6 +543,16 @@ function App() { } /> + + + + + + } + /> ; @@ -274,6 +278,72 @@ export const landingApi = { }, }; +// ============================================================ +// Admin stats types +// ============================================================ + +export interface LandingDailyStat { + date: string; + purchases: number; + revenue_kopeks: number; + gifts: number; +} + +export interface LandingTariffStat { + tariff_id: number | null; + tariff_name: string; + purchases: number; + revenue_kopeks: number; +} + +export interface LandingStatsResponse { + total_purchases: number; + total_revenue_kopeks: number; + total_gifts: number; + total_regular: number; + avg_purchase_kopeks: number; + total_created: number; + total_successful: number; + conversion_rate: number; + daily_stats: LandingDailyStat[]; + tariff_stats: LandingTariffStat[]; +} + +// ============================================================ +// Admin purchase list types +// ============================================================ + +export type PurchaseItemStatus = + | 'pending' + | 'paid' + | 'delivered' + | 'pending_activation' + | 'failed' + | 'expired'; + +export interface LandingPurchaseItem { + id: number; + token: string; + contact_type: 'email' | 'telegram'; + contact_value: string; + is_gift: boolean; + gift_recipient_type: 'email' | 'telegram' | null; + gift_recipient_value: string | null; + tariff_name: string; + period_days: number; + amount_kopeks: number; + currency: string; + payment_method: string; + status: PurchaseItemStatus; + created_at: string; + paid_at: string | null; +} + +export interface LandingPurchaseListResponse { + items: LandingPurchaseItem[]; + total: number; +} + // ============================================================ // Admin API // ============================================================ @@ -312,4 +382,21 @@ export const adminLandingsApi = { reorder: async (landingIds: number[]): Promise => { await apiClient.put('/cabinet/admin/landings/order', { landing_ids: landingIds }); }, + + getStats: async (id: number): Promise => { + const response = await apiClient.get(`/cabinet/admin/landings/${id}/stats`); + return response.data; + }, + + getPurchases: async ( + id: number, + offset: number, + limit: number, + status?: PurchaseItemStatus, + ): Promise => { + const params: Record = { offset, limit }; + if (status) params.status = status; + const response = await apiClient.get(`/cabinet/admin/landings/${id}/purchases`, { params }); + return response.data; + }, }; diff --git a/src/components/TelegramLoginButton.tsx b/src/components/TelegramLoginButton.tsx index 08ebd8d..037be27 100644 --- a/src/components/TelegramLoginButton.tsx +++ b/src/components/TelegramLoginButton.tsx @@ -106,6 +106,8 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto }, [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; @@ -114,7 +116,25 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto container.removeChild(container.firstChild); } - const redirectUrl = `${window.location.origin}/auth/telegram/callback`; + const callbackName = '__onTelegramWidgetAuth'; + (window as unknown as Record)[callbackName] = async ( + user: Record, + ) => { + 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 + } + }; const script = document.createElement('script'); script.src = 'https://telegram.org/js/telegram-widget.js?23'; @@ -122,7 +142,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto script.setAttribute('data-size', widgetConfig.size); script.setAttribute('data-radius', String(widgetConfig.radius)); script.setAttribute('data-userpic', String(widgetConfig.userpic)); - script.setAttribute('data-auth-url', redirectUrl); + script.setAttribute('data-onauth', `${callbackName}(user)`); if (widgetConfig.request_access) { script.setAttribute('data-request-access', 'write'); } @@ -131,11 +151,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto container.appendChild(script); return () => { + delete (window as unknown as Record)[callbackName]; while (container.firstChild) { container.removeChild(container.firstChild); } }; - }, [isOIDC, botUsername, widgetConfig]); + }, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate]); if (!botUsername || botUsername === 'your_bot') { return ( diff --git a/src/components/admin/BackgroundConfigEditor.tsx b/src/components/admin/BackgroundConfigEditor.tsx new file mode 100644 index 0000000..e168932 --- /dev/null +++ b/src/components/admin/BackgroundConfigEditor.tsx @@ -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 ( +
+ +
+ onChange(parseFloat(e.target.value))} + className="w-24 accent-accent-500" + /> + {displayVal} +
+
+ ); + } + + 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 ( +
+ +
+ onChange(e.target.value)} + className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent" + /> + {colorVal} +
+
+ ); + } + + if (def.type === 'boolean') { + const boolVal = (value as boolean) ?? (def.default as boolean); + return ( +
+ + onChange(!boolVal)} /> +
+ ); + } + + if (def.type === 'select' && def.options) { + const selectVal = (value as string) ?? (def.default as string); + return ( +
+ + +
+ ); + } + + return null; +} + +interface BackgroundConfigEditorProps { + value: AnimationConfig; + onChange: (config: AnimationConfig) => void; +} + +export function BackgroundConfigEditor({ value: config, onChange }: BackgroundConfigEditorProps) { + const { t } = useTranslation(); + + const updateConfig = (patch: Partial) => { + 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 = {}; + 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(); + for (const def of backgroundRegistry) { + const list = cats.get(def.category) ?? []; + list.push(def); + cats.set(def.category, list); + } + return cats; + }, []); + + return ( +
+ {/* Header with enable toggle */} +
+
+

{t('admin.backgrounds.title')}

+

{t('admin.backgrounds.description')}

+
+ updateConfig({ enabled: !config.enabled })} + /> +
+ + {config.enabled && ( + <> + {/* Preview */} +
+ + +
+ + {/* Type selector gallery */} +
+ + + {/* None option */} + + + {/* Background types by category */} +
+ {Array.from(categories.entries()).map(([category, defs]) => ( +
+ + {t(`admin.backgrounds.category${category.toUpperCase()}`)} + +
+ {defs.map((def) => ( + + ))} +
+
+ ))} +
+
+ + {/* Per-type settings */} + {currentDef && currentDef.settings.length > 0 && ( +
+

+ {t('admin.backgrounds.settings')} +

+
+ {currentDef.settings.map((def) => ( + updateSetting(def.key, val)} + t={t} + /> + ))} +
+
+ )} + + {/* Global settings */} +
+
+
+ +
+ updateConfig({ opacity: parseFloat(e.target.value) })} + className="w-24 accent-accent-500" + /> + + {config.opacity} + +
+
+ +
+ +
+ updateConfig({ blur: Number(e.target.value) })} + className="w-24 accent-accent-500" + /> + + {config.blur}px + +
+
+ +
+
+ +

+ {t('admin.backgrounds.reducedOnMobileDesc')} +

+
+ updateConfig({ reducedOnMobile: !config.reducedOnMobile })} + /> +
+
+
+ + )} +
+ ); +} diff --git a/src/components/admin/BackgroundEditor.tsx b/src/components/admin/BackgroundEditor.tsx index 979761d..f57f651 100644 --- a/src/components/admin/BackgroundEditor.tsx +++ b/src/components/admin/BackgroundEditor.tsx @@ -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 ( -
- -
- onChange(parseFloat(e.target.value))} - className="w-24 accent-accent-500" - /> - {displayVal} -
-
- ); - } - - 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 ( -
- -
- onChange(e.target.value)} - className="h-7 w-10 cursor-pointer rounded border border-dark-600 bg-transparent" - /> - {colorVal} -
-
- ); - } - - if (def.type === 'boolean') { - const boolVal = (value as boolean) ?? (def.default as boolean); - return ( -
- - onChange(!boolVal)} /> -
- ); - } - - if (def.type === 'select' && def.options) { - const selectVal = (value as string) ?? (def.default as string); - return ( -
- - -
- ); - } - - return null; -} - export function BackgroundEditor() { const { t } = useTranslation(); const queryClient = useQueryClient(); const saveTimerRef = useRef>(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) => { - 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 = {}; - 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(); - for (const def of backgroundRegistry) { - const list = cats.get(def.category) ?? []; - list.push(def); - cats.set(def.category, list); - } - return cats; - }, []); - return (
- {/* Header with enable toggle */} -
-
-

{t('admin.backgrounds.title')}

-

{t('admin.backgrounds.description')}

-
- updateConfig({ enabled: !config.enabled })} - /> -
- - {config.enabled && ( - <> - {/* Preview */} -
- - -
- - {/* Type selector gallery */} -
- - - {/* None option */} - - - {/* Background types by category */} -
- {Array.from(categories.entries()).map(([category, defs]) => ( -
- - {t(`admin.backgrounds.category${category.toUpperCase()}`)} - -
- {defs.map((def) => ( - - ))} -
-
- ))} -
-
- - {/* Per-type settings */} - {currentDef && currentDef.settings.length > 0 && ( -
-

- {t('admin.backgrounds.settings')} -

-
- {currentDef.settings.map((def) => ( - updateSetting(def.key, val)} - t={t} - /> - ))} -
-
- )} - - {/* Global settings */} -
-
-
- -
- updateConfig({ opacity: parseFloat(e.target.value) })} - className="w-24 accent-accent-500" - /> - - {config.opacity} - -
-
- -
- -
- updateConfig({ blur: parseInt(e.target.value) })} - className="w-24 accent-accent-500" - /> - - {config.blur}px - -
-
- -
-
- -

- {t('admin.backgrounds.reducedOnMobileDesc')} -

-
- updateConfig({ reducedOnMobile: !config.reducedOnMobile })} - /> -
-
-
- - )} + {/* Save button */} {showSaveButton && ( diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index d7e7f93..2638c3d 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -96,52 +96,35 @@ function reduceMobileSettings(settings: Record): Record 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; + const bgType = config.type as Exclude; 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(
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 ; +} + +/** 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 ; +} diff --git a/src/locales/en.json b/src/locales/en.json index 5f7347f..83e5890 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3192,7 +3192,7 @@ "metaDesc": "Meta Description", "active": "Active", "inactive": "Inactive", - "purchases": "purchases", + "purchaseCount": "purchases", "copyUrl": "Copy URL", "urlCopied": "URL copied", "deleteConfirm": "Delete landing \"{{title}}\"?", @@ -3227,7 +3227,54 @@ "discountOverrides": "Per-tariff overrides", "discountOverridesHint": "Leave empty to use global discount", "discountPreview": "Preview", - "discountActive": "Discount" + "discountActive": "Discount", + "statistics": "Statistics", + "stats": { + "title": "Landing Statistics", + "totalPurchases": "Purchases", + "revenue": "Revenue", + "giftPurchases": "Gifts", + "regularPurchases": "Regular", + "conversionRate": "Conversion", + "avgPurchase": "Avg. Check", + "dailyChart": "Purchases & Revenue by Day", + "tariffChart": "Tariff Distribution", + "giftBreakdown": "Gifts vs Regular", + "purchases": "Purchases", + "revenueLabel": "Revenue", + "gifts": "Gifts", + "regular": "Regular", + "created": "Created", + "successful": "Successful", + "funnel": "Funnel", + "loadError": "Failed to load statistics", + "noPurchases": "No purchases" + }, + "purchases": { + "title": "Purchases", + "contact": "Contact", + "recipient": "Recipient", + "tariff": "Tariff", + "period": "Period", + "days": "days", + "price": "Price", + "method": "Method", + "date": "Date", + "gift": "Gift", + "forSelf": "For self", + "allStatuses": "All statuses", + "status_pending": "Pending", + "status_paid": "Paid", + "status_delivered": "Delivered", + "status_pending_activation": "Pending activation", + "status_failed": "Failed", + "status_expired": "Expired", + "noPurchases": "No purchases", + "showing": "Showing {{from}}\u2013{{to}} of {{total}}", + "page": "Page {{current}} of {{total}}", + "prev": "Previous", + "next": "Next" + } } }, "adminUpdates": { diff --git a/src/locales/fa.json b/src/locales/fa.json index 3cda2af..f6c21df 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2917,7 +2917,7 @@ "metaDesc": "توضیحات متا", "active": "فعال", "inactive": "غیرفعال", - "purchases": "خرید", + "purchaseCount": "خرید", "copyUrl": "کپی URL", "urlCopied": "URL کپی شد", "deleteConfirm": "حذف صفحه فرود «{{title}}»؟", diff --git a/src/locales/ru.json b/src/locales/ru.json index 341951f..9fde123 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3743,7 +3743,7 @@ "metaDesc": "Meta Description", "active": "Активен", "inactive": "Неактивен", - "purchases": "покупок", + "purchaseCount": "покупок", "copyUrl": "Скопировать URL", "urlCopied": "URL скопирован", "deleteConfirm": "Удалить лендинг «{{title}}»?", @@ -3778,7 +3778,55 @@ "discountOverrides": "Индивидуальные скидки по тарифам", "discountOverridesHint": "Оставьте пустым для использования общей скидки", "discountPreview": "Предпросмотр", - "discountActive": "Скидка" + "discountActive": "Скидка", + "background": "Анимированный фон", + "statistics": "Статистика", + "stats": { + "title": "Статистика лендинга", + "totalPurchases": "Покупки", + "revenue": "Доход", + "giftPurchases": "Подарки", + "regularPurchases": "Обычные", + "conversionRate": "Конверсия", + "avgPurchase": "Средний чек", + "dailyChart": "Покупки и доход по дням", + "tariffChart": "Распределение по тарифам", + "giftBreakdown": "Подарки vs обычные", + "purchases": "Покупки", + "revenueLabel": "Доход", + "gifts": "Подарки", + "regular": "Обычные", + "created": "Создано", + "successful": "Успешных", + "funnel": "Воронка", + "loadError": "Не удалось загрузить статистику", + "noPurchases": "Нет покупок" + }, + "purchases": { + "title": "Покупки", + "contact": "Контакт", + "recipient": "Получатель", + "tariff": "Тариф", + "period": "Период", + "days": "дн.", + "price": "Цена", + "method": "Метод", + "date": "Дата", + "gift": "Подарок", + "forSelf": "Для себя", + "allStatuses": "Все статусы", + "status_pending": "Ожидание", + "status_paid": "Оплачен", + "status_delivered": "Доставлен", + "status_pending_activation": "Ожидает активации", + "status_failed": "Ошибка", + "status_expired": "Истёк", + "noPurchases": "Нет покупок", + "showing": "Показано {{from}}\u2013{{to}} из {{total}}", + "page": "Стр. {{current}} из {{total}}", + "prev": "Назад", + "next": "Далее" + } } }, "adminUpdates": { diff --git a/src/locales/zh.json b/src/locales/zh.json index d6560b1..1213972 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2916,7 +2916,7 @@ "metaDesc": "Meta描述", "active": "已激活", "inactive": "未激活", - "purchases": "次购买", + "purchaseCount": "次购买", "copyUrl": "复制URL", "urlCopied": "URL已复制", "deleteConfirm": "删除落地页「{{title}}」?", diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index 961aaa2..39dc68f 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -15,6 +15,9 @@ import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs'; import { formatPrice } from '../utils/format'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin'; +import { BackgroundConfigEditor } from '../components/admin/BackgroundConfigEditor'; +import type { AnimationConfig } from '@/components/ui/backgrounds/types'; +import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types'; import { SortableFeatureItem, type FeatureWithId } from '../components/admin/SortableFeatureItem'; import { SortableSelectedMethodCard, @@ -102,6 +105,7 @@ export default function AdminLandingEditor() { discount: false, methods: false, gifts: false, + background: false, footer: false, }); @@ -127,6 +131,12 @@ export default function AdminLandingEditor() { const [footerText, setFooterText] = useState({}); const [customCss, setCustomCss] = useState(''); + // Background config state + const [backgroundConfig, setBackgroundConfig] = useState({ + ...DEFAULT_ANIMATION_CONFIG, + enabled: false, + }); + // Discount state const [discountPercent, setDiscountPercent] = useState(null); const [discountOverrides, setDiscountOverrides] = useState>({}); @@ -245,6 +255,9 @@ export default function AdminLandingEditor() { setGiftEnabled(landingData.gift_enabled); setFooterText(toLocaleDict(landingData.footer_text)); setCustomCss(landingData.custom_css ?? ''); + if (landingData.background_config) { + setBackgroundConfig(landingData.background_config); + } setDiscountPercent(landingData.discount_percent ?? null); setDiscountOverrides(landingData.discount_overrides ?? {}); setDiscountStartsAt( @@ -364,6 +377,7 @@ export default function AdminLandingEditor() { discountPercent !== null && discountEndsAt ? new Date(discountEndsAt).toISOString() : null, discount_badge_text: discountPercent !== null ? (nonEmptyDict(discountBadgeText) ?? null) : null, + background_config: backgroundConfig.enabled ? backgroundConfig : null, }; if (isEdit) { @@ -1056,6 +1070,15 @@ export default function AdminLandingEditor() {
+ {/* Background Section */} +
toggleSection('background')} + > + +
+ {/* Footer & Custom CSS Section */}
( + + + +); + +const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; +const GIFT_COLOR = '#a855f7'; + +const PURCHASE_STATUS_STYLES: Record = { + pending: 'bg-warning-500/20 text-warning-400', + paid: 'bg-accent-500/20 text-accent-400', + delivered: 'bg-success-500/20 text-success-400', + pending_activation: 'bg-accent-500/20 text-accent-400', + failed: 'bg-error-500/20 text-error-400', + expired: 'bg-dark-500/20 text-dark-400', +}; + +const PURCHASE_STATUS_OPTIONS: Array = [ + 'all', + 'pending', + 'paid', + 'delivered', + 'pending_activation', + 'failed', + 'expired', +]; + +const PURCHASES_PAGE_SIZE = 20; + +// Small icons for the purchase cards + +const EmailIcon = () => ( + + + +); + +const TelegramSmallIcon = () => ( + + + +); + +const ArrowRightIcon = () => ( + + + +); + +const GiftIcon = () => ( + + + +); + +const ChevronLeftSmall = () => ( + + + +); + +const ChevronRightSmall = () => ( + + + +); + +// Contact display helper +function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) { + return ( + + {type === 'email' ? : } + {value} + + ); +} + +// Purchase card component +interface PurchaseCardProps { + item: LandingPurchaseItem; + formatPrice: (kopeks: number) => string; + lang: string; + t: (key: string, opts?: Record) => string; +} + +function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) { + const statusStyle = PURCHASE_STATUS_STYLES[item.status] || 'bg-dark-600 text-dark-300'; + const dateStr = new Date(item.created_at).toLocaleDateString(lang, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + + return ( +
+ {/* Mobile: stacked | Desktop: horizontal */} +
+ {/* Status badge */} +
+ + {t(`admin.landings.purchases.status_${item.status}`)} + +
+ + {/* Contact info */} +
+
+ + {item.is_gift && item.gift_recipient_type && item.gift_recipient_value && ( + + + + + )} +
+
+ + {/* Tariff + period */} +
+ {item.tariff_name} + + {' '} + · {item.period_days} {t('admin.landings.purchases.days')} + +
+ + {/* Price */} +
+ {formatPrice(item.amount_kopeks)} +
+ + {/* Payment method */} +
{item.payment_method}
+ + {/* Gift badge */} + {item.is_gift && ( +
+ + + {t('admin.landings.purchases.gift')} + +
+ )} + + {/* Date */} +
{dateStr}
+
+
+ ); +} + +export default function AdminLandingStats() { + const { t, i18n } = useTranslation(); + const { id } = useParams<{ id: string }>(); + const numericId = id ? Number(id) : NaN; + const isValidId = !isNaN(numericId); + const navigate = useNavigate(); + const { formatWithCurrency } = useCurrency(); + const colors = useChartColors(); + + // Purchases list state + const [purchaseOffset, setPurchaseOffset] = useState(0); + const [purchaseStatusFilter, setPurchaseStatusFilter] = useState( + 'all', + ); + + // Fetch stats + const { + data: stats, + isLoading, + error, + } = useQuery({ + queryKey: ['landing-stats', numericId], + queryFn: () => adminLandingsApi.getStats(numericId), + enabled: isValidId, + staleTime: CHART_COMMON.STALE_TIME, + }); + + // Fetch landing detail for header + const { data: landing } = useQuery({ + queryKey: ['admin-landing', numericId], + queryFn: () => adminLandingsApi.get(numericId), + enabled: isValidId, + staleTime: CHART_COMMON.STALE_TIME, + }); + + // Fetch purchases list + const { data: purchasesData, isLoading: purchasesLoading } = useQuery({ + queryKey: [ + 'landing-purchases', + numericId, + purchaseOffset, + PURCHASES_PAGE_SIZE, + purchaseStatusFilter, + ], + queryFn: () => + adminLandingsApi.getPurchases( + numericId, + purchaseOffset, + PURCHASES_PAGE_SIZE, + purchaseStatusFilter === 'all' ? undefined : purchaseStatusFilter, + ), + enabled: isValidId, + staleTime: CHART_COMMON.STALE_TIME, + }); + + const purchaseItems = purchasesData?.items ?? []; + const purchaseTotal = purchasesData?.total ?? 0; + const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE); + const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1; + + // Prepare daily chart data + const dailyData = useMemo(() => { + if (!stats) return []; + return stats.daily_stats.map((item) => ({ + label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, { + month: 'short', + day: 'numeric', + }), + purchases: item.purchases, + revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR, + gifts: item.gifts, + })); + }, [stats, i18n.language]); + + // Prepare tariff chart data + const tariffData = useMemo(() => { + if (!stats) return []; + return stats.tariff_stats.map((item) => ({ + name: item.tariff_name, + purchases: item.purchases, + revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR, + })); + }, [stats]); + + // Donut data for gift vs regular + const donutData = useMemo(() => { + if (!stats) return []; + return [ + { + name: t('admin.landings.stats.regular'), + value: stats.total_regular, + color: colors.referrals, + }, + { name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR }, + ]; + }, [stats, t, colors.referrals]); + + // Bar chart height based on tariff count + const barChartHeight = useMemo(() => { + const count = tariffData.length; + return Math.max(220, count * 45 + 40); + }, [tariffData.length]); + + // Loading state + if (isLoading) { + return ( +
+
+
+ ); + } + + // Error state + if (error || !stats) { + return ( +
+
+ +

{t('admin.landings.stats.title')}

+
+
+

{t('admin.landings.stats.loadError')}

+ +
+
+ ); + } + + const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`; + + return ( +
+ {/* Header */} +
+
+ +
+ +
+
+

{landingTitle}

+
+ {landing?.is_active ? ( + + {t('admin.landings.active')} + + ) : ( + + {t('admin.landings.inactive')} + + )} +
+
+
+
+ +
+ {/* Summary Cards */} +
+
+
+ {stats.total_purchases} +
+
{t('admin.landings.stats.totalPurchases')}
+
+
+
+ {formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)} +
+
{t('admin.landings.stats.revenue')}
+
+
+
{stats.total_gifts}
+
{t('admin.landings.stats.giftPurchases')}
+
+
+
+ {stats.conversion_rate}% +
+
{t('admin.landings.stats.conversionRate')}
+
+
+ + {/* Charts */} +
+ {/* Daily Purchases & Revenue */} +
+

+ {t('admin.landings.stats.dailyChart')} +

+ {dailyData.length === 0 ? ( +
+ {t('admin.landings.stats.noPurchases')} +
+ ) : ( + + + + + + + + + + + + + + + + + + + + + + )} +
+ + {/* Tariff Distribution */} +
+

+ {t('admin.landings.stats.tariffChart')} +

+ {tariffData.length === 0 ? ( +
+ {t('admin.landings.stats.noPurchases')} +
+ ) : ( + + + + + + { + return [value ?? 0, t('admin.landings.stats.purchases')]; + }} + /> + + {tariffData.map((_, index) => ( + + ))} + + + + )} +
+
+ + {/* Additional Stats Row */} +
+
+
+ {t('admin.landings.stats.avgPurchase')} +
+
+ {formatWithCurrency(stats.avg_purchase_kopeks / CHART_COMMON.KOPEKS_DIVISOR)} +
+
+
+
+ {t('admin.landings.stats.regularPurchases')} +
+
{stats.total_regular}
+
+
+
{t('admin.landings.stats.funnel')}
+
+ {stats.total_created}{' '} + {t('admin.landings.stats.created')} + {' / '} + {stats.total_successful}{' '} + {t('admin.landings.stats.successful')} +
+
+
+ + {/* Gift vs Regular Donut */} + {stats.total_purchases > 0 && ( +
+

+ {t('admin.landings.stats.giftBreakdown')} +

+
+
+ + + + {donutData.map((entry, index) => ( + + ))} + + + + + {/* Center text */} +
+ {stats.total_purchases} +
+
+
+
+
+ + {t('admin.landings.stats.regular')}: {stats.total_regular} + +
+
+
+ + {t('admin.landings.stats.gifts')}: {stats.total_gifts} + +
+
+
+
+ )} + + {/* Purchases List */} +
+ {/* Header row: title + status filter */} +
+

{t('admin.landings.purchases.title')}

+ +
+ + {/* Content */} + {purchasesLoading ? ( +
+
+
+ ) : purchaseItems.length === 0 ? ( +
+ {t('admin.landings.purchases.noPurchases')} +
+ ) : ( + <> +
+ {purchaseItems.map((item) => ( + + formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR) + } + lang={i18n.language} + t={t} + /> + ))} +
+ + {/* Pagination */} + {purchaseTotalPages > 1 && ( +
+ + {t('admin.landings.purchases.showing', { + from: purchaseOffset + 1, + to: Math.min(purchaseOffset + PURCHASES_PAGE_SIZE, purchaseTotal), + total: purchaseTotal, + })} + +
+ + + + {t('admin.landings.purchases.page', { + current: purchaseCurrentPage, + total: purchaseTotalPages, + })} + + + +
+
+ )} + + )} +
+
+
+ ); +} diff --git a/src/pages/AdminLandings.tsx b/src/pages/AdminLandings.tsx index 96fd103..568e9b7 100644 --- a/src/pages/AdminLandings.tsx +++ b/src/pages/AdminLandings.tsx @@ -79,11 +79,22 @@ const CopyIcon = () => ( ); +const StatsChartIcon = () => ( + + + +); + // ============ Sortable Landing Card ============ interface SortableLandingCardProps { landing: LandingListItem; onEdit: () => void; + onStats: () => void; onDelete: () => void; onToggle: () => void; onCopyUrl: () => void; @@ -93,6 +104,7 @@ interface SortableLandingCardProps { function SortableLandingCard({ landing, onEdit, + onStats, onDelete, onToggle, onCopyUrl, @@ -168,7 +180,7 @@ function SortableLandingCard({
- {landing.purchase_stats.total} {t('admin.landings.purchases')} + {landing.purchase_stats.total} {t('admin.landings.purchaseCount')}
@@ -182,6 +194,13 @@ function SortableLandingCard({ > + +