import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { PiCaretDownDuotone } from 'react-icons/pi'; import { useNavigate, useParams } from 'react-router'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminLandingsApi, type LandingCreateRequest, type AdminLandingFeature, type EditableMethodField, type LocaleDict, type SupportedLocale, toLocaleDict, } from '../api/landings'; import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs'; import { formatPrice } from '../utils/format'; import { useCurrency } from '../hooks/useCurrency'; 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, type MethodWithId, } from '../components/admin/SortableSelectedMethodCard'; import { useNotify } from '@/platform'; import { usePlatform } from '../platform/hooks/usePlatform'; import { getApiErrorMessage } from '../utils/api-error'; import { BackIcon, PlusIcon } from '../components/icons/LandingIcons'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { cn } from '../lib/utils'; import type { PaymentMethodSubOptionInfo } from '../types'; function isoToDatetimeLocal(iso: string): string { const d = new Date(iso); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } const ChevronDownIcon = ({ open }: { open: boolean }) => ( ); // ============ Collapsible Section ============ interface SectionProps { title: string; open: boolean; onToggle: () => void; children: React.ReactNode; } function Section({ title, open, onToggle, children }: SectionProps) { return (
{open &&
{children}
}
); } // ============ Main Editor ============ export default function AdminLandingEditor() { const { t } = useTranslation(); const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const queryClient = useQueryClient(); const notify = useNotify(); const { capabilities } = usePlatform(); const isEdit = !!id; // Прогреваем кэш курсов валют для formatPrice (preview лендинга в не-RU локали). useCurrency(); // Section visibility const [openSections, setOpenSections] = useState>({ general: true, features: false, tariffs: false, discount: false, methods: false, gifts: false, background: false, analytics: false, footer: false, }); const toggleSection = useCallback((key: string) => { setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); }, []); // Locale editing state const [editingLocale, setEditingLocale] = useState('ru'); // Form state — text fields are now LocaleDict const [slug, setSlug] = useState(''); const [title, setTitle] = useState({ ru: '' }); const [subtitle, setSubtitle] = useState({}); const [isActive, setIsActive] = useState(true); const [metaTitle, setMetaTitle] = useState({}); const [metaDescription, setMetaDescription] = useState({}); const [features, setFeatures] = useState([]); const [selectedTariffIds, setSelectedTariffIds] = useState([]); const [allowedPeriods, setAllowedPeriods] = useState>({}); const [paymentMethods, setPaymentMethods] = useState([]); const [giftEnabled, setGiftEnabled] = useState(false); const [footerText, setFooterText] = useState({}); const [customCss, setCustomCss] = useState(''); // Background config state const [backgroundConfig, setBackgroundConfig] = useState({ ...DEFAULT_ANIMATION_CONFIG, enabled: false, }); // Analytics goals state const [analyticsViewEnabled, setAnalyticsViewEnabled] = useState(false); const [analyticsViewGoal, setAnalyticsViewGoal] = useState('landing_view'); const [analyticsClickEnabled, setAnalyticsClickEnabled] = useState(false); const [analyticsClickGoal, setAnalyticsClickGoal] = useState('landing_pay'); const [stickyPayButton, setStickyPayButton] = useState(false); // Discount state const [discountPercent, setDiscountPercent] = useState(null); const [discountOverrides, setDiscountOverrides] = useState>({}); const [discountStartsAt, setDiscountStartsAt] = useState(''); const [discountEndsAt, setDiscountEndsAt] = useState(''); const [discountBadgeText, setDiscountBadgeText] = useState({}); // DnD sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); // Fetch tariffs for selection const { data: tariffsData } = useQuery({ queryKey: ['admin-tariffs'], queryFn: () => tariffsApi.getTariffs(true), staleTime: 30_000, }); const allTariffs = tariffsData?.tariffs ?? []; // Fetch system payment methods const { data: systemMethods } = useQuery({ queryKey: ['admin-payment-methods'], queryFn: () => adminPaymentMethodsApi.getAll(), staleTime: 30_000, }); const availablePaymentMethods = useMemo( () => (systemMethods ?? []).filter((m) => m.is_enabled && m.is_provider_configured), [systemMethods], ); const subOptionsMap = useMemo(() => { const map: Record = {}; for (const m of availablePaymentMethods) { if (m.available_sub_options && m.available_sub_options.length > 0) { map[m.method_id] = m.available_sub_options; } } return map; }, [availablePaymentMethods]); // Fetch tariff details for period info const tariffDetailQueries = useQueries({ queries: selectedTariffIds.map((tariffId) => ({ queryKey: ['admin-tariff-detail', tariffId], queryFn: () => tariffsApi.getTariff(tariffId), staleTime: 60_000, enabled: selectedTariffIds.includes(tariffId), })), }); const tariffPeriodsData = tariffDetailQueries.map((q) => q.data); const tariffPeriodsMap = useMemo(() => { const map: Record = {}; tariffPeriodsData.forEach((data) => { if (data) { map[data.id] = data.period_prices; } }); return map; // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(tariffPeriodsData.map((d) => d?.id)), selectedTariffIds]); // Fetch landing for editing const { data: landingData } = useQuery({ queryKey: ['admin-landing', id], queryFn: () => adminLandingsApi.get(Number(id)), enabled: isEdit, staleTime: 0, gcTime: 0, refetchOnMount: true, refetchOnWindowFocus: false, }); // Populate form from fetched data (only once) const formPopulated = useRef(false); // Reset formPopulated when navigating to a different landing useEffect(() => { formPopulated.current = false; }, [id]); useEffect(() => { if (!landingData || formPopulated.current) return; formPopulated.current = true; setSlug(landingData.slug); setTitle(toLocaleDict(landingData.title, { ru: '' })); setSubtitle(toLocaleDict(landingData.subtitle)); setIsActive(landingData.is_active); setMetaTitle(toLocaleDict(landingData.meta_title)); setMetaDescription(toLocaleDict(landingData.meta_description)); setFeatures( (landingData.features ?? []).map((f) => ({ ...f, _id: crypto.randomUUID(), title: toLocaleDict(f.title), description: toLocaleDict(f.description), })), ); setSelectedTariffIds(landingData.allowed_tariff_ids ?? []); setAllowedPeriods(landingData.allowed_periods ?? {}); setPaymentMethods( (landingData.payment_methods ?? []).map((m) => ({ ...m, _id: crypto.randomUUID(), min_amount_kopeks: m.min_amount_kopeks ?? null, max_amount_kopeks: m.max_amount_kopeks ?? null, currency: m.currency ?? null, return_url: m.return_url ?? null, sub_options: m.sub_options ?? null, })), ); 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( landingData.discount_starts_at ? isoToDatetimeLocal(landingData.discount_starts_at) : '', ); setDiscountEndsAt( landingData.discount_ends_at ? isoToDatetimeLocal(landingData.discount_ends_at) : '', ); setDiscountBadgeText(toLocaleDict(landingData.discount_badge_text)); setAnalyticsViewEnabled(landingData.analytics_view_enabled ?? false); setAnalyticsViewGoal(landingData.analytics_view_goal || 'landing_view'); setAnalyticsClickEnabled(landingData.analytics_click_enabled ?? false); setAnalyticsClickGoal(landingData.analytics_click_goal || 'landing_pay'); setStickyPayButton(landingData.sticky_pay_button ?? false); }, [landingData]); // Create mutation const createMutation = useMutation({ mutationFn: adminLandingsApi.create, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-landings'] }); notify.success(t('admin.landings.created')); navigate('/admin/landings'); }, onError: (err: unknown) => { notify.error(getApiErrorMessage(err, t('common.error'))); }, }); // Update mutation const updateMutation = useMutation({ mutationFn: ({ landingId, data }: { landingId: number; data: LandingCreateRequest }) => adminLandingsApi.update(landingId, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-landings'] }); queryClient.invalidateQueries({ queryKey: ['admin-landing', id] }); notify.success(t('admin.landings.updated')); navigate('/admin/landings'); }, onError: (err: unknown) => { notify.error(getApiErrorMessage(err, t('common.error'))); }, }); /** Return a LocaleDict only if it has at least one non-empty value, else undefined */ const nonEmptyDict = (dict: LocaleDict): LocaleDict | undefined => { const filtered = Object.fromEntries( Object.entries(dict).filter(([, v]) => typeof v === 'string' && v.trim().length > 0), ); return Object.keys(filtered).length > 0 ? filtered : undefined; }; const handleSubmit = () => { // Client-side validation if (!isEdit && !/^[a-z0-9-]+$/.test(slug)) { notify.error( t( 'admin.landings.invalidSlug', 'Slug can only contain lowercase letters, numbers, and hyphens', ), ); return; } const titleHasContent = Object.values(title).some((v) => v.trim().length > 0); if (!titleHasContent) { notify.error(t('admin.landings.titleRequired', 'Title is required')); return; } if (selectedTariffIds.length === 0) { notify.error(t('admin.landings.noTariffs', 'Select at least one tariff')); return; } if (paymentMethods.length === 0) { notify.error(t('admin.landings.noPaymentMethods', 'Add at least one payment method')); return; } // Strip _id before sending to API const cleanFeatures: AdminLandingFeature[] = features.map(({ _id: _, ...rest }) => rest); const cleanMethods = paymentMethods.map(({ _id: _, ...rest }) => ({ ...rest, description: rest.description || null, icon_url: rest.icon_url || null, min_amount_kopeks: rest.min_amount_kopeks ?? null, max_amount_kopeks: rest.max_amount_kopeks ?? null, currency: rest.currency || null, return_url: rest.return_url || null, sub_options: rest.sub_options ?? null, })); // Filter out empty period arrays and periods for non-selected tariffs const cleanPeriods = Object.fromEntries( Object.entries(allowedPeriods).filter( ([key, days]) => days.length > 0 && selectedTariffIds.includes(Number(key)), ), ); const data: LandingCreateRequest = { slug, title, subtitle: nonEmptyDict(subtitle), is_active: isActive, features: cleanFeatures, footer_text: nonEmptyDict(footerText), allowed_tariff_ids: selectedTariffIds, allowed_periods: cleanPeriods, payment_methods: cleanMethods, gift_enabled: giftEnabled, custom_css: customCss || undefined, meta_title: nonEmptyDict(metaTitle), meta_description: nonEmptyDict(metaDescription), discount_percent: discountPercent ?? null, discount_overrides: discountPercent !== null && Object.keys(discountOverrides).length > 0 ? discountOverrides : null, discount_starts_at: discountPercent !== null && discountStartsAt ? new Date(discountStartsAt).toISOString() : null, discount_ends_at: discountPercent !== null && discountEndsAt ? new Date(discountEndsAt).toISOString() : null, discount_badge_text: discountPercent !== null ? (nonEmptyDict(discountBadgeText) ?? null) : null, background_config: backgroundConfig.enabled ? backgroundConfig : null, analytics_view_enabled: analyticsViewEnabled, analytics_view_goal: analyticsViewGoal, analytics_click_enabled: analyticsClickEnabled, analytics_click_goal: analyticsClickGoal, sticky_pay_button: stickyPayButton, }; if (isEdit) { updateMutation.mutate({ landingId: Number(id), data }); } else { createMutation.mutate(data); } }; const isPending = createMutation.isPending || updateMutation.isPending; // ---- Features helpers ---- const addFeature = () => { setFeatures((prev) => [ ...prev, { _id: crypto.randomUUID(), icon: '', title: {}, description: {} }, ]); }; const updateFeatureIcon = useCallback((index: number, value: string) => { setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, icon: value } : f))); }, []); const updateFeatureLocalized = useCallback( (index: number, field: 'title' | 'description', value: LocaleDict) => { setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, [field]: value } : f))); }, [], ); const removeFeature = useCallback((index: number) => { setFeatures((prev) => prev.filter((_, i) => i !== index)); }, []); const handleFeatureDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setFeatures((prev) => { const oldIndex = prev.findIndex((f) => f._id === active.id); const newIndex = prev.findIndex((f) => f._id === over.id); if (oldIndex === -1 || newIndex === -1) return prev; return arrayMove(prev, oldIndex, newIndex); }); } }, []); // ---- Payment methods helpers ---- const togglePaymentMethod = useCallback( (methodId: string) => { setPaymentMethods((prev) => { const exists = prev.find((m) => m.method_id === methodId); if (exists) { return prev.filter((m) => m.method_id !== methodId); } const systemMethod = availablePaymentMethods.find((m) => m.method_id === methodId); if (!systemMethod) return prev; // Seed sub_options from global config defaults; if no global overrides exist, // explicitly set all available sub-options to enabled const subOptions = systemMethod.available_sub_options && systemMethod.available_sub_options.length > 0 ? systemMethod.sub_options ? { ...systemMethod.sub_options } : Object.fromEntries(systemMethod.available_sub_options.map((o) => [o.id, true])) : null; return [ ...prev, { _id: crypto.randomUUID(), method_id: systemMethod.method_id, display_name: systemMethod.display_name ?? systemMethod.default_display_name, description: null, icon_url: null, sort_order: prev.length, min_amount_kopeks: systemMethod.min_amount_kopeks ?? null, max_amount_kopeks: systemMethod.max_amount_kopeks ?? null, currency: null, return_url: null, sub_options: subOptions, }, ]; }); }, [availablePaymentMethods], ); const updatePaymentMethod = useCallback( (methodId: string, field: EditableMethodField, value: string | number | null) => { setPaymentMethods((prev) => prev.map((m) => (m.method_id === methodId ? { ...m, [field]: value } : m)), ); }, [], ); const updateSubOptions = useCallback((methodId: string, subOptions: Record) => { setPaymentMethods((prev) => prev.map((m) => (m.method_id === methodId ? { ...m, sub_options: subOptions } : m)), ); }, []); const removePaymentMethod = useCallback((methodId: string) => { setPaymentMethods((prev) => prev.filter((m) => m.method_id !== methodId)); }, []); const handleMethodDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setPaymentMethods((prev) => { const oldIndex = prev.findIndex((m) => m._id === active.id); const newIndex = prev.findIndex((m) => m._id === over.id); if (oldIndex === -1 || newIndex === -1) return prev; return arrayMove(prev, oldIndex, newIndex); }); } }, []); // ---- Tariff/period helpers ---- const toggleTariff = (tariffId: number) => { setSelectedTariffIds((prev) => { if (prev.includes(tariffId)) { const key = String(tariffId); // Clean up allowedPeriods for unchecked tariff setAllowedPeriods((ap) => { if (!(key in ap)) return ap; const { [key]: _, ...rest } = ap; return rest; }); // Clean up discount override for unchecked tariff setDiscountOverrides((overrides) => { if (!(key in overrides)) return overrides; const { [key]: _, ...rest } = overrides; return rest; }); return prev.filter((id) => id !== tariffId); } return [...prev, tariffId]; }); }; const togglePeriodFromTariff = (tariffId: number, days: number, allPeriods: PeriodPrice[]) => { const key = String(tariffId); const allDays = allPeriods.map((p) => p.days); setAllowedPeriods((prev) => { const current = prev[key]; if (!current) { // No override yet -- all periods allowed. Remove this one. const updated = allDays.filter((d) => d !== days); return { ...prev, [key]: updated }; } const hasDay = current.includes(days); const updated = hasDay ? current.filter((d) => d !== days) : [...current, days]; // If all periods are selected again, remove the override if (updated.length === allDays.length) { const { [key]: _, ...rest } = prev; return rest; } return { ...prev, [key]: updated }; }); }; // Feature IDs for DnD const featureIds = useMemo(() => features.map((f) => f._id), [features]); const methodIds = useMemo(() => paymentMethods.map((m) => m._id), [paymentMethods]); return (
{/* Header */}
{!capabilities.hasBackButton && ( )}

