mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
831
src/pages/QuickPurchase.tsx
Normal file
831
src/pages/QuickPurchase.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user