mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Audit-driven sweep: replaced 168 hand-written inline <svg> icons across 77 files with the central Phosphor (react-icons/pi) components from @/components/icons, preserving each icon's size classes and colour (dynamic stroke colours via the parent's currentColor, RefreshIcon's spinning state, conditional rotate-180 chevrons). Verified: tsc + vite build + eslint clean; an adversarial diff review of all changed files found the replacements correct (70 files clean, 0 blockers). Remaining inline <svg> dropped from 262 to ~95 — the survivors are legitimate non-icons (brand/provider logos, loading spinners & framer-motion animations, charts/sparklines, background decoration) plus a few ambiguous glyphs.
1240 lines
43 KiB
TypeScript
1240 lines
43 KiB
TypeScript
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useParams } from 'react-router';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { fireAnalyticsEvent, getYandexCid } from '../hooks/useAnalyticsCounters';
|
|
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 { StaticBackgroundRenderer } from '../components/backgrounds/BackgroundRenderer';
|
|
import { CheckCircleIcon, CheckIcon, DevicesIcon, DownloadIcon } from '@/components/icons';
|
|
import LanguageSwitcher from '../components/LanguageSwitcher';
|
|
import { cn } from '../lib/utils';
|
|
import { getApiErrorMessage } from '../utils/api-error';
|
|
import { formatPrice } from '../utils/format';
|
|
import { useCurrency } from '../hooks/useCurrency';
|
|
|
|
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);
|
|
}
|
|
|
|
function formatPeriodLabel(
|
|
days: number,
|
|
t: (key: string, options?: Record<string, unknown>) => string,
|
|
): string {
|
|
const key = `landing.periodLabels.d${days}`;
|
|
const result = t(key);
|
|
if (result !== key) return result;
|
|
|
|
const months = Math.floor(days / 30);
|
|
const remainder = days % 30;
|
|
if (months > 0 && remainder === 0) {
|
|
return t('landing.periodLabels.nMonths', { count: months });
|
|
}
|
|
return t('landing.periodLabels.nDays', { count: days });
|
|
}
|
|
|
|
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;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
{periods.map((period) => (
|
|
<button
|
|
key={period.days}
|
|
type="button"
|
|
onClick={() => onSelect(period.days)}
|
|
className={cn(
|
|
'whitespace-nowrap rounded-full px-4 py-2 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',
|
|
)}
|
|
>
|
|
{formatPeriodLabel(period.days, t)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function GiftToggle({ isGift, onToggle }: { isGift: boolean; onToggle: (v: boolean) => void }) {
|
|
const { t } = useTranslation();
|
|
|
|
return (
|
|
<div
|
|
role="group"
|
|
aria-label={t('landing.giftToggleLabel', 'Purchase type')}
|
|
className="flex rounded-xl bg-dark-800/50 p-1"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggle(false)}
|
|
aria-pressed={!isGift}
|
|
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)}
|
|
aria-pressed={isGift}
|
|
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 htmlFor="contact-input" className="mb-2 block text-sm font-medium text-dark-200">
|
|
{t('landing.yourContact', 'Your contact')}
|
|
</label>
|
|
<input
|
|
id="contact-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
|
|
htmlFor="gift-recipient-input"
|
|
className="mb-2 block text-sm font-medium text-dark-200"
|
|
>
|
|
{t('landing.recipientLabel')}
|
|
</label>
|
|
<input
|
|
id="gift-recipient-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
|
|
htmlFor="gift-message-input"
|
|
className="mb-2 block text-sm font-medium text-dark-200"
|
|
>
|
|
{t('landing.giftMessageLabel')}
|
|
</label>
|
|
<textarea
|
|
id="gift-message-input"
|
|
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"
|
|
role="radio"
|
|
aria-checked={isSelected}
|
|
onClick={onSelect}
|
|
className={cn(
|
|
'relative flex w-full flex-col rounded-2xl border p-5 text-start 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 && <CheckIcon className="h-3 w-3 text-white" />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info row */}
|
|
<div className="flex items-center gap-3 text-xs text-dark-400">
|
|
<span className="flex items-center gap-1">
|
|
<DownloadIcon className="h-3.5 w-3.5" />
|
|
{tariff.traffic_limit_gb === 0 ? '∞' : tariff.traffic_limit_gb} {t('landing.gb', 'GB')}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<DevicesIcon className="h-3.5 w-3.5" />
|
|
{tariff.device_limit} {t('landing.devices', 'devices')}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Price */}
|
|
{selectedPeriod && (
|
|
<div className="mt-3 border-t border-dark-800/30 pt-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg font-bold text-accent-400">
|
|
{formatPrice(selectedPeriod.price_kopeks)}
|
|
</span>
|
|
{selectedPeriod.original_price_kopeks != null &&
|
|
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
|
|
<>
|
|
<span className="text-sm text-dark-500 line-through">
|
|
{formatPrice(selectedPeriod.original_price_kopeks)}
|
|
</span>
|
|
{selectedPeriod.discount_percent != null && (
|
|
<span className="rounded-full bg-accent-500/20 px-1.5 py-0.5 text-[10px] font-bold text-accent-400">
|
|
-{selectedPeriod.discount_percent}%
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PaymentMethodCard({
|
|
method,
|
|
isSelected,
|
|
selectedSubOption,
|
|
onSelect,
|
|
onSelectSubOption,
|
|
}: {
|
|
method: LandingPaymentMethod;
|
|
isSelected: boolean;
|
|
selectedSubOption: string | null;
|
|
onSelect: () => void;
|
|
onSelectSubOption: (subOptionId: string) => void;
|
|
}) {
|
|
const hasSubOptions = method.sub_options && method.sub_options.length > 1;
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'rounded-2xl border 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',
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={isSelected}
|
|
onClick={onSelect}
|
|
className="flex w-full items-center gap-4 p-4 text-start"
|
|
>
|
|
{/* 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>
|
|
|
|
{/* Sub-options */}
|
|
{isSelected && hasSubOptions && (
|
|
<div className="border-t border-dark-800/30 px-4 pb-4 pt-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
{method.sub_options!.map((opt) => (
|
|
<button
|
|
key={opt.id}
|
|
type="button"
|
|
onClick={() => onSelectSubOption(opt.id)}
|
|
className={cn(
|
|
'rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200',
|
|
selectedSubOption === opt.id
|
|
? 'bg-accent-500 text-white shadow-sm shadow-accent-500/25'
|
|
: 'bg-dark-800/50 text-dark-300 hover:bg-dark-700/50 hover:text-dark-100',
|
|
)}
|
|
>
|
|
{opt.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SanitizedHtml({ html, className }: { html: string; className?: string }) {
|
|
const sanitized = useMemo(() => {
|
|
const clean = DOMPurify.sanitize(html, {
|
|
ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'b', 'i', 'br', 'span', 'ul', 'ol', 'li'],
|
|
ALLOWED_ATTR: ['href', 'target', 'rel'],
|
|
});
|
|
// Enforce rel="noopener noreferrer" and target="_blank" on all links
|
|
const container = document.createElement('div');
|
|
container.innerHTML = clean;
|
|
container.querySelectorAll('a').forEach((a) => {
|
|
a.setAttribute('rel', 'noopener noreferrer');
|
|
a.setAttribute('target', '_blank');
|
|
});
|
|
return container.innerHTML;
|
|
}, [html]);
|
|
|
|
return (
|
|
<div className={cn('break-words', className)} dangerouslySetInnerHTML={{ __html: sanitized }} />
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
config,
|
|
selectedTariff,
|
|
selectedPeriod,
|
|
currentPrice,
|
|
isSubmitting,
|
|
canSubmit,
|
|
stickyPayButton = false,
|
|
submitError,
|
|
onSubmit,
|
|
}: {
|
|
config: LandingConfig;
|
|
selectedTariff: LandingTariff | undefined;
|
|
selectedPeriod: LandingTariffPeriod | undefined;
|
|
currentPrice: number;
|
|
isSubmitting: boolean;
|
|
canSubmit: boolean;
|
|
stickyPayButton?: boolean;
|
|
submitError: string | null;
|
|
onSubmit: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
|
|
// Responsive: track mobile for sticky pay button
|
|
const [isMobile, setIsMobile] = useState(
|
|
() => typeof window !== 'undefined' && window.innerWidth < 1024,
|
|
);
|
|
useEffect(() => {
|
|
const onResize = () => setIsMobile(window.innerWidth < 1024);
|
|
window.addEventListener('resize', onResize);
|
|
return () => window.removeEventListener('resize', onResize);
|
|
}, []);
|
|
|
|
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">
|
|
{formatPeriodLabel(selectedPeriod.days, t)}
|
|
</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>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<span className="text-2xl font-bold text-accent-400">{formatPrice(currentPrice)}</span>
|
|
{selectedPeriod?.original_price_kopeks != null &&
|
|
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
|
|
<>
|
|
<span className="text-base text-dark-500 line-through">
|
|
{formatPrice(selectedPeriod.original_price_kopeks)}
|
|
</span>
|
|
{selectedPeriod.discount_percent != null && (
|
|
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs font-bold text-accent-400">
|
|
-{selectedPeriod.discount_percent}%
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</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">
|
|
<CheckCircleIcon className="h-3 w-3 text-success-500" />
|
|
</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 */}
|
|
{stickyPayButton && isMobile ? (
|
|
createPortal(
|
|
<div
|
|
className="fixed bottom-0 left-0 right-0 z-50 p-3"
|
|
style={{
|
|
background:
|
|
'linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.6) 70%, transparent 100%)',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!canSubmit || isSubmitting) {
|
|
const el = document.getElementById('contact-input');
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.classList.add('!border-error-500', '!ring-2', '!ring-error-500/50');
|
|
setTimeout(() => {
|
|
el.classList.remove('!border-error-500', '!ring-2', '!ring-error-500/50');
|
|
}, 2000);
|
|
}
|
|
return;
|
|
}
|
|
onSubmit();
|
|
}}
|
|
disabled={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')}{' '}
|
|
{selectedPeriod?.original_price_kopeks != null &&
|
|
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
|
|
<span className="mr-1 text-sm font-normal text-white/50 line-through">
|
|
{formatPrice(selectedPeriod.original_price_kopeks)}
|
|
</span>
|
|
)}
|
|
{formatPrice(currentPrice)}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!canSubmit || isSubmitting) {
|
|
const el = document.getElementById('contact-input');
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
el.classList.add('!border-error-500', '!ring-2', '!ring-error-500/50');
|
|
setTimeout(() => {
|
|
el.focus();
|
|
}, 300);
|
|
setTimeout(() => {
|
|
el.classList.remove('!border-error-500', '!ring-2', '!ring-error-500/50');
|
|
}, 2000);
|
|
}
|
|
return;
|
|
}
|
|
onSubmit();
|
|
}}
|
|
disabled={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')}{' '}
|
|
{selectedPeriod?.original_price_kopeks != null &&
|
|
selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
|
|
<span className="mr-1 text-sm font-normal text-white/50 line-through">
|
|
{formatPrice(selectedPeriod.original_price_kopeks)}
|
|
</span>
|
|
)}
|
|
{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>
|
|
);
|
|
}
|
|
|
|
function TimeUnit({ value, label }: { value: number; label: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center">
|
|
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-dark-800/80 text-lg font-bold tabular-nums text-dark-50 sm:h-12 sm:w-12 sm:text-xl">
|
|
{String(value).padStart(2, '0')}
|
|
</span>
|
|
<span className="mt-1 text-[10px] uppercase tracking-wider text-dark-500">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function calcTimeLeft(endTime: number) {
|
|
const diff = Math.max(0, endTime - Date.now());
|
|
return {
|
|
diff,
|
|
days: Math.floor(diff / 86_400_000),
|
|
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
|
minutes: Math.floor((diff % 3_600_000) / 60_000),
|
|
seconds: Math.floor((diff % 60_000) / 1_000),
|
|
};
|
|
}
|
|
|
|
function DiscountBanner({
|
|
discount,
|
|
onExpired,
|
|
}: {
|
|
discount: { percent: number; ends_at: string; badge_text: string | null };
|
|
onExpired: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const endTime = useMemo(() => new Date(discount.ends_at).getTime(), [discount.ends_at]);
|
|
const initial = useMemo(() => calcTimeLeft(endTime), [endTime]);
|
|
const [timeLeft, setTimeLeft] = useState(initial);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
|
const expiredRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
expiredRef.current = false;
|
|
const fresh = calcTimeLeft(endTime);
|
|
setTimeLeft(fresh);
|
|
if (fresh.diff === 0) {
|
|
if (!expiredRef.current) {
|
|
expiredRef.current = true;
|
|
onExpired();
|
|
}
|
|
return;
|
|
}
|
|
|
|
intervalRef.current = setInterval(() => {
|
|
const tl = calcTimeLeft(endTime);
|
|
setTimeLeft(tl);
|
|
if (tl.diff === 0) {
|
|
clearInterval(intervalRef.current);
|
|
if (!expiredRef.current) {
|
|
expiredRef.current = true;
|
|
onExpired();
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
return () => clearInterval(intervalRef.current);
|
|
}, [endTime, onExpired]);
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
className="mb-8 overflow-hidden rounded-2xl border border-accent-500/30 bg-gradient-to-r from-accent-500/10 via-accent-500/5 to-transparent"
|
|
>
|
|
<div className="flex flex-col items-center gap-4 px-5 py-5 sm:flex-row sm:justify-between">
|
|
{/* Left: badge + text */}
|
|
<div className="flex items-center gap-3">
|
|
<span className="shrink-0 rounded-full bg-accent-500 px-3 py-1 text-sm font-bold text-white shadow-lg shadow-accent-500/25">
|
|
-{discount.percent}%
|
|
</span>
|
|
{discount.badge_text && (
|
|
<span className="text-sm font-medium text-dark-100">{discount.badge_text}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: countdown */}
|
|
<div className="flex items-center gap-1.5">
|
|
{timeLeft.days > 0 && (
|
|
<>
|
|
<TimeUnit value={timeLeft.days} label={t('landing.discount.days', 'd')} />
|
|
<span className="text-lg font-bold text-dark-500">:</span>
|
|
</>
|
|
)}
|
|
<TimeUnit value={timeLeft.hours} label={t('landing.discount.hours', 'h')} />
|
|
<span className="text-lg font-bold text-dark-500">:</span>
|
|
<TimeUnit value={timeLeft.minutes} label={t('landing.discount.minutes', 'm')} />
|
|
<span className="text-lg font-bold text-dark-500">:</span>
|
|
<TimeUnit value={timeLeft.seconds} label={t('landing.discount.seconds', 's')} />
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
export default function QuickPurchase() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const { t, i18n } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
|
|
// Подгружаем курсы валют, чтобы formatPrice конвертировал суммы для не-RU локалей
|
|
// (хук кладёт rates в глобальный кэш utils/format.ts).
|
|
useCurrency();
|
|
|
|
// Fetch config
|
|
const {
|
|
data: config,
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
queryKey: ['landing-config', slug, i18n.language],
|
|
queryFn: () => landingApi.getConfig(slug!, i18n.language),
|
|
enabled: !!slug,
|
|
staleTime: 60_000,
|
|
retry: 1,
|
|
});
|
|
|
|
const [discountExpired, setDiscountExpired] = useState(false);
|
|
|
|
const handleDiscountExpired = useCallback(() => {
|
|
setDiscountExpired(true);
|
|
queryClient.invalidateQueries({ queryKey: ['landing-config', slug] });
|
|
}, [queryClient, slug]);
|
|
|
|
// Reset expired flag when config changes (e.g. new discount scheduled)
|
|
useEffect(() => {
|
|
if (config?.discount) setDiscountExpired(false);
|
|
}, [config?.discount]);
|
|
|
|
// Save document.referrer on mount (before SPA navigation loses it).
|
|
// Clamp to 500 chars -- backend `referrer` column is max_length=500 and would
|
|
// otherwise reject long ad-click referrers (gclid+gbraid+params) with 422.
|
|
useEffect(() => {
|
|
if (document.referrer && !sessionStorage.getItem('landing_referrer')) {
|
|
sessionStorage.setItem('landing_referrer', document.referrer.slice(0, 500));
|
|
}
|
|
// Save subid from URL (also clamped to backend limit of 255)
|
|
const urlSubid = new URLSearchParams(window.location.search).get('subid');
|
|
if (urlSubid) {
|
|
sessionStorage.setItem('landing_subid', urlSubid.slice(0, 255));
|
|
}
|
|
}, []);
|
|
|
|
// Fire landing-specific view goal on mount
|
|
useEffect(() => {
|
|
if (config?.analytics_view_enabled && config?.analytics_view_goal) {
|
|
try {
|
|
const w = window as unknown as Record<string, unknown>;
|
|
const counterId = localStorage.getItem('ym_counter_id');
|
|
if (counterId && typeof w.ym === 'function') {
|
|
(w.ym as (...args: unknown[]) => void)(
|
|
Number(counterId),
|
|
'reachGoal',
|
|
config.analytics_view_goal,
|
|
);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}, [config?.analytics_view_enabled, config?.analytics_view_goal]);
|
|
|
|
// Selection state
|
|
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
|
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
|
|
const contactKey = `lp_contact_${slug ?? ''}`;
|
|
const [contactValue, setContactValue] = useState(() => {
|
|
try {
|
|
return localStorage.getItem(contactKey) || '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
});
|
|
const [isGift, setIsGift] = useState(false);
|
|
const [giftRecipient, setGiftRecipient] = useState('');
|
|
const [giftMessage, setGiftMessage] = useState('');
|
|
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
|
const [selectedSubOption, setSelectedSubOption] = useState<string | null>(null);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const redirectTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
// Cleanup redirect timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (redirectTimeoutRef.current) clearTimeout(redirectTimeoutRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// Collect ALL unique periods across ALL tariffs
|
|
const allPeriods = useMemo(() => {
|
|
if (!config) return [];
|
|
const periodMap = new Map<number, LandingTariffPeriod>();
|
|
for (const tariff of config.tariffs) {
|
|
for (const period of tariff.periods) {
|
|
if (!periodMap.has(period.days)) {
|
|
periodMap.set(period.days, period);
|
|
}
|
|
}
|
|
}
|
|
return Array.from(periodMap.values()).sort((a, b) => a.days - b.days);
|
|
}, [config]);
|
|
|
|
// Filter tariffs to only those that have the selected period
|
|
const visibleTariffs = useMemo(() => {
|
|
if (!config || !selectedPeriodDays) return config?.tariffs ?? [];
|
|
return config.tariffs.filter((tariff) =>
|
|
tariff.periods.some((p) => p.days === selectedPeriodDays),
|
|
);
|
|
}, [config, selectedPeriodDays]);
|
|
|
|
// Auto-select first tariff, period, method on config load
|
|
useEffect(() => {
|
|
if (!config) return;
|
|
|
|
// Auto-select first period from all available periods
|
|
if (allPeriods.length > 0 && selectedPeriodDays === null) {
|
|
setSelectedPeriodDays(allPeriods[0].days);
|
|
}
|
|
|
|
// Auto-select first visible tariff
|
|
if (visibleTariffs.length > 0 && selectedTariffId === null) {
|
|
setSelectedTariffId(visibleTariffs[0].id);
|
|
}
|
|
|
|
if (config.payment_methods.length > 0 && selectedMethod === null) {
|
|
const firstMethod = config.payment_methods[0];
|
|
setSelectedMethod(firstMethod.method_id);
|
|
if (firstMethod.sub_options && firstMethod.sub_options.length >= 1) {
|
|
setSelectedSubOption(firstMethod.sub_options[0].id);
|
|
} else {
|
|
setSelectedSubOption(null);
|
|
}
|
|
}
|
|
}, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
|
|
|
|
// When period changes, auto-select first visible tariff if current is hidden
|
|
useEffect(() => {
|
|
if (!visibleTariffs.length) return;
|
|
const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
|
|
if (!currentVisible) {
|
|
setSelectedTariffId(visibleTariffs[0].id);
|
|
}
|
|
}, [visibleTariffs, selectedTariffId]);
|
|
|
|
// 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)
|
|
useEffect(() => {
|
|
if (!config?.custom_css) return;
|
|
|
|
let css = config.custom_css;
|
|
// Strip ALL @-rules (block + inline). The previous full-strip regex was
|
|
// intentionally broad: @media / @keyframes / @supports / @container can
|
|
// smuggle behaviour the rest of the sanitizer doesn't catch.
|
|
css = css.replace(/@[a-zA-Z-]+\s*[^{}]*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, '');
|
|
css = css.replace(/@[a-zA-Z-]+\s*[^;{}]+;/g, '');
|
|
// Strip ALL url() including data: URIs
|
|
css = css.replace(/url\s*\([^)]*\)/gi, 'url(about:blank)');
|
|
// Strip expression(), behavior, -moz-binding
|
|
css = css.replace(/expression\s*\([^)]*\)/gi, '');
|
|
css = css.replace(/behavior\s*:[^;]+/gi, '');
|
|
css = css.replace(/-moz-binding\s*:[^;]+/gi, '');
|
|
// Strip content property (prevents text injection via ::before/::after)
|
|
css = css.replace(/content\s*:[^;]+;/gi, '');
|
|
// Strip CSS escape sequences that could bypass filters
|
|
css = css.replace(/\\[0-9a-fA-F]{1,6}\s?/g, '');
|
|
|
|
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 selectedPeriod = useMemo(
|
|
() => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays),
|
|
[selectedTariff, selectedPeriodDays],
|
|
);
|
|
|
|
const currentPrice = selectedPeriod?.price_kopeks ?? 0;
|
|
|
|
// 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]);
|
|
|
|
// Purchase mutation
|
|
const purchaseMutation = useMutation({
|
|
mutationFn: (data: PurchaseRequest) => landingApi.createPurchase(slug!, data),
|
|
onSuccess: (result) => {
|
|
window.location.href = result.payment_url;
|
|
// If redirect blocked (popup blocker etc.), reset after 5s
|
|
redirectTimeoutRef.current = setTimeout(() => setIsSubmitting(false), 5000);
|
|
},
|
|
onError: (err) => {
|
|
const msg = getApiErrorMessage(
|
|
err,
|
|
t('landing.purchaseError', 'Something went wrong. Please try again.'),
|
|
);
|
|
setSubmitError(msg);
|
|
setIsSubmitting(false);
|
|
},
|
|
});
|
|
|
|
// Submit handler
|
|
const handleSubmit = () => {
|
|
if (!canSubmit || !slug || isSubmitting) return;
|
|
|
|
fireAnalyticsEvent('purchase_click');
|
|
|
|
setIsSubmitting(true);
|
|
setSubmitError(null);
|
|
|
|
// Build the payment_method string: append sub-option suffix if selected
|
|
// e.g. "platega" + "2" → "platega_2", "yookassa" + "sbp" → "yookassa_sbp"
|
|
let paymentMethod = selectedMethod!;
|
|
if (selectedSubOption) {
|
|
paymentMethod = `${paymentMethod}_${selectedSubOption}`;
|
|
}
|
|
|
|
const data: PurchaseRequest = {
|
|
tariff_id: selectedTariffId!,
|
|
period_days: selectedPeriodDays!,
|
|
contact_type: detectContactType(contactValue),
|
|
contact_value: contactValue.trim(),
|
|
payment_method: paymentMethod,
|
|
language: i18n.language,
|
|
is_gift: isGift,
|
|
referrer: sessionStorage.getItem('landing_referrer') || undefined,
|
|
};
|
|
|
|
if (isGift && giftRecipient) {
|
|
data.gift_recipient_type = detectContactType(giftRecipient);
|
|
data.gift_recipient_value = giftRecipient.trim();
|
|
data.gift_message = giftMessage.trim() || undefined;
|
|
}
|
|
|
|
// Get Yandex CID for offline conversions (sync from localStorage)
|
|
const ymCid = getYandexCid();
|
|
if (ymCid) data.yandex_cid = ymCid;
|
|
const subid = sessionStorage.getItem('landing_subid');
|
|
if (subid) (data as unknown as Record<string, unknown>).subid = subid;
|
|
|
|
// Fire landing-specific click goal
|
|
if (config?.analytics_click_enabled && config?.analytics_click_goal) {
|
|
try {
|
|
const w = window as unknown as Record<string, unknown>;
|
|
const counterId = localStorage.getItem('ym_counter_id');
|
|
if (counterId && typeof w.ym === 'function') {
|
|
(w.ym as (...args: unknown[]) => void)(
|
|
Number(counterId),
|
|
'reachGoal',
|
|
config.analytics_click_goal,
|
|
);
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
purchaseMutation.mutate(data);
|
|
};
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return <LoadingSkeleton />;
|
|
}
|
|
|
|
// Error state
|
|
if (error || !config) {
|
|
const errMsg = getApiErrorMessage(error, t('landing.notFound', 'Landing page not found'));
|
|
return <ErrorState message={errMsg} />;
|
|
}
|
|
|
|
const showTariffCards = visibleTariffs.length > 1;
|
|
|
|
return (
|
|
<div className={cn('min-h-dvh overflow-x-hidden', !config.background_config && 'bg-dark-950')}>
|
|
{config.background_config && <StaticBackgroundRenderer config={config.background_config} />}
|
|
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
|
{/* Language switcher */}
|
|
<div className="mb-4 flex justify-end">
|
|
<LanguageSwitcher />
|
|
</div>
|
|
|
|
{/* 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>
|
|
|
|
{/* Discount banner */}
|
|
<AnimatePresence>
|
|
{config.discount && !discountExpired && (
|
|
<DiscountBanner discount={config.discount} onExpired={handleDiscountExpired} />
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 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="min-w-0 space-y-6"
|
|
>
|
|
{/* Period tabs */}
|
|
{allPeriods.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={allPeriods}
|
|
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);
|
|
try {
|
|
localStorage.setItem(contactKey, v);
|
|
} catch {
|
|
/* */
|
|
}
|
|
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
|
|
role="radiogroup"
|
|
aria-label={t('landing.chooseTariff', 'Choose tariff')}
|
|
className="grid gap-3 sm:grid-cols-2"
|
|
>
|
|
{visibleTariffs.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
|
|
role="radiogroup"
|
|
aria-label={t('landing.paymentMethod', 'Payment method')}
|
|
className="space-y-2"
|
|
>
|
|
{config.payment_methods.map((method) => (
|
|
<PaymentMethodCard
|
|
key={method.method_id}
|
|
method={method}
|
|
isSelected={method.method_id === selectedMethod}
|
|
selectedSubOption={
|
|
method.method_id === selectedMethod ? selectedSubOption : null
|
|
}
|
|
onSelect={() => {
|
|
setSelectedMethod(method.method_id);
|
|
// Auto-select first sub-option (even for single — backend needs suffix)
|
|
if (method.sub_options && method.sub_options.length >= 1) {
|
|
setSelectedSubOption(method.sub_options[0].id);
|
|
} else {
|
|
setSelectedSubOption(null);
|
|
}
|
|
}}
|
|
onSelectSubOption={setSelectedSubOption}
|
|
/>
|
|
))}
|
|
</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={cn(
|
|
'min-w-0 lg:sticky lg:top-8 lg:self-start',
|
|
config?.sticky_pay_button && 'mb-20 lg:mb-0',
|
|
)}
|
|
>
|
|
<SummaryCard
|
|
config={config}
|
|
selectedTariff={selectedTariff}
|
|
selectedPeriod={selectedPeriod}
|
|
currentPrice={currentPrice}
|
|
isSubmitting={isSubmitting}
|
|
canSubmit={canSubmit}
|
|
submitError={submitError}
|
|
onSubmit={handleSubmit}
|
|
stickyPayButton={config?.sticky_pay_button}
|
|
/>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|