{isEdit ? t('admin.landings.edit') : t('admin.landings.create')}

{/* Locale tabs — always visible above sections */} [f.title, f.description]), ]} className="mb-4" />
{/* General Section */}
toggleSection('general')} >
setSlug(e.target.value)} disabled={isEdit} placeholder="my-landing" className="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 disabled:opacity-50" />

{t('admin.landings.slugHint')}

setIsActive(!isActive)} />
{/* SEO */}

{t('admin.landings.seo')}

{/* Features Section */}
toggleSection('features')} >
{features.map((feature, index) => ( ))}
{/* Tariffs Section */}
toggleSection('tariffs')} >

{t('admin.landings.selectTariffs')}

{allTariffs.map((tariff: TariffListItem) => (
{/* Period checkboxes from tariff detail */} {selectedTariffIds.includes(tariff.id) && !tariff.is_daily && (
{t('admin.landings.periods')}: {tariffPeriodsMap[tariff.id] ? (
{tariffPeriodsMap[tariff.id].map((period) => { const override = allowedPeriods[String(tariff.id)]; const isAllowed = !override || override.includes(period.days); return ( ); })}
) : ( {t('admin.landings.loadingPeriods')} )}
)}
))}
{/* Discount Section */}
toggleSection('discount')} >
{/* Enable/disable discount */}
{ if (discountPercent !== null) { setDiscountPercent(null); setDiscountOverrides({}); setDiscountStartsAt(''); setDiscountEndsAt(''); setDiscountBadgeText({}); } else { setDiscountPercent(10); } }} />
{discountPercent !== null && (
{/* Global percent */}
setDiscountPercent(Number(e.target.value))} className="h-2 flex-1 cursor-pointer appearance-none rounded-lg bg-dark-700 accent-accent-500" />
{ const v = Math.min(99, Math.max(1, Number(e.target.value) || 1)); setDiscountPercent(v); }} className="w-full bg-transparent px-2 py-1.5 text-center text-sm text-dark-100 outline-none" /> %
{/* Date range */}
setDiscountStartsAt(e.target.value)} className="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 [&::-webkit-calendar-picker-indicator]:invert" />
setDiscountEndsAt(e.target.value)} className="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 [&::-webkit-calendar-picker-indicator]:invert" />
{/* Badge text */}
{/* Per-tariff overrides */} {selectedTariffIds.length > 0 && (

{t( 'admin.landings.discountOverridesHint', 'Leave empty to use global discount', )}

{selectedTariffIds.map((tariffId) => { const tariff = allTariffs.find((tr) => tr.id === tariffId); if (!tariff) return null; const override = discountOverrides[String(tariffId)]; const hasOverride = override !== undefined; return (
{tariff.name} {hasOverride ? `${override}%` : `${discountPercent}%`} { const val = e.target.value; setDiscountOverrides((prev) => { if (!val) { const { [String(tariffId)]: _, ...rest } = prev; return rest; } return { ...prev, [String(tariffId)]: Math.min(99, Math.max(1, Number(val))), }; }); }} className="w-16 rounded border border-dark-600 bg-dark-700 px-2 py-1 text-center text-sm text-dark-100 outline-none focus:border-accent-500" />
); })}
)} {/* Preview */} {selectedTariffIds.length > 0 && (

{t('admin.landings.discountPreview', 'Preview')}

{selectedTariffIds.slice(0, 3).map((tariffId) => { const tariff = allTariffs.find((tr) => tr.id === tariffId); if (!tariff) return null; const override = discountOverrides[String(tariffId)]; const pct = override ?? discountPercent ?? 0; const periods = tariffPeriodsMap[tariffId]; if (!periods || periods.length === 0) return null; const firstPeriod = periods[0]; const discounted = Math.max( 1, firstPeriod.price_kopeks - Math.floor((firstPeriod.price_kopeks * pct) / 100), ); return (
{tariff.name}: {formatPrice(firstPeriod.price_kopeks)} {formatPrice(discounted)} -{pct}%
); })}
)}
)}
{/* Payment Methods Section */}
toggleSection('methods')} >
{/* Available system methods as toggleable list */}

