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, type LandingCreateRequest, type AdminLandingFeature, type LandingPaymentMethod, type LocaleDict, type SupportedLocale, toLocaleDict, } from '../api/landings'; 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 { 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 = AdminLandingFeature & { _id: string }; type MethodWithId = LandingPaymentMethod & { _id: string }; const ChevronDownIcon = ({ open }: { open: boolean }) => ( ); // ============ Sortable Feature Item ============ interface SortableFeatureProps { feature: FeatureWithId; index: number; locale: SupportedLocale; onUpdateIcon: (index: number, value: string) => void; onUpdateLocalized: (index: number, field: 'title' | 'description', value: LocaleDict) => void; onRemove: (index: number) => void; } function SortableFeatureItem({ feature, index, locale, onUpdateIcon, onUpdateLocalized, onRemove, }: SortableFeatureProps) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: feature._id, }); const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 50 : undefined, position: isDragging ? 'relative' : undefined, }; return (
onUpdateIcon(index, e.target.value)} placeholder={t('admin.landings.featureIcon')} className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500" /> onUpdateLocalized(index, 'title', v)} locale={locale} placeholder={t('admin.landings.featureTitle')} className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" />
onUpdateLocalized(index, 'description', v)} locale={locale} placeholder={t('admin.landings.featureDesc')} className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" />
); } // ============ 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 = 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(''); // 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 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() })), ); setGiftEnabled(landingData.gift_enabled); setFooterText(toLocaleDict(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'))); }, }); /** 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); // 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), }; 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 = (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) => { if (prev.includes(tariffId)) { // Clean up allowedPeriods for unchecked tariff const key = String(tariffId); setAllowedPeriods((ap) => { if (!(key in ap)) return ap; const { [key]: _, ...rest } = ap; 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')} )}
)}
))}
{/* 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)} />
{/* Footer & Custom CSS Section */}
toggleSection('footer')} >