feat: публичные лендинг-страницы для быстрой покупки VPN-подписок

- QuickPurchase: публичная страница покупки с выбором тарифа/периода/оплаты
- PurchaseSuccess: статус покупки с поллингом и копированием ссылки
- AdminLandings: управление лендингами (создание, сортировка, удаление)
- AdminLandingEditor: полнофункциональный редактор лендинга
- API-клиент landings.ts для всех эндпоинтов
- Роутинг /buy/:slug и /buy/success/:token
- Локализация ru/en/zh/fa для всех текстов лендинга
- Санитизация HTML/CSS, rate limit защита, DOMPurify
This commit is contained in:
Fringg
2026-03-06 07:02:21 +03:00
parent e278fec506
commit 8b5d777f0a
16 changed files with 3813 additions and 766 deletions

View File

@@ -0,0 +1,829 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
adminLandingsApi,
LandingCreateRequest,
LandingFeature,
LandingPaymentMethod,
} from '../api/landings';
import { tariffsApi, TariffListItem } from '../api/tariffs';
import { Toggle } from '../components/admin';
import { useNotify } from '@/platform';
import { usePlatform } from '../platform/hooks/usePlatform';
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';
// Icons
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const PlusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const GripIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const ChevronDownIcon = ({ open }: { open: boolean }) => (
<svg
className={`h-5 w-5 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
// ============ Sortable Feature Item ============
interface SortableFeatureProps {
feature: LandingFeature;
featureId: string;
index: number;
onUpdate: (index: number, field: keyof LandingFeature, value: string) => void;
onRemove: (index: number) => void;
}
function SortableFeatureItem({
feature,
featureId,
index,
onUpdate,
onRemove,
}: SortableFeatureProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: featureId,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-start gap-2 rounded-lg border p-3 ${
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50'
}`}
>
<button
{...attributes}
{...listeners}
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
>
<GripIcon />
</button>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex gap-2">
<input
type="text"
value={feature.icon}
onChange={(e) => 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"
/>
<input
type="text"
value={feature.title}
onChange={(e) => 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"
/>
</div>
<input
type="text"
value={feature.description}
onChange={(e) => 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"
/>
</div>
<button
onClick={() => onRemove(index)}
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
>
<TrashIcon />
</button>
</div>
);
}
// ============ Sortable Payment Method ============
interface SortableMethodProps {
method: LandingPaymentMethod;
methodId: string;
index: number;
onUpdate: (index: number, field: keyof LandingPaymentMethod, value: string) => void;
onRemove: (index: number) => void;
}
function SortableMethodItem({ method, methodId, index, onUpdate, onRemove }: SortableMethodProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: methodId,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-start gap-2 rounded-lg border p-3 ${
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50'
}`}
>
<button
{...attributes}
{...listeners}
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
>
<GripIcon />
</button>
<div className="min-w-0 flex-1 space-y-2">
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={method.method_id}
onChange={(e) => onUpdate(index, 'method_id', e.target.value)}
placeholder={t('admin.landings.methodId')}
className="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"
/>
<input
type="text"
value={method.display_name}
onChange={(e) => onUpdate(index, 'display_name', e.target.value)}
placeholder={t('admin.landings.methodName')}
className="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"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
value={method.description}
onChange={(e) => onUpdate(index, 'description', e.target.value)}
placeholder={t('admin.landings.methodDesc')}
className="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"
/>
<input
type="text"
value={method.icon_url}
onChange={(e) => onUpdate(index, 'icon_url', e.target.value)}
placeholder={t('admin.landings.methodIcon')}
className="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"
/>
</div>
</div>
<button
onClick={() => onRemove(index)}
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
>
<TrashIcon />
</button>
</div>
);
}
// ============ Collapsible Section ============
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}
function Section({ title, open, onToggle, children }: SectionProps) {
return (
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-900/50">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-dark-100 hover:bg-dark-800/50"
>
{title}
<ChevronDownIcon open={open} />
</button>
{open && <div className="border-t border-dark-700 px-4 py-4">{children}</div>}
</div>
);
}
// ============ 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<Record<string, boolean>>({
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<LandingFeature[]>([]);
const [selectedTariffIds, setSelectedTariffIds] = useState<number[]>([]);
const [allowedPeriods, setAllowedPeriods] = useState<Record<string, number[]>>({});
const [paymentMethods, setPaymentMethods] = useState<LandingPaymentMethod[]>([]);
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),
});
const allTariffs = tariffsData?.tariffs ?? [];
// 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 ?? []);
setSelectedTariffIds(landingData.allowed_tariff_ids ?? []);
setAllowedPeriods(landingData.allowed_periods ?? {});
setPaymentMethods(landingData.payment_methods ?? []);
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) => {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail;
notify.error(message || 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) => {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail;
notify.error(message || t('common.error'));
},
});
const handleSubmit = () => {
const data: LandingCreateRequest = {
slug,
title,
subtitle: subtitle || undefined,
is_active: isActive,
features,
footer_text: footerText || undefined,
allowed_tariff_ids: selectedTariffIds,
allowed_periods: allowedPeriods,
payment_methods: paymentMethods,
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, { 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((_, i) => `feature-${i}` === active.id);
const newIndex = prev.findIndex((_, i) => `feature-${i}` === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
}
}, []);
// ---- Payment methods helpers ----
const addMethod = () => {
setPaymentMethods((prev) => [
...prev,
{
method_id: '',
display_name: '',
description: '',
icon_url: '',
sort_order: prev.length,
},
]);
};
const updateMethod = (index: number, field: keyof LandingPaymentMethod, value: string) => {
setPaymentMethods((prev) => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m)));
};
const removeMethod = (index: number) => {
setPaymentMethods((prev) => prev.filter((_, i) => i !== index));
};
const handleMethodDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setPaymentMethods((prev) => {
const oldIndex = prev.findIndex((_, i) => `method-${i}` === active.id);
const newIndex = prev.findIndex((_, i) => `method-${i}` === 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 togglePeriod = (tariffId: number, days: number) => {
const key = String(tariffId);
setAllowedPeriods((prev) => {
const current = prev[key] ?? [];
const updated = current.includes(days)
? current.filter((d) => d !== days)
: [...current, days];
return { ...prev, [key]: updated };
});
};
// Feature IDs for DnD
const featureIds = features.map((_, i) => `feature-${i}`);
const methodIds = paymentMethods.map((_, i) => `method-${i}`);
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin/landings')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<h1 className="text-xl font-semibold text-dark-100">
{isEdit ? t('admin.landings.edit') : t('admin.landings.create')}
</h1>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => navigate('/admin/landings')}
className="rounded-lg border border-dark-700 bg-dark-800 px-4 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600"
>
{t('admin.landings.back')}
</button>
<button
onClick={handleSubmit}
disabled={isPending || !slug || !title}
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{isPending && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
)}
{t('admin.landings.save')}
</button>
</div>
</div>
<div className="space-y-4">
{/* General Section */}
<Section
title={t('admin.landings.general')}
open={openSections.general}
onToggle={() => toggleSection('general')}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm text-dark-400">{t('admin.landings.slug')}</label>
<input
type="text"
value={slug}
onChange={(e) => 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"
/>
<p className="mt-1 text-xs text-dark-500">{t('admin.landings.slugHint')}</p>
</div>
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.pageTitle')}
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.subtitle')}
</label>
<textarea
value={subtitle}
onChange={(e) => setSubtitle(e.target.value)}
rows={2}
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"
/>
</div>
<div className="flex items-center justify-between">
<label className="text-sm text-dark-400">{t('admin.landings.active')}</label>
<Toggle checked={isActive} onChange={() => setIsActive(!isActive)} />
</div>
{/* SEO */}
<div className="border-t border-dark-700 pt-4">
<h4 className="mb-3 text-sm font-medium text-dark-300">{t('admin.landings.seo')}</h4>
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.metaTitle')}
</label>
<input
type="text"
value={metaTitle}
onChange={(e) => setMetaTitle(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"
/>
</div>
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.metaDesc')}
</label>
<textarea
value={metaDescription}
onChange={(e) => setMetaDescription(e.target.value)}
rows={2}
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"
/>
</div>
</div>
</div>
</div>
</Section>
{/* Features Section */}
<Section
title={t('admin.landings.features')}
open={openSections.features}
onToggle={() => toggleSection('features')}
>
<div className="space-y-3">
<DndContext sensors={sensors} onDragEnd={handleFeatureDragEnd}>
<SortableContext items={featureIds} strategy={verticalListSortingStrategy}>
{features.map((feature, index) => (
<SortableFeatureItem
key={featureIds[index]}
feature={feature}
featureId={featureIds[index]}
index={index}
onUpdate={updateFeature}
onRemove={removeFeature}
/>
))}
</SortableContext>
</DndContext>
<button
onClick={addFeature}
className="flex items-center gap-2 rounded-lg border border-dashed border-dark-600 px-4 py-2 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:text-dark-300"
>
<PlusIcon />
{t('admin.landings.addFeature')}
</button>
</div>
</Section>
{/* Tariffs Section */}
<Section
title={t('admin.landings.tariffs')}
open={openSections.tariffs}
onToggle={() => toggleSection('tariffs')}
>
<div className="space-y-3">
<p className="text-sm text-dark-500">{t('admin.landings.selectTariffs')}</p>
{allTariffs.map((tariff: TariffListItem) => (
<div key={tariff.id} className="rounded-lg border border-dark-700 bg-dark-800/50 p-3">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={selectedTariffIds.includes(tariff.id)}
onChange={() => toggleTariff(tariff.id)}
className="h-4 w-4 rounded border-dark-600 bg-dark-700 text-accent-500"
/>
<span className="text-sm font-medium text-dark-100">{tariff.name}</span>
{!tariff.is_active && (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.landings.inactive')}
</span>
)}
</label>
{/* Period checkboxes if tariff is selected and is not daily */}
{selectedTariffIds.includes(tariff.id) && !tariff.is_daily && (
<div className="ml-7 mt-2 flex flex-wrap gap-2">
<span className="text-xs text-dark-500">{t('admin.landings.periods')}:</span>
{/* We show known periods from the tariff detail. Since TariffListItem doesn't have period_prices,
we let the user type periods or rely on the backend returning allowed_periods.
For simplicity, show the allowed_periods for this tariff if any are set. */}
{(allowedPeriods[String(tariff.id)] ?? []).map((days) => (
<button
key={days}
onClick={() => togglePeriod(tariff.id, days)}
className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400 hover:bg-accent-500/30"
>
{days}d<span className="ml-1 text-accent-300">x</span>
</button>
))}
<AddPeriodButton tariffId={tariff.id} onAdd={togglePeriod} />
</div>
)}
</div>
))}
</div>
</Section>
{/* Payment Methods Section */}
<Section
title={t('admin.landings.paymentMethods')}
open={openSections.methods}
onToggle={() => toggleSection('methods')}
>
<div className="space-y-3">
<DndContext sensors={sensors} onDragEnd={handleMethodDragEnd}>
<SortableContext items={methodIds} strategy={verticalListSortingStrategy}>
{paymentMethods.map((method, index) => (
<SortableMethodItem
key={methodIds[index]}
method={method}
methodId={methodIds[index]}
index={index}
onUpdate={updateMethod}
onRemove={removeMethod}
/>
))}
</SortableContext>
</DndContext>
<button
onClick={addMethod}
className="flex items-center gap-2 rounded-lg border border-dashed border-dark-600 px-4 py-2 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:text-dark-300"
>
<PlusIcon />
{t('admin.landings.addMethod')}
</button>
</div>
</Section>
{/* Gift Settings Section */}
<Section
title={t('admin.landings.gifts')}
open={openSections.gifts}
onToggle={() => toggleSection('gifts')}
>
<div className="flex items-center justify-between">
<label className="text-sm text-dark-400">{t('admin.landings.giftEnabled')}</label>
<Toggle checked={giftEnabled} onChange={() => setGiftEnabled(!giftEnabled)} />
</div>
</Section>
{/* Footer & Custom CSS Section */}
<Section
title={t('admin.landings.content')}
open={openSections.footer}
onToggle={() => toggleSection('footer')}
>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.footerText')}
</label>
<textarea
value={footerText}
onChange={(e) => setFooterText(e.target.value)}
rows={4}
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"
/>
</div>
<div>
<label className="mb-1 block text-sm text-dark-400">
{t('admin.landings.customCss')}
</label>
<textarea
value={customCss}
onChange={(e) => setCustomCss(e.target.value)}
rows={6}
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 font-mono text-sm text-dark-100 outline-none focus:border-accent-500"
/>
</div>
</div>
</Section>
</div>
</div>
);
}
// ============ Tiny helper for adding a period ============
function AddPeriodButton({
tariffId,
onAdd,
}: {
tariffId: number;
onAdd: (tariffId: number, days: number) => void;
}) {
const [value, setValue] = useState('');
const [showInput, setShowInput] = useState(false);
if (!showInput) {
return (
<button
onClick={() => setShowInput(true)}
className="rounded border border-dashed border-dark-600 px-2 py-0.5 text-xs text-dark-500 hover:border-dark-500 hover:text-dark-400"
>
+
</button>
);
}
return (
<input
type="number"
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={() => {
const days = parseInt(value, 10);
if (days > 0) {
onAdd(tariffId, days);
}
setValue('');
setShowInput(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const days = parseInt(value, 10);
if (days > 0) {
onAdd(tariffId, days);
}
setValue('');
setShowInput(false);
}
if (e.key === 'Escape') {
setValue('');
setShowInput(false);
}
}}
placeholder="30"
className="w-16 rounded border border-dark-600 bg-dark-800 px-2 py-0.5 text-xs text-dark-100 outline-none focus:border-accent-500"
/>
);
}

457
src/pages/AdminLandings.tsx Normal file
View File

@@ -0,0 +1,457 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminLandingsApi, LandingListItem } from '../api/landings';
import { useNotify } from '@/platform';
import { copyToClipboard } from '../utils/clipboard';
import { usePlatform } from '../platform/hooks/usePlatform';
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';
// Icons
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const GiftIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const SaveIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
// ============ Sortable Landing Card ============
interface SortableLandingCardProps {
landing: LandingListItem;
onEdit: () => void;
onDelete: () => void;
onToggle: () => void;
onCopyUrl: () => void;
isPendingDelete?: boolean;
}
function SortableLandingCard({
landing,
onEdit,
onDelete,
onToggle,
onCopyUrl,
isPendingDelete,
}: SortableLandingCardProps) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: landing.id,
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 50 : undefined,
position: isDragging ? 'relative' : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
isDragging
? 'border-accent-500/50 shadow-xl shadow-accent-500/20'
: landing.is_active
? 'border-dark-700'
: 'border-dark-700/50 opacity-60'
}`}
>
<div className="flex items-start gap-3">
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="mt-1 flex-shrink-0 cursor-grab touch-none rounded-lg p-2.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:p-1.5"
title={t('admin.tariffs.dragToReorder')}
>
<GripIcon />
</button>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<h3 className="truncate font-medium text-dark-100">{landing.title}</h3>
<span className="rounded bg-dark-800 px-2 py-0.5 text-xs text-dark-400">
{landing.slug}
</span>
{landing.is_active ? (
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.landings.active')}
</span>
) : (
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.landings.inactive')}
</span>
)}
{landing.gift_enabled && (
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
<GiftIcon />
</span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
<span>
{landing.purchase_stats.paid} {t('admin.landings.purchases')}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={onCopyUrl}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.copyUrl')}
>
<CopyIcon />
</button>
<button
onClick={onToggle}
className={`rounded-lg p-2 transition-colors ${
landing.is_active
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')}
>
{landing.is_active ? <CheckIcon /> : <XIcon />}
</button>
<button
onClick={onEdit}
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
title={t('admin.landings.edit')}
>
<EditIcon />
</button>
<button
onClick={onDelete}
className={`rounded-lg p-2 transition-colors ${
isPendingDelete
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400'
}`}
title={
isPendingDelete
? t('admin.landings.deleteConfirm', { title: landing.title })
: t('common.delete')
}
>
{isPendingDelete ? (
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
) : (
<TrashIcon />
)}
</button>
</div>
</div>
</div>
);
}
// ============ Main Page ============
export default function AdminLandings() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const notify = useNotify();
const { capabilities } = usePlatform();
const [localLandings, setLocalLandings] = useState<LandingListItem[]>([]);
const [orderChanged, setOrderChanged] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
const deleteTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
return () => {
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
};
}, []);
// Queries
const { data: landingsData, isLoading } = useQuery({
queryKey: ['admin-landings'],
queryFn: () => adminLandingsApi.list(),
});
// Sync fetched data to local state
useEffect(() => {
if (landingsData && !orderChanged) {
setLocalLandings(landingsData);
}
}, [landingsData, orderChanged]);
// Save order mutation
const saveOrderMutation = useMutation({
mutationFn: (landingIds: number[]) => adminLandingsApi.reorder(landingIds),
onSuccess: () => {
setOrderChanged(false);
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
notify.success(t('admin.landings.orderSaved'));
},
onError: (err: unknown) => {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail;
notify.error(message || t('common.error'));
},
});
// Mutations
const deleteMutation = useMutation({
mutationFn: adminLandingsApi.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
notify.success(t('admin.landings.deleted'));
},
onError: (err: unknown) => {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail;
notify.error(message || t('common.error'));
},
});
const handleDelete = (landing: LandingListItem) => {
if (pendingDeleteId === landing.id) {
deleteMutation.mutate(landing.id);
setPendingDeleteId(null);
} else {
setPendingDeleteId(landing.id);
deleteTimeoutRef.current = setTimeout(
() => setPendingDeleteId((prev) => (prev === landing.id ? null : prev)),
3000,
);
}
};
const toggleMutation = useMutation({
mutationFn: adminLandingsApi.toggle,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
},
onError: (err: unknown) => {
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail;
notify.error(message || t('common.error'));
},
});
const handleCopyUrl = async (slug: string) => {
const url = `${window.location.origin}/buy/${slug}`;
try {
await copyToClipboard(url);
notify.success(t('admin.landings.urlCopied'));
} catch {
// Clipboard write failed silently
}
};
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setLocalLandings((prev) => {
const oldIndex = prev.findIndex((l) => l.id === active.id);
const newIndex = prev.findIndex((l) => l.id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex);
});
setOrderChanged(true);
}
}, []);
const handleSaveOrder = () => {
saveOrderMutation.mutate(localLandings.map((l) => l.id));
};
return (
<div className="animate-fade-in">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
{/* Show back button only on web, not in Telegram Mini App */}
{!capabilities.hasBackButton && (
<button
onClick={() => navigate('/admin')}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.landings.title')}</h1>
</div>
</div>
<div className="flex items-center gap-2">
{orderChanged && (
<button
onClick={handleSaveOrder}
disabled={saveOrderMutation.isPending}
className="flex items-center gap-2 rounded-lg bg-success-500 px-4 py-2 text-white transition-colors hover:bg-success-600"
>
{saveOrderMutation.isPending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<SaveIcon />
)}
{t('admin.landings.saveOrder')}
</button>
)}
<button
onClick={() => navigate('/admin/landings/create')}
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
>
<PlusIcon />
{t('admin.landings.create')}
</button>
</div>
</div>
{/* Drag hint */}
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
<GripIcon />
{t('admin.tariffs.dragToReorder')}
</div>
{/* Landings List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : localLandings.length === 0 ? (
<div className="py-12 text-center">
<p className="text-dark-400">{t('common.noData')}</p>
</div>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={localLandings.map((l) => l.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{localLandings.map((landing) => (
<SortableLandingCard
key={landing.id}
landing={landing}
onEdit={() => navigate(`/admin/landings/${landing.id}/edit`)}
onDelete={() => handleDelete(landing)}
onToggle={() => toggleMutation.mutate(landing.id)}
onCopyUrl={() => handleCopyUrl(landing.slug)}
isPendingDelete={pendingDeleteId === landing.id}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}

View File

@@ -304,6 +304,16 @@ const ClipboardDocumentListIcon = () => (
</svg>
);
const RectangleGroupIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z"
/>
</svg>
);
interface AdminItem {
to: string;
icon: React.ReactNode;
@@ -499,6 +509,13 @@ export default function AdminPanel() {
description: t('admin.panel.paymentMethodsDesc'),
permission: 'payment_methods:read',
},
{
to: '/admin/landings',
icon: <RectangleGroupIcon />,
title: t('admin.nav.landings'),
description: t('admin.panel.landingsDesc'),
permission: 'landings:read',
},
],
},
{

View File

@@ -0,0 +1,283 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useParams } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { QRCodeSVG } from 'qrcode.react';
import { landingApi } from '../api/landings';
import { copyToClipboard } from '../utils/clipboard';
import { cn } from '../lib/utils';
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
// ============================================================
// Sub-components
// ============================================================
function Spinner({ className }: { className?: string }) {
return (
<div
className={cn(
'animate-spin rounded-full border-2 border-dark-600 border-t-accent-500',
className,
)}
/>
);
}
function PendingState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<Spinner className="h-16 w-16 border-[3px]" />
<div>
<h1 className="text-xl font-bold text-dark-50">
{t('landing.awaitingPayment', 'Awaiting payment')}
</h1>
<p className="mt-2 text-sm text-dark-400">{t('landing.awaitingPaymentDesc')}</p>
</div>
</motion.div>
);
}
function SuccessState({
subscriptionUrl,
cryptoLink,
contactValue,
tariffName,
periodDays,
isGift,
}: {
subscriptionUrl: string | null;
cryptoLink: string | null;
contactValue: string | null;
tariffName: string | null;
periodDays: number | null;
isGift: boolean;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
const url = subscriptionUrl ?? cryptoLink;
if (!url) return;
try {
await copyToClipboard(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard write failed silently
}
}, [subscriptionUrl, cryptoLink]);
const displayUrl = subscriptionUrl ?? cryptoLink;
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
{/* Animated checkmark */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
>
<motion.svg
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
className="h-10 w-10 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<motion.path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.4, delay: 0.3 }}
/>
</motion.svg>
</motion.div>
{/* Title */}
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseSuccess')}</h1>
{tariffName && periodDays && (
<p className="mt-1 text-sm text-dark-300">
{tariffName} {periodDays} {t('landing.daysAccess')}
</p>
)}
{contactValue && (
<p className="mt-2 text-sm text-dark-400">
{isGift
? t('landing.giftSentTo', { contact: contactValue })
: t('landing.keySentTo', { contact: contactValue })}
</p>
)}
</div>
{/* QR Code */}
{displayUrl && (
<div className="space-y-4">
<div className="rounded-2xl bg-white p-5">
<QRCodeSVG
value={displayUrl}
size={200}
level="M"
includeMargin={false}
className="h-[200px] w-[200px]"
/>
</div>
{/* Copy button */}
<button
type="button"
onClick={handleCopy}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200',
copied
? 'bg-success-500/10 text-success-500'
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
)}
>
{copied ? (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{t('landing.copied', 'Copied!')}
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
{t('landing.copyLink', 'Copy link')}
</>
)}
</button>
</div>
)}
</motion.div>
);
}
function FailedState() {
const { t } = useTranslation();
return (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-6 text-center"
>
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-error-500/10">
<svg
className="h-10 w-10 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseFailed')}</h1>
<p className="mt-2 text-sm text-dark-400">{t('landing.purchaseFailedDesc')}</p>
</div>
</motion.div>
);
}
// ============================================================
// Main Component
// ============================================================
export default function PurchaseSuccess() {
const { token } = useParams<{ token: string }>();
const pollStart = useRef(Date.now());
// Referrer-Policy: prevent leaking payment token via referer header
useEffect(() => {
const meta = document.createElement('meta');
meta.name = 'referrer';
meta.content = 'no-referrer';
document.head.appendChild(meta);
return () => {
document.head.removeChild(meta);
};
}, []);
const { data: status, isError } = useQuery({
queryKey: ['purchase-status', token],
queryFn: () => landingApi.getPurchaseStatus(token!),
enabled: !!token,
refetchInterval: (query) => {
const currentStatus = query.state.data?.status;
if (currentStatus === 'pending' || currentStatus === 'paid') {
if (Date.now() - pollStart.current > MAX_POLL_MS) {
return false;
}
return 3_000;
}
return false;
},
retry: 2,
});
const isSuccess = status?.status === 'delivered';
const isFailed = status?.status === 'failed' || status?.status === 'expired';
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8">
{isError ? (
<FailedState />
) : isSuccess ? (
<SuccessState
subscriptionUrl={status.subscription_url}
cryptoLink={status.subscription_crypto_link}
contactValue={status.contact_value}
tariffName={status.tariff_name}
periodDays={status.period_days}
isGift={status.is_gift}
/>
) : isFailed ? (
<FailedState />
) : (
<PendingState />
)}
</div>
</div>
);
}

831
src/pages/QuickPurchase.tsx Normal file
View File

@@ -0,0 +1,831 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { motion, AnimatePresence } from 'framer-motion';
import DOMPurify from 'dompurify';
import { landingApi } from '../api/landings';
import type {
LandingConfig,
LandingTariff,
LandingTariffPeriod,
LandingPaymentMethod,
PurchaseRequest,
} from '../api/landings';
import { cn } from '../lib/utils';
// ============================================================
// Helpers
// ============================================================
function formatPrice(kopeks: number): string {
const rubles = kopeks / 100;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(rubles);
}
function detectContactType(value: string): 'email' | 'telegram' {
return value.startsWith('@') ? 'telegram' : 'email';
}
function isValidContact(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed.startsWith('@')) {
return trimmed.length >= 4;
}
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
}
// ============================================================
// Sub-components
// ============================================================
function LoadingSkeleton() {
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-500" />
</div>
</div>
);
}
function ErrorState({ message }: { message: string }) {
const { t } = useTranslation();
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-error-500/10">
<svg
className="h-8 w-8 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-dark-50">{t('landing.error', 'Error')}</h2>
<p className="text-sm text-dark-300">{message}</p>
</div>
</div>
);
}
function PeriodTabs({
periods,
selectedDays,
onSelect,
}: {
periods: LandingTariffPeriod[];
selectedDays: number;
onSelect: (days: number) => void;
}) {
return (
<div className="scrollbar-none flex gap-2 overflow-x-auto pb-1">
{periods.map((period) => (
<button
key={period.days}
type="button"
onClick={() => onSelect(period.days)}
className={cn(
'whitespace-nowrap rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-200',
selectedDays === period.days
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25'
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100',
)}
>
{period.label}
</button>
))}
</div>
);
}
function GiftToggle({ isGift, onToggle }: { isGift: boolean; onToggle: (v: boolean) => void }) {
const { t } = useTranslation();
return (
<div className="flex rounded-xl bg-dark-800/50 p-1">
<button
type="button"
onClick={() => onToggle(false)}
className={cn(
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
!isGift ? 'bg-dark-700 text-dark-50 shadow-sm' : 'text-dark-400 hover:text-dark-200',
)}
>
{t('landing.forMe', 'For me')}
</button>
<button
type="button"
onClick={() => onToggle(true)}
className={cn(
'flex-1 rounded-lg px-4 py-2.5 text-sm font-medium transition-all duration-200',
isGift ? 'bg-dark-700 text-dark-50 shadow-sm' : 'text-dark-400 hover:text-dark-200',
)}
>
{t('landing.asGift', 'As a gift')}
</button>
</div>
);
}
function ContactForm({
contactValue,
onContactChange,
isGift,
giftRecipient,
onGiftRecipientChange,
giftMessage,
onGiftMessageChange,
}: {
contactValue: string;
onContactChange: (v: string) => void;
isGift: boolean;
giftRecipient: string;
onGiftRecipientChange: (v: string) => void;
giftMessage: string;
onGiftMessageChange: (v: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-4 rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
{/* Main contact */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-200">
{t('landing.yourContact', 'Your contact')}
</label>
<input
type="text"
value={contactValue}
onChange={(e) => onContactChange(e.target.value)}
placeholder={t('landing.contactPlaceholder', 'email@example.com or @telegram')}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
/>
<p className="mt-1.5 text-xs text-dark-500">{t('landing.contactHint')}</p>
</div>
{/* Gift fields */}
<AnimatePresence mode="wait">
{isGift && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="space-y-4 overflow-hidden"
>
<div className="border-t border-dark-800/50 pt-4">
<label className="mb-2 block text-sm font-medium text-dark-200">
{t('landing.recipientLabel')}
</label>
<input
type="text"
value={giftRecipient}
onChange={(e) => onGiftRecipientChange(e.target.value)}
placeholder={t('landing.recipientPlaceholder', 'Recipient email or @telegram')}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
/>
</div>
<div>
<label className="mb-2 block text-sm font-medium text-dark-200">
{t('landing.giftMessageLabel')}
</label>
<textarea
value={giftMessage}
onChange={(e) => onGiftMessageChange(e.target.value)}
placeholder={t(
'landing.giftMessagePlaceholder',
'Add a personal message (optional)',
)}
rows={3}
className="w-full resize-none rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
/>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function TariffCard({
tariff,
isSelected,
selectedPeriod,
onSelect,
}: {
tariff: LandingTariff;
isSelected: boolean;
selectedPeriod: LandingTariffPeriod | undefined;
onSelect: () => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onSelect}
className={cn(
'relative flex w-full flex-col rounded-2xl border p-5 text-left transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5 ring-1 ring-accent-500/25'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
)}
>
{/* Header */}
<div className="mb-3 flex items-start justify-between">
<div>
<h3 className="text-base font-semibold text-dark-50">{tariff.name}</h3>
{tariff.description && (
<p className="mt-0.5 text-xs text-dark-400">{tariff.description}</p>
)}
</div>
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
)}
>
{isSelected && (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
</div>
{/* Info row */}
<div className="flex items-center gap-3 text-xs text-dark-400">
<span className="flex items-center gap-1">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
{tariff.traffic_limit_gb} {t('landing.gb', 'GB')}
</span>
<span className="flex items-center gap-1">
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
{tariff.device_limit} {t('landing.devices', 'devices')}
</span>
</div>
{/* Price */}
{selectedPeriod && (
<div className="mt-3 border-t border-dark-800/30 pt-3">
<span className="text-lg font-bold text-accent-400">
{formatPrice(selectedPeriod.price_kopeks)}
</span>
</div>
)}
</button>
);
}
function PaymentMethodCard({
method,
isSelected,
onSelect,
}: {
method: LandingPaymentMethod;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
'flex w-full items-center gap-4 rounded-2xl border p-4 text-left transition-all duration-200',
isSelected
? 'border-accent-500/50 bg-accent-500/5'
: 'border-dark-800/50 bg-dark-900/50 hover:border-dark-700/50 hover:bg-dark-800/30',
)}
>
{/* Icon */}
{method.icon_url && (
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-dark-800/50">
<img src={method.icon_url} alt="" className="h-6 w-6 object-contain" />
</div>
)}
{/* Text */}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-dark-100">{method.display_name}</p>
{method.description && (
<p className="mt-0.5 truncate text-xs text-dark-400">{method.description}</p>
)}
</div>
{/* Radio */}
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors',
isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600',
)}
>
{isSelected && <div className="h-2 w-2 rounded-full bg-white" />}
</div>
</button>
);
}
function SanitizedHtml({ html, className }: { html: string; className?: string }) {
const sanitized = useMemo(
() =>
DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'b', 'i', 'br', 'span', 'ul', 'ol', 'li', 'div'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
}),
[html],
);
return (
<div
className={className}
// Content is sanitized via DOMPurify above
dangerouslySetInnerHTML={{ __html: sanitized }}
/>
);
}
function SummaryCard({
config,
selectedTariff,
selectedPeriod,
currentPrice,
isSubmitting,
canSubmit,
submitError,
onSubmit,
}: {
config: LandingConfig;
selectedTariff: LandingTariff | undefined;
selectedPeriod: LandingTariffPeriod | undefined;
currentPrice: number;
isSubmitting: boolean;
canSubmit: boolean;
submitError: string | null;
onSubmit: () => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-5">
{/* Summary */}
<div className="rounded-2xl border border-dark-800/50 bg-dark-900/50 p-5">
{selectedTariff && (
<div className="mb-3">
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('landing.selectedTariff', 'Tariff')}
</p>
<p className="mt-1 text-sm font-semibold text-dark-50">{selectedTariff.name}</p>
</div>
)}
{selectedPeriod && (
<div className="mb-4">
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('landing.period', 'Period')}
</p>
<p className="mt-1 text-sm text-dark-200">{selectedPeriod.label}</p>
</div>
)}
<div className="border-t border-dark-800/50 pt-4">
<p className="text-xs font-medium uppercase tracking-wider text-dark-500">
{t('landing.total', 'Total')}
</p>
<p className="mt-1 text-2xl font-bold text-accent-400">{formatPrice(currentPrice)}</p>
</div>
</div>
{/* Features */}
{config.features.length > 0 && (
<div className="space-y-3">
{config.features.map((feature, idx) => (
<div key={idx} className="flex gap-3">
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-success-500/10">
<svg
className="h-3 w-3 text-success-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-dark-100">{feature.title}</p>
<p className="text-xs text-dark-400">{feature.description}</p>
</div>
</div>
))}
</div>
)}
{/* Error */}
<AnimatePresence>
{submitError && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="rounded-xl border border-error-500/20 bg-error-500/5 p-3"
>
<p className="text-sm text-error-400">{submitError}</p>
</motion.div>
)}
</AnimatePresence>
{/* Pay button */}
<button
type="button"
onClick={onSubmit}
disabled={!canSubmit || isSubmitting}
className={cn(
'flex w-full items-center justify-center gap-2 rounded-2xl px-6 py-4 text-base font-semibold transition-all duration-200',
canSubmit && !isSubmitting
? 'bg-accent-500 text-white shadow-lg shadow-accent-500/25 hover:bg-accent-400 hover:shadow-accent-500/40 active:scale-[0.98]'
: 'cursor-not-allowed bg-dark-800 text-dark-500',
)}
>
{isSubmitting ? (
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : (
<>
{t('landing.pay', 'Pay')} {formatPrice(currentPrice)}
</>
)}
</button>
{/* Footer */}
{config.footer_text && (
<SanitizedHtml
html={config.footer_text}
className="text-center text-xs leading-relaxed text-dark-500 [&_a]:text-accent-400 [&_a]:underline [&_a]:underline-offset-2"
/>
)}
</div>
);
}
// ============================================================
// Main Component
// ============================================================
export default function QuickPurchase() {
const { slug } = useParams<{ slug: string }>();
const { t } = useTranslation();
// Fetch config
const {
data: config,
isLoading,
error,
} = useQuery({
queryKey: ['landing-config', slug],
queryFn: () => landingApi.getConfig(slug!),
enabled: !!slug,
staleTime: 60_000,
retry: 1,
});
// Selection state
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
const [contactValue, setContactValue] = useState('');
const [isGift, setIsGift] = useState(false);
const [giftRecipient, setGiftRecipient] = useState('');
const [giftMessage, setGiftMessage] = useState('');
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Auto-select first tariff, period, method on config load
useEffect(() => {
if (!config) return;
if (config.tariffs.length > 0 && selectedTariffId === null) {
const firstTariff = config.tariffs[0];
setSelectedTariffId(firstTariff.id);
if (firstTariff.periods.length > 0 && selectedPeriodDays === null) {
setSelectedPeriodDays(firstTariff.periods[0].days);
}
}
if (config.payment_methods.length > 0 && selectedMethod === null) {
setSelectedMethod(config.payment_methods[0].method_id);
}
}, [config, selectedTariffId, selectedPeriodDays, selectedMethod]);
// SEO: set document title
useEffect(() => {
if (!config?.meta_title) return;
const prev = document.title;
document.title = config.meta_title;
return () => {
document.title = prev;
};
}, [config?.meta_title]);
// SEO: set meta description
useEffect(() => {
if (!config?.meta_description) return;
let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.name = 'description';
document.head.appendChild(meta);
}
const prev = meta.content;
meta.content = config.meta_description;
return () => {
meta!.content = prev;
};
}, [config?.meta_description]);
// Inject custom CSS (sanitized: strip JS expressions, imports, and url() with non-data schemes)
useEffect(() => {
if (!config?.custom_css) return;
let css = config.custom_css;
// Remove @import rules
css = css.replace(/@import\b[^;]*;?/gi, '');
// Remove expression() (IE)
css = css.replace(/expression\s*\([^)]*\)/gi, '');
// Block all url() except safe data:image/* — prevents data exfiltration via external URLs
css = css.replace(
/url\s*\(\s*['"]?\s*(?!data:image\/)(.*?)\s*['"]?\s*\)/gi,
'url(about:blank)',
);
// Remove behavior and -moz-binding (IE/Firefox legacy)
css = css.replace(/behavior\s*:/gi, '/* blocked */');
css = css.replace(/-moz-binding\s*:/gi, '/* blocked */');
// Strip @font-face blocks (can load external fonts for tracking)
css = css.replace(/@font-face\s*\{[^}]*\}/gi, '');
// Strip @charset declarations
css = css.replace(/@charset\b[^;]*;?/gi, '');
// Strip @namespace declarations
css = css.replace(/@namespace\b[^;]*;?/gi, '');
const style = document.createElement('style');
style.setAttribute('data-landing-css', 'true');
style.textContent = css;
document.head.appendChild(style);
return () => {
style.remove();
};
}, [config?.custom_css]);
// Derived data
const selectedTariff = useMemo(
() => config?.tariffs.find((tr) => tr.id === selectedTariffId),
[config?.tariffs, selectedTariffId],
);
const availablePeriods = useMemo(() => selectedTariff?.periods ?? [], [selectedTariff]);
const selectedPeriod = useMemo(
() => availablePeriods.find((p) => p.days === selectedPeriodDays),
[availablePeriods, selectedPeriodDays],
);
const currentPrice = selectedPeriod?.price_kopeks ?? 0;
// When tariff changes, reset period to first available if current is invalid
useEffect(() => {
if (!selectedTariff) return;
const hasCurrent = selectedTariff.periods.some((p) => p.days === selectedPeriodDays);
if (!hasCurrent && selectedTariff.periods.length > 0) {
setSelectedPeriodDays(selectedTariff.periods[0].days);
}
}, [selectedTariff, selectedPeriodDays]);
// Validation
const canSubmit = useMemo(() => {
if (!selectedTariffId || !selectedPeriodDays || !selectedMethod) return false;
if (!isValidContact(contactValue)) return false;
if (isGift && !isValidContact(giftRecipient)) return false;
return true;
}, [selectedTariffId, selectedPeriodDays, selectedMethod, contactValue, isGift, giftRecipient]);
// Submit handler
const handleSubmit = async () => {
if (!canSubmit || !slug || isSubmitting) return;
setIsSubmitting(true);
setSubmitError(null);
const data: PurchaseRequest = {
tariff_id: selectedTariffId!,
period_days: selectedPeriodDays!,
contact_type: detectContactType(contactValue),
contact_value: contactValue.trim(),
payment_method: selectedMethod!,
is_gift: isGift,
};
if (isGift && giftRecipient) {
data.gift_recipient_type = detectContactType(giftRecipient);
data.gift_recipient_value = giftRecipient.trim();
data.gift_message = giftMessage.trim() || undefined;
}
try {
const result = await landingApi.createPurchase(slug, data);
window.location.href = result.payment_url;
// If redirect blocked (popup blocker etc.), reset after 5s
setTimeout(() => setIsSubmitting(false), 5000);
} catch (err) {
const axiosErr = err as { response?: { data?: { detail?: string } } };
const message =
axiosErr?.response?.data?.detail ??
t('landing.purchaseError', 'Something went wrong. Please try again.');
setSubmitError(message);
setIsSubmitting(false);
}
};
// Loading state
if (isLoading) {
return <LoadingSkeleton />;
}
// Error state
if (error || !config) {
const errMsg =
(error as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
t('landing.notFound', 'Landing page not found');
return <ErrorState message={errMsg} />;
}
const showTariffCards = config.tariffs.length > 1;
return (
<div className="min-h-dvh bg-dark-950">
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-10 text-center"
>
<h1 className="text-3xl font-bold tracking-tight text-dark-50 sm:text-4xl">
{config.title}
</h1>
{config.subtitle && (
<p className="mt-3 text-base text-dark-300 sm:text-lg">{config.subtitle}</p>
)}
</motion.div>
{/* Two-column layout */}
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
{/* Left column */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="space-y-6"
>
{/* Period tabs */}
{availablePeriods.length > 0 && (
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('landing.choosePeriod', 'Choose period')}
</h2>
<PeriodTabs
periods={availablePeriods}
selectedDays={selectedPeriodDays ?? 0}
onSelect={setSelectedPeriodDays}
/>
</div>
)}
{/* Gift toggle */}
{config.gift_enabled && <GiftToggle isGift={isGift} onToggle={setIsGift} />}
{/* Contact form */}
<ContactForm
contactValue={contactValue}
onContactChange={(v) => {
setContactValue(v);
setSubmitError(null);
}}
isGift={isGift}
giftRecipient={giftRecipient}
onGiftRecipientChange={(v) => {
setGiftRecipient(v);
setSubmitError(null);
}}
giftMessage={giftMessage}
onGiftMessageChange={setGiftMessage}
/>
{/* Tariff cards */}
{showTariffCards && (
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('landing.chooseTariff', 'Choose tariff')}
</h2>
<div className="grid gap-3 sm:grid-cols-2">
{config.tariffs.map((tariff) => {
const period = tariff.periods.find((p) => p.days === selectedPeriodDays);
return (
<TariffCard
key={tariff.id}
tariff={tariff}
isSelected={tariff.id === selectedTariffId}
selectedPeriod={period}
onSelect={() => setSelectedTariffId(tariff.id)}
/>
);
})}
</div>
</div>
)}
{/* Payment methods */}
{config.payment_methods.length > 0 && (
<div>
<h2 className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
{t('landing.paymentMethod', 'Payment method')}
</h2>
<div className="space-y-2">
{config.payment_methods.map((method) => (
<PaymentMethodCard
key={method.method_id}
method={method}
isSelected={method.method_id === selectedMethod}
onSelect={() => setSelectedMethod(method.method_id)}
/>
))}
</div>
</div>
)}
</motion.div>
{/* Right column (sticky sidebar / bottom on mobile) */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="lg:sticky lg:top-8 lg:self-start"
>
<SummaryCard
config={config}
selectedTariff={selectedTariff}
selectedPeriod={selectedPeriod}
currentPrice={currentPrice}
isSubmitting={isSubmitting}
canSubmit={canSubmit}
submitError={submitError}
onSubmit={handleSubmit}
/>
</motion.div>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -772,7 +772,9 @@ export default function SubscriptionPurchase() {
/>
</svg>
<span className="text-dark-300">
{t('subscription.devices', { count: tariff.device_limit })}
{tariff.device_limit === 0
? '∞'
: t('subscription.devices', { count: tariff.device_limit })}
</span>
</div>
{tariff.traffic_reset_mode &&
@@ -959,7 +961,7 @@ export default function SubscriptionPurchase() {
<div>
<span className="text-dark-500">{t('subscription.devices')}:</span>
<span className="ml-2 text-dark-200">
{selectedTariff.device_limit}
{selectedTariff.device_limit === 0 ? '∞' : selectedTariff.device_limit}
{selectedTariff.extra_devices_count > 0 && (
<span className="ml-1 text-xs text-accent-400">
(+{selectedTariff.extra_devices_count})