{t('admin.landings.selectMethods')}

{availablePaymentMethods.map((sysMethod) => { const isSelected = paymentMethods.some( (m) => m.method_id === sysMethod.method_id, ); return ( ); })} {availablePaymentMethods.length === 0 && (

{t('admin.landings.noSystemMethods')}

)}
{/* Selected methods with drag-to-reorder */} {paymentMethods.length > 0 && (

{t('admin.landings.methodOrder')}

{paymentMethods.map((method) => ( ))}
)}
{/* Gift Settings Section */}
toggleSection('gifts')} >
setGiftEnabled(!giftEnabled)} />
{/* Background Section */}
toggleSection('background')} >
{/* Analytics Goals Section */}
toggleSection('analytics')} >
{/* View Goal */}
setAnalyticsViewGoal(e.target.value)} disabled={!analyticsViewEnabled} className="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 disabled:opacity-50" placeholder="landing_view" />
setAnalyticsViewEnabled((v) => !v)} />
{/* Click Goal */}
setAnalyticsClickGoal(e.target.value)} disabled={!analyticsClickEnabled} className="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 disabled:opacity-50" placeholder="landing_pay" />
setAnalyticsClickEnabled((v) => !v)} />
{/* Sticky pay button on mobile */}

{t('admin.landings.stickyPayButton', 'Sticky pay button (mobile)')}

{t( 'admin.landings.stickyPayButtonHint', 'Button pinned to bottom of screen on mobile', )}

setStickyPayButton((v) => !v)} />
{/* Footer & Custom CSS Section */}
toggleSection('footer')} >