import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminLandingsApi, LandingCreateRequest, LandingFeature, LandingPaymentMethod, } from '../api/landings'; import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { Toggle } from '../components/admin'; import { useNotify } from '@/platform'; import { usePlatform } from '../platform/hooks/usePlatform'; import { getApiErrorMessage } from '../utils/api-error'; import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons'; import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { cn } from '../lib/utils'; // Types with stable IDs for DnD type FeatureWithId = LandingFeature & { _id: string }; type MethodWithId = LandingPaymentMethod & { _id: string }; const ChevronDownIcon = ({ open }: { open: boolean }) => ( ); function formatPrice(kopeks: number): string { return `${(kopeks / 100).toLocaleString('ru-RU')} \u20BD`; } // ============ Sortable Feature Item ============ interface SortableFeatureProps { feature: FeatureWithId; index: number; onUpdate: (index: number, field: keyof LandingFeature, value: string) => void; onRemove: (index: number) => void; } function SortableFeatureItem({ feature, index, onUpdate, onRemove }: SortableFeatureProps) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: feature._id, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, position: isDragging ? 'relative' : undefined, }; return (
onUpdate(index, 'icon', e.target.value)} placeholder={t('admin.landings.featureIcon')} className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500" /> onUpdate(index, 'title', e.target.value)} placeholder={t('admin.landings.featureTitle')} className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" />
onUpdate(index, 'description', e.target.value)} placeholder={t('admin.landings.featureDesc')} className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" />
); } // ============ Sortable Selected Payment Method Card ============ interface SortableSelectedMethodProps { method: MethodWithId; onRemove: (methodId: string) => void; } function SortableSelectedMethodCard({ method, onRemove }: SortableSelectedMethodProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: method._id, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, position: isDragging ? 'relative' : undefined, }; return (
{method.display_name}
); } // ============ 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; // Section visibility const [openSections, setOpenSections] = useState>({ general: true, features: false, tariffs: false, methods: false, gifts: false, footer: false, }); const toggleSection = (key: string) => { setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); }; // Form state const [slug, setSlug] = useState(''); const [title, setTitle] = useState(''); 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(''); // 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], ); // 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 tariffPeriodsMap = useMemo(() => { const map: Record = {}; tariffDetailQueries.forEach((q, i) => { if (q.data) { map[selectedTariffIds[i]] = q.data.period_prices; } }); return map; }, [tariffDetailQueries, 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(landingData.title); setSubtitle(landingData.subtitle ?? ''); setIsActive(landingData.is_active); setMetaTitle(landingData.meta_title ?? ''); setMetaDescription(landingData.meta_description ?? ''); setFeatures((landingData.features ?? []).map((f) => ({ ...f, _id: crypto.randomUUID() }))); setSelectedTariffIds(landingData.allowed_tariff_ids ?? []); setAllowedPeriods(landingData.allowed_periods ?? {}); setPaymentMethods( (landingData.payment_methods ?? []).map((m) => ({ ...m, _id: crypto.randomUUID() })), ); setGiftEnabled(landingData.gift_enabled); setFooterText(landingData.footer_text ?? ''); setCustomCss(landingData.custom_css ?? ''); }, [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'))); }, }); 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; } if (!title.trim()) { 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 = features.map(({ _id: _, ...rest }) => rest); const cleanMethods = paymentMethods.map(({ _id: _, ...rest }) => rest); const data: LandingCreateRequest = { slug, title, subtitle: subtitle || undefined, is_active: isActive, features: cleanFeatures, footer_text: footerText || undefined, allowed_tariff_ids: selectedTariffIds, allowed_periods: allowedPeriods, payment_methods: cleanMethods, gift_enabled: giftEnabled, custom_css: customCss || undefined, meta_title: metaTitle || undefined, meta_description: metaDescription || undefined, }; 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 updateFeature = (index: number, field: keyof LandingFeature, value: string) => { setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, [field]: value } : f))); }; const removeFeature = (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 = (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; return [ ...prev, { _id: crypto.randomUUID(), method_id: systemMethod.method_id, display_name: systemMethod.display_name ?? systemMethod.default_display_name, description: '', icon_url: '', sort_order: prev.length, }, ]; }); }; const removePaymentMethod = (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) => prev.includes(tariffId) ? prev.filter((id) => id !== tariffId) : [...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 = features.map((f) => f._id); const methodIds = paymentMethods.map((m) => m._id); return (
{/* Header */}
{!capabilities.hasBackButton && ( )}

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

{/* 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')}

setTitle(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" />