From 30190199ed88cde6aea575eed44a2f7d4361dbdc Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 09:35:47 +0300 Subject: [PATCH 1/7] feat: add landing page statistics page with recharts Add AdminLandingStats page with: - Summary cards (purchases, revenue, gifts, conversion) - AreaChart with dual Y-axis (daily purchases + revenue) - BarChart (tariff distribution) - PieChart donut (gift vs regular breakdown) - Responsive layout for desktop and mobile - Stats button on landing cards list Includes API types, route, translations (ru/en). --- src/App.tsx | 11 + src/api/landings.ts | 36 +++ src/locales/en.json | 24 +- src/locales/ru.json | 24 +- src/pages/AdminLandingStats.tsx | 445 ++++++++++++++++++++++++++++++++ src/pages/AdminLandings.tsx | 27 ++ 6 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 src/pages/AdminLandingStats.tsx 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() { } /> + + + + + + } + /> => { 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; + }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 5f7347f..b523618 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3227,7 +3227,29 @@ "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" + } } }, "adminUpdates": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 341951f..c2175bb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3778,7 +3778,29 @@ "discountOverrides": "Индивидуальные скидки по тарифам", "discountOverridesHint": "Оставьте пустым для использования общей скидки", "discountPreview": "Предпросмотр", - "discountActive": "Скидка" + "discountActive": "Скидка", + "statistics": "Статистика", + "stats": { + "title": "Статистика лендинга", + "totalPurchases": "Покупки", + "revenue": "Доход", + "giftPurchases": "Подарки", + "regularPurchases": "Обычные", + "conversionRate": "Конверсия", + "avgPurchase": "Средний чек", + "dailyChart": "Покупки и доход по дням", + "tariffChart": "Распределение по тарифам", + "giftBreakdown": "Подарки vs обычные", + "purchases": "Покупки", + "revenueLabel": "Доход", + "gifts": "Подарки", + "regular": "Обычные", + "created": "Создано", + "successful": "Успешных", + "funnel": "Воронка", + "loadError": "Не удалось загрузить статистику", + "noPurchases": "Нет покупок" + } } }, "adminUpdates": { diff --git a/src/pages/AdminLandingStats.tsx b/src/pages/AdminLandingStats.tsx new file mode 100644 index 0000000..a041cce --- /dev/null +++ b/src/pages/AdminLandingStats.tsx @@ -0,0 +1,445 @@ +import { useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + Area, + AreaChart, + Bar, + BarChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import { adminLandingsApi, resolveLocaleDisplay } from '../api/landings'; +import { useCurrency } from '../hooks/useCurrency'; +import { useChartColors } from '../hooks/useChartColors'; +import { CHART_COMMON } from '../constants/charts'; +import { AdminBackButton } from '../components/admin'; + +// Icons +const ChartIcon = () => ( + + + +); + +const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; +const GIFT_COLOR = '#a855f7'; + +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(); + + // 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, + }); + + // 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} + +
+
+
+
+ )} +
+
+ ); +} diff --git a/src/pages/AdminLandings.tsx b/src/pages/AdminLandings.tsx index 96fd103..578398d 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, @@ -182,6 +194,13 @@ function SortableLandingCard({ > + +
)} + + {/* 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, + })} + + + +
+
+ )} + + )} +
); From a4046903344855d849482b585fee1e27d13efcae Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 12:46:05 +0300 Subject: [PATCH 3/7] feat: add configurable animated background for landing pages Extract BackgroundConfigEditor as reusable controlled component from BackgroundEditor. Add background settings section to landing editor. Render per-landing backgrounds on public purchase pages. - Extract BackgroundConfigEditor (value/onChange) from BackgroundEditor - Refactor BackgroundEditor to thin wrapper with API persistence - Add StaticBackgroundRenderer for prop-based config (public pages) - Add background_config to landing API types and editor form - Render animated background on QuickPurchase page --- src/api/landings.ts | 4 + .../admin/BackgroundConfigEditor.tsx | 304 +++++++++++++++++ src/components/admin/BackgroundEditor.tsx | 312 +----------------- .../backgrounds/BackgroundRenderer.tsx | 57 ++-- src/locales/ru.json | 1 + src/pages/AdminLandingEditor.tsx | 23 ++ src/pages/QuickPurchase.tsx | 2 + 7 files changed, 374 insertions(+), 329 deletions(-) create mode 100644 src/components/admin/BackgroundConfigEditor.tsx diff --git a/src/api/landings.ts b/src/api/landings.ts index b9bf5f1..18b5b1d 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -1,4 +1,5 @@ import apiClient from './client'; +import type { AnimationConfig } from '@/components/ui/backgrounds/types'; // ============================================================ // Public types @@ -91,6 +92,7 @@ export interface LandingConfig { meta_title: string | null; meta_description: string | null; discount: LandingDiscountInfo | null; + background_config: AnimationConfig | null; } export interface PurchaseRequest { @@ -201,6 +203,7 @@ export interface LandingDetail { discount_starts_at: string | null; discount_ends_at: string | null; discount_badge_text: LocaleDict | null; + background_config: AnimationConfig | null; } export interface LandingCreateRequest { @@ -222,6 +225,7 @@ export interface LandingCreateRequest { discount_starts_at?: string | null; discount_ends_at?: string | null; discount_badge_text?: LocaleDict | null; + background_config?: AnimationConfig | null; } export type LandingUpdateRequest = Partial; 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/ru.json b/src/locales/ru.json index 83426aa..a899ce6 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3779,6 +3779,7 @@ "discountOverridesHint": "Оставьте пустым для использования общей скидки", "discountPreview": "Предпросмотр", "discountActive": "Скидка", + "background": "Анимированный фон", "statistics": "Статистика", "stats": { "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 */}
+ {config.background_config && }
{/* Language switcher */}
From 0ce74ea5fb7efad60bb78b56a0bf6518fecb88d8 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 13:22:08 +0300 Subject: [PATCH 4/7] fix: rename duplicate 'purchases' i18n key to 'purchaseCount' The admin.landings namespace had two 'purchases' keys at the same level: a string for purchase count label and an object for purchases section. JSON last-key-wins caused the string to be overwritten by the object, producing "returned an object instead of string" error on landings list. --- src/locales/en.json | 2 +- src/locales/fa.json | 2 +- src/locales/ru.json | 2 +- src/locales/zh.json | 2 +- src/pages/AdminLandings.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index b24bf13..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}}\"?", 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 a899ce6..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}}»?", 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/AdminLandings.tsx b/src/pages/AdminLandings.tsx index 578398d..568e9b7 100644 --- a/src/pages/AdminLandings.tsx +++ b/src/pages/AdminLandings.tsx @@ -180,7 +180,7 @@ function SortableLandingCard({
- {landing.purchase_stats.total} {t('admin.landings.purchases')} + {landing.purchase_stats.total} {t('admin.landings.purchaseCount')}
From 66bb86a5f286b40eb0cc7cbac8a82ad3d6336de2 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 15:34:59 +0300 Subject: [PATCH 5/7] fix: allow animated background to show through on landing pages The root div had an opaque bg-dark-950 that covered the portal-rendered animated background at zIndex:-2. Remove the opaque background when background_config is present, matching the AppShell pattern. --- src/pages/QuickPurchase.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index 5c6ae42..537f03e 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -963,7 +963,7 @@ export default function QuickPurchase() { const showTariffCards = visibleTariffs.length > 1; return ( -
+
{config.background_config && }
{/* Language switcher */} From 68e6ce1bce1edc3c6048c1ed873865a27c39ea52 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 15:59:45 +0300 Subject: [PATCH 6/7] fix: send Bearer token on email register (link to Telegram account) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /cabinet/auth/email/register requires authentication (links email to existing Telegram account) but was in the AUTH_ENDPOINTS skip-list, causing the request interceptor to omit the Bearer token → 401. Change the skip-list entry to the specific /standalone path which genuinely doesn't need a Bearer token. --- src/api/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/client.ts b/src/api/client.ts index d200982..4e38a3d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -69,7 +69,7 @@ const AUTH_ENDPOINTS = [ '/cabinet/auth/telegram', '/cabinet/auth/telegram/widget', '/cabinet/auth/email/login', - '/cabinet/auth/email/register', + '/cabinet/auth/email/register/standalone', '/cabinet/auth/email/verify', '/cabinet/auth/refresh', '/cabinet/auth/password/forgot', From 32091d3648889795d01d78bff933da3a38caa10f Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 7 Mar 2026 16:15:20 +0300 Subject: [PATCH 7/7] fix: replace deprecated Telegram Login Widget redirect with callback Telegram deprecated the data-auth-url redirect flow, which returns a blank page with "deprecated" text. Switch both TelegramLinkWidget (account linking) and TelegramLoginButton (legacy login) to use data-onauth callback approach that works client-side without redirects. --- src/components/TelegramLoginButton.tsx | 27 +++++++++++++++-- src/pages/ConnectedAccounts.tsx | 42 +++++++++++++++++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) 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/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx index 4217dbf..977e67e 100644 --- a/src/pages/ConnectedAccounts.tsx +++ b/src/pages/ConnectedAccounts.tsx @@ -27,6 +27,10 @@ export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state'; /** Compact Telegram Login Widget for account linking (browser only). */ function TelegramLinkWidget() { const containerRef = useRef(null); + const navigate = useNavigate(); + const { showToast } = useToast(); + const { t } = useTranslation(); + const queryClient = useQueryClient(); const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; useEffect(() => { @@ -37,29 +41,53 @@ function TelegramLinkWidget() { container.removeChild(container.firstChild); } - // Generate CSRF state token and store in sessionStorage - const csrfState = crypto.randomUUID(); - sessionStorage.setItem(LINK_TELEGRAM_STATE_KEY, csrfState); - - const redirectUrl = `${window.location.origin}/auth/link/telegram/callback?csrf_state=${encodeURIComponent(csrfState)}`; + // Global callback invoked by the Telegram Login Widget + const callbackName = '__onTelegramLinkAuth'; + (window as unknown as Record)[callbackName] = async ( + user: Record, + ) => { + try { + const response = await authApi.linkTelegram({ + 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, + }); + if (response.merge_required && response.merge_token) { + navigate(`/merge/${response.merge_token}`, { replace: true }); + } else { + queryClient.invalidateQueries({ queryKey: ['linked-providers'] }); + showToast({ type: 'success', message: t('profile.accounts.linkSuccess') }); + } + } catch (err: unknown) { + showToast({ + type: 'error', + message: getErrorDetail(err) || t('profile.accounts.linkError'), + }); + } + }; const script = document.createElement('script'); script.src = 'https://telegram.org/js/telegram-widget.js?23'; script.setAttribute('data-telegram-login', botUsername); script.setAttribute('data-size', 'small'); script.setAttribute('data-radius', '8'); - script.setAttribute('data-auth-url', redirectUrl); + script.setAttribute('data-onauth', `${callbackName}(user)`); script.setAttribute('data-request-access', 'write'); script.async = true; container.appendChild(script); return () => { + delete (window as unknown as Record)[callbackName]; while (container.firstChild) { container.removeChild(container.firstChild); } }; - }, [botUsername]); + }, [botUsername, navigate, showToast, t, queryClient]); if (!botUsername) { return null;