mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
fix: безопасность и UX лендингов — 16 исправлений
- CRITICAL: усиленная CSS-санитизация (escape-последовательности, at-rules) - CRITICAL: очистка setTimeout при unmount - HIGH: useMutation для создания покупки (вместо ручного fetch) - HIGH: DOMPurify + rel="noopener noreferrer" на ссылках - HIGH: ErrorBoundary для публичных страниц покупки - MEDIUM: стабильные UUID для DnD элементов - MEDIUM: race condition в delete timeout - MEDIUM: клиентская валидация формы лендинга - MEDIUM: staleTime для админ-запросов - MEDIUM: общая утилита getApiErrorMessage - MEDIUM: уведомление о таймауте поллинга - LOW: ARIA роли для radio-карточек и форм - LOW: дедупликация иконок в LandingIcons.tsx - LOW: cn() вместо template literals
This commit is contained in:
17
src/App.tsx
17
src/App.tsx
@@ -9,6 +9,7 @@ import {
|
||||
ChannelSubscriptionScreen,
|
||||
BlacklistedScreen,
|
||||
} from './components/blocking';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||
import { saveReturnUrl } from './utils/token';
|
||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||
@@ -201,17 +202,21 @@ function App() {
|
||||
<Route
|
||||
path="/buy/success/:token"
|
||||
element={
|
||||
<LazyPage>
|
||||
<PurchaseSuccess />
|
||||
</LazyPage>
|
||||
<ErrorBoundary level="app">
|
||||
<LazyPage>
|
||||
<PurchaseSuccess />
|
||||
</LazyPage>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buy/:slug"
|
||||
element={
|
||||
<LazyPage>
|
||||
<QuickPurchase />
|
||||
</LazyPage>
|
||||
<ErrorBoundary level="app">
|
||||
<LazyPage>
|
||||
<QuickPurchase />
|
||||
</LazyPage>
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
69
src/components/icons/LandingIcons.tsx
Normal file
69
src/components/icons/LandingIcons.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
interface IconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BackIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn('h-5 w-5 text-dark-400', className)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlusIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrashIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function GripIcon({ className }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3184,7 +3184,11 @@
|
||||
"general": "General",
|
||||
"content": "Content",
|
||||
"save": "Save",
|
||||
"back": "Back"
|
||||
"back": "Back",
|
||||
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
"titleRequired": "Title is required",
|
||||
"noTariffs": "Select at least one tariff",
|
||||
"noPaymentMethods": "Add at least one payment method"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -3950,6 +3954,9 @@
|
||||
"pay": "Pay",
|
||||
"choosePeriod": "Choose period",
|
||||
"copied": "Copied!",
|
||||
"copyLink": "Copy link"
|
||||
"copyLink": "Copy link",
|
||||
"giftToggleLabel": "Purchase type",
|
||||
"pollTimedOut": "Taking longer than expected",
|
||||
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2910,7 +2910,11 @@
|
||||
"general": "عمومی",
|
||||
"content": "محتوا",
|
||||
"save": "ذخیره",
|
||||
"back": "بازگشت"
|
||||
"back": "بازگشت",
|
||||
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
"titleRequired": "Title is required",
|
||||
"noTariffs": "Select at least one tariff",
|
||||
"noPaymentMethods": "Add at least one payment method"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -3501,6 +3505,9 @@
|
||||
"pay": "پرداخت",
|
||||
"choosePeriod": "انتخاب دوره",
|
||||
"copied": "کپی شد!",
|
||||
"copyLink": "کپی لینک"
|
||||
"copyLink": "کپی لینک",
|
||||
"giftToggleLabel": "Purchase type",
|
||||
"pollTimedOut": "Taking longer than expected",
|
||||
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3739,7 +3739,11 @@
|
||||
"general": "Основное",
|
||||
"content": "Контент",
|
||||
"save": "Сохранить",
|
||||
"back": "Назад"
|
||||
"back": "Назад",
|
||||
"invalidSlug": "Slug может содержать только строчные буквы, цифры и дефисы",
|
||||
"titleRequired": "Заголовок обязателен",
|
||||
"noTariffs": "Выберите хотя бы один тариф",
|
||||
"noPaymentMethods": "Добавьте хотя бы один способ оплаты"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -4513,6 +4517,9 @@
|
||||
"pay": "Оплатить",
|
||||
"choosePeriod": "Выберите период",
|
||||
"copied": "Скопировано!",
|
||||
"copyLink": "Скопировать ссылку"
|
||||
"copyLink": "Скопировать ссылку",
|
||||
"giftToggleLabel": "Тип покупки",
|
||||
"pollTimedOut": "Занимает больше времени, чем ожидалось",
|
||||
"pollTimedOutDesc": "Обработка платежа занимает больше времени, чем обычно. Вы можете попробовать проверить ещё раз."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2909,7 +2909,11 @@
|
||||
"general": "基本设置",
|
||||
"content": "内容",
|
||||
"save": "保存",
|
||||
"back": "返回"
|
||||
"back": "返回",
|
||||
"invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
|
||||
"titleRequired": "Title is required",
|
||||
"noTariffs": "Select at least one tariff",
|
||||
"noPaymentMethods": "Add at least one payment method"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -3500,6 +3504,9 @@
|
||||
"pay": "支付",
|
||||
"choosePeriod": "选择时长",
|
||||
"copied": "已复制!",
|
||||
"copyLink": "复制链接"
|
||||
"copyLink": "复制链接",
|
||||
"giftToggleLabel": "Purchase type",
|
||||
"pollTimedOut": "Taking longer than expected",
|
||||
"pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { tariffsApi, TariffListItem } from '../api/tariffs';
|
||||
import { Toggle } from '../components/admin';
|
||||
import { useNotify } from '@/platform';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -28,49 +30,15 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
// 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>
|
||||
);
|
||||
// Types with stable IDs for DnD
|
||||
type FeatureWithId = LandingFeature & { _id: string };
|
||||
type MethodWithId = LandingPaymentMethod & { _id: string };
|
||||
|
||||
const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
||||
<svg
|
||||
className={`h-5 w-5 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -83,23 +51,16 @@ const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
||||
// ============ Sortable Feature Item ============
|
||||
|
||||
interface SortableFeatureProps {
|
||||
feature: LandingFeature;
|
||||
featureId: string;
|
||||
feature: FeatureWithId;
|
||||
index: number;
|
||||
onUpdate: (index: number, field: keyof LandingFeature, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableFeatureItem({
|
||||
feature,
|
||||
featureId,
|
||||
index,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: SortableFeatureProps) {
|
||||
function SortableFeatureItem({ feature, index, onUpdate, onRemove }: SortableFeatureProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: featureId,
|
||||
id: feature._id,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
@@ -113,9 +74,10 @@ function SortableFeatureItem({
|
||||
<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'
|
||||
}`}
|
||||
className={cn(
|
||||
'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}
|
||||
@@ -162,17 +124,16 @@ function SortableFeatureItem({
|
||||
// ============ Sortable Payment Method ============
|
||||
|
||||
interface SortableMethodProps {
|
||||
method: LandingPaymentMethod;
|
||||
methodId: string;
|
||||
method: MethodWithId;
|
||||
index: number;
|
||||
onUpdate: (index: number, field: keyof LandingPaymentMethod, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableMethodItem({ method, methodId, index, onUpdate, onRemove }: SortableMethodProps) {
|
||||
function SortableMethodItem({ method, index, onUpdate, onRemove }: SortableMethodProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: methodId,
|
||||
id: method._id,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
@@ -186,9 +147,10 @@ function SortableMethodItem({ method, methodId, index, onUpdate, onRemove }: Sor
|
||||
<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'
|
||||
}`}
|
||||
className={cn(
|
||||
'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}
|
||||
@@ -297,10 +259,10 @@ export default function AdminLandingEditor() {
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [metaTitle, setMetaTitle] = useState('');
|
||||
const [metaDescription, setMetaDescription] = useState('');
|
||||
const [features, setFeatures] = useState<LandingFeature[]>([]);
|
||||
const [features, setFeatures] = useState<FeatureWithId[]>([]);
|
||||
const [selectedTariffIds, setSelectedTariffIds] = useState<number[]>([]);
|
||||
const [allowedPeriods, setAllowedPeriods] = useState<Record<string, number[]>>({});
|
||||
const [paymentMethods, setPaymentMethods] = useState<LandingPaymentMethod[]>([]);
|
||||
const [paymentMethods, setPaymentMethods] = useState<MethodWithId[]>([]);
|
||||
const [giftEnabled, setGiftEnabled] = useState(false);
|
||||
const [footerText, setFooterText] = useState('');
|
||||
const [customCss, setCustomCss] = useState('');
|
||||
@@ -315,6 +277,7 @@ export default function AdminLandingEditor() {
|
||||
const { data: tariffsData } = useQuery({
|
||||
queryKey: ['admin-tariffs'],
|
||||
queryFn: () => tariffsApi.getTariffs(true),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const allTariffs = tariffsData?.tariffs ?? [];
|
||||
@@ -347,10 +310,12 @@ export default function AdminLandingEditor() {
|
||||
setIsActive(landingData.is_active);
|
||||
setMetaTitle(landingData.meta_title ?? '');
|
||||
setMetaDescription(landingData.meta_description ?? '');
|
||||
setFeatures(landingData.features ?? []);
|
||||
setFeatures((landingData.features ?? []).map((f) => ({ ...f, _id: crypto.randomUUID() })));
|
||||
setSelectedTariffIds(landingData.allowed_tariff_ids ?? []);
|
||||
setAllowedPeriods(landingData.allowed_periods ?? {});
|
||||
setPaymentMethods(landingData.payment_methods ?? []);
|
||||
setPaymentMethods(
|
||||
(landingData.payment_methods ?? []).map((m) => ({ ...m, _id: crypto.randomUUID() })),
|
||||
);
|
||||
setGiftEnabled(landingData.gift_enabled);
|
||||
setFooterText(landingData.footer_text ?? '');
|
||||
setCustomCss(landingData.custom_css ?? '');
|
||||
@@ -365,9 +330,7 @@ export default function AdminLandingEditor() {
|
||||
navigate('/admin/landings');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail;
|
||||
notify.error(message || t('common.error'));
|
||||
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -382,23 +345,48 @@ export default function AdminLandingEditor() {
|
||||
navigate('/admin/landings');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail;
|
||||
notify.error(message || t('common.error'));
|
||||
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Client-side validation
|
||||
if (!isEdit && !/^[a-z0-9-]+$/.test(slug)) {
|
||||
notify.error(
|
||||
t(
|
||||
'admin.landings.invalidSlug',
|
||||
'Slug can only contain lowercase letters, numbers, and hyphens',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!title.trim()) {
|
||||
notify.error(t('admin.landings.titleRequired', 'Title is required'));
|
||||
return;
|
||||
}
|
||||
if (selectedTariffIds.length === 0) {
|
||||
notify.error(t('admin.landings.noTariffs', 'Select at least one tariff'));
|
||||
return;
|
||||
}
|
||||
if (paymentMethods.length === 0) {
|
||||
notify.error(t('admin.landings.noPaymentMethods', 'Add at least one payment method'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip _id before sending to API
|
||||
const cleanFeatures = features.map(({ _id: _, ...rest }) => rest);
|
||||
const cleanMethods = paymentMethods.map(({ _id: _, ...rest }) => rest);
|
||||
|
||||
const data: LandingCreateRequest = {
|
||||
slug,
|
||||
title,
|
||||
subtitle: subtitle || undefined,
|
||||
is_active: isActive,
|
||||
features,
|
||||
features: cleanFeatures,
|
||||
footer_text: footerText || undefined,
|
||||
allowed_tariff_ids: selectedTariffIds,
|
||||
allowed_periods: allowedPeriods,
|
||||
payment_methods: paymentMethods,
|
||||
payment_methods: cleanMethods,
|
||||
gift_enabled: giftEnabled,
|
||||
custom_css: customCss || undefined,
|
||||
meta_title: metaTitle || undefined,
|
||||
@@ -416,7 +404,10 @@ export default function AdminLandingEditor() {
|
||||
|
||||
// ---- Features helpers ----
|
||||
const addFeature = () => {
|
||||
setFeatures((prev) => [...prev, { icon: '', title: '', description: '' }]);
|
||||
setFeatures((prev) => [
|
||||
...prev,
|
||||
{ _id: crypto.randomUUID(), icon: '', title: '', description: '' },
|
||||
]);
|
||||
};
|
||||
|
||||
const updateFeature = (index: number, field: keyof LandingFeature, value: string) => {
|
||||
@@ -431,8 +422,8 @@ export default function AdminLandingEditor() {
|
||||
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);
|
||||
const oldIndex = prev.findIndex((f) => f._id === active.id);
|
||||
const newIndex = prev.findIndex((f) => f._id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
@@ -444,6 +435,7 @@ export default function AdminLandingEditor() {
|
||||
setPaymentMethods((prev) => [
|
||||
...prev,
|
||||
{
|
||||
_id: crypto.randomUUID(),
|
||||
method_id: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
@@ -465,8 +457,8 @@ export default function AdminLandingEditor() {
|
||||
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);
|
||||
const oldIndex = prev.findIndex((m) => m._id === active.id);
|
||||
const newIndex = prev.findIndex((m) => m._id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
@@ -492,8 +484,8 @@ export default function AdminLandingEditor() {
|
||||
};
|
||||
|
||||
// Feature IDs for DnD
|
||||
const featureIds = features.map((_, i) => `feature-${i}`);
|
||||
const methodIds = paymentMethods.map((_, i) => `method-${i}`);
|
||||
const featureIds = features.map((f) => f._id);
|
||||
const methodIds = paymentMethods.map((m) => m._id);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
@@ -541,8 +533,11 @@ export default function AdminLandingEditor() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">{t('admin.landings.slug')}</label>
|
||||
<label htmlFor="landing-slug" className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.slug')}
|
||||
</label>
|
||||
<input
|
||||
id="landing-slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
@@ -554,10 +549,11 @@ export default function AdminLandingEditor() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
<label htmlFor="landing-title" className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.pageTitle')}
|
||||
</label>
|
||||
<input
|
||||
id="landing-title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -566,10 +562,11 @@ export default function AdminLandingEditor() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
<label htmlFor="landing-subtitle" className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.subtitle')}
|
||||
</label>
|
||||
<textarea
|
||||
id="landing-subtitle"
|
||||
value={subtitle}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
rows={2}
|
||||
@@ -624,9 +621,8 @@ export default function AdminLandingEditor() {
|
||||
<SortableContext items={featureIds} strategy={verticalListSortingStrategy}>
|
||||
{features.map((feature, index) => (
|
||||
<SortableFeatureItem
|
||||
key={featureIds[index]}
|
||||
key={feature._id}
|
||||
feature={feature}
|
||||
featureId={featureIds[index]}
|
||||
index={index}
|
||||
onUpdate={updateFeature}
|
||||
onRemove={removeFeature}
|
||||
@@ -703,9 +699,8 @@ export default function AdminLandingEditor() {
|
||||
<SortableContext items={methodIds} strategy={verticalListSortingStrategy}>
|
||||
{paymentMethods.map((method, index) => (
|
||||
<SortableMethodItem
|
||||
key={methodIds[index]}
|
||||
key={method._id}
|
||||
method={method}
|
||||
methodId={methodIds[index]}
|
||||
index={index}
|
||||
onUpdate={updateMethod}
|
||||
onRemove={removeMethod}
|
||||
|
||||
@@ -5,7 +5,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { adminLandingsApi, LandingListItem } from '../api/landings';
|
||||
import { useNotify } from '@/platform';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { cn } from '../lib/utils';
|
||||
import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -23,13 +26,7 @@ import {
|
||||
} 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>
|
||||
);
|
||||
|
||||
// Icons (non-shared, page-specific)
|
||||
const EditIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
@@ -40,16 +37,6 @@ const EditIcon = () => (
|
||||
</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" />
|
||||
@@ -72,28 +59,6 @@ const GiftIcon = () => (
|
||||
</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
|
||||
@@ -149,13 +114,14 @@ function SortableLandingCard({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
|
||||
className={cn(
|
||||
'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'
|
||||
}`}
|
||||
: 'border-dark-700/50 opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Drag handle */}
|
||||
@@ -209,11 +175,12 @@ function SortableLandingCard({
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
className={cn(
|
||||
'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'
|
||||
}`}
|
||||
: '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 />}
|
||||
@@ -229,11 +196,12 @@ function SortableLandingCard({
|
||||
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
className={cn(
|
||||
'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'
|
||||
}`}
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400',
|
||||
)}
|
||||
title={
|
||||
isPendingDelete
|
||||
? t('admin.landings.deleteConfirm', { title: landing.title })
|
||||
@@ -276,6 +244,7 @@ export default function AdminLandings() {
|
||||
const { data: landingsData, isLoading } = useQuery({
|
||||
queryKey: ['admin-landings'],
|
||||
queryFn: () => adminLandingsApi.list(),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Sync fetched data to local state
|
||||
@@ -294,9 +263,7 @@ export default function AdminLandings() {
|
||||
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'));
|
||||
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -308,9 +275,7 @@ export default function AdminLandings() {
|
||||
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'));
|
||||
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -320,6 +285,7 @@ export default function AdminLandings() {
|
||||
setPendingDeleteId(null);
|
||||
} else {
|
||||
setPendingDeleteId(landing.id);
|
||||
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
|
||||
deleteTimeoutRef.current = setTimeout(
|
||||
() => setPendingDeleteId((prev) => (prev === landing.id ? null : prev)),
|
||||
3000,
|
||||
@@ -333,9 +299,7 @@ export default function AdminLandings() {
|
||||
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'));
|
||||
notify.error(getApiErrorMessage(err, t('common.error')));
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,13 @@ function SuccessState({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const url = subscriptionUrl ?? cryptoLink;
|
||||
@@ -70,7 +77,8 @@ function SuccessState({
|
||||
try {
|
||||
await copyToClipboard(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
|
||||
copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard write failed silently
|
||||
}
|
||||
@@ -219,6 +227,52 @@ function FailedState() {
|
||||
);
|
||||
}
|
||||
|
||||
function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
|
||||
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-dark-800/50">
|
||||
<svg
|
||||
className="h-10 w-10 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-50">
|
||||
{t('landing.pollTimedOut', 'Taking longer than expected')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-dark-400">
|
||||
{t(
|
||||
'landing.pollTimedOutDesc',
|
||||
'Payment processing is taking longer than usual. You can try checking again.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="rounded-xl bg-accent-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-accent-400"
|
||||
>
|
||||
{t('common.retry', 'Retry')}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Component
|
||||
// ============================================================
|
||||
@@ -226,6 +280,7 @@ function FailedState() {
|
||||
export default function PurchaseSuccess() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const pollStart = useRef(Date.now());
|
||||
const [pollTimedOut, setPollTimedOut] = useState(false);
|
||||
|
||||
// Referrer-Policy: prevent leaking payment token via referer header
|
||||
useEffect(() => {
|
||||
@@ -238,14 +293,19 @@ export default function PurchaseSuccess() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: status, isError } = useQuery({
|
||||
const {
|
||||
data: status,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['purchase-status', token],
|
||||
queryFn: () => landingApi.getPurchaseStatus(token!),
|
||||
enabled: !!token,
|
||||
enabled: !!token && !pollTimedOut,
|
||||
refetchInterval: (query) => {
|
||||
const currentStatus = query.state.data?.status;
|
||||
if (currentStatus === 'pending' || currentStatus === 'paid') {
|
||||
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||
setPollTimedOut(true);
|
||||
return false;
|
||||
}
|
||||
return 3_000;
|
||||
@@ -255,6 +315,12 @@ export default function PurchaseSuccess() {
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const handleRetryPoll = useCallback(() => {
|
||||
pollStart.current = Date.now();
|
||||
setPollTimedOut(false);
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const isSuccess = status?.status === 'delivered';
|
||||
const isFailed = status?.status === 'failed' || status?.status === 'expired';
|
||||
|
||||
@@ -274,6 +340,8 @@ export default function PurchaseSuccess() {
|
||||
/>
|
||||
) : isFailed ? (
|
||||
<FailedState />
|
||||
) : pollTimedOut ? (
|
||||
<PollTimedOutState onRetry={handleRetryPoll} />
|
||||
) : (
|
||||
<PendingState />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
PurchaseRequest,
|
||||
} from '../api/landings';
|
||||
import { cn } from '../lib/utils';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
@@ -117,10 +118,15 @@ function GiftToggle({ isGift, onToggle }: { isGift: boolean; onToggle: (v: boole
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex rounded-xl bg-dark-800/50 p-1">
|
||||
<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',
|
||||
@@ -131,6 +137,7 @@ function GiftToggle({ isGift, onToggle }: { isGift: boolean; onToggle: (v: boole
|
||||
<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',
|
||||
@@ -165,10 +172,11 @@ function ContactForm({
|
||||
<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">
|
||||
<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)}
|
||||
@@ -189,10 +197,14 @@ function ContactForm({
|
||||
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">
|
||||
<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)}
|
||||
@@ -201,10 +213,14 @@ function ContactForm({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
<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(
|
||||
@@ -238,6 +254,8 @@ function TariffCard({
|
||||
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-left transition-all duration-200',
|
||||
@@ -334,6 +352,8 @@ function PaymentMethodCard({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-4 rounded-2xl border p-4 text-left transition-all duration-200',
|
||||
@@ -371,22 +391,22 @@ function PaymentMethodCard({
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
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={className}
|
||||
// Content is sanitized via DOMPurify above
|
||||
dangerouslySetInnerHTML={{ __html: sanitized }}
|
||||
/>
|
||||
);
|
||||
return <div className={className} dangerouslySetInnerHTML={{ __html: sanitized }} />;
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
@@ -540,6 +560,14 @@ export default function QuickPurchase() {
|
||||
const [selectedMethod, setSelectedMethod] = 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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-select first tariff, period, method on config load
|
||||
useEffect(() => {
|
||||
@@ -584,29 +612,24 @@ export default function QuickPurchase() {
|
||||
};
|
||||
}, [config?.meta_description]);
|
||||
|
||||
// Inject custom CSS (sanitized: strip JS expressions, imports, and url() with non-data schemes)
|
||||
// Inject custom CSS (sanitized)
|
||||
useEffect(() => {
|
||||
if (!config?.custom_css) return;
|
||||
|
||||
let css = config.custom_css;
|
||||
// Remove @import rules
|
||||
css = css.replace(/@import\b[^;]*;?/gi, '');
|
||||
// Remove expression() (IE)
|
||||
// Strip all at-rules (including @font-face, @import, @charset, @namespace, @keyframes, @media)
|
||||
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, '');
|
||||
// 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, '');
|
||||
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');
|
||||
@@ -650,8 +673,26 @@ export default function QuickPurchase() {
|
||||
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 = async () => {
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit || !slug || isSubmitting) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
@@ -672,19 +713,7 @@ export default function QuickPurchase() {
|
||||
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);
|
||||
}
|
||||
purchaseMutation.mutate(data);
|
||||
};
|
||||
|
||||
// Loading state
|
||||
@@ -694,9 +723,7 @@ export default function QuickPurchase() {
|
||||
|
||||
// Error state
|
||||
if (error || !config) {
|
||||
const errMsg =
|
||||
(error as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||
t('landing.notFound', 'Landing page not found');
|
||||
const errMsg = getApiErrorMessage(error, t('landing.notFound', 'Landing page not found'));
|
||||
return <ErrorState message={errMsg} />;
|
||||
}
|
||||
|
||||
@@ -769,7 +796,11 @@ export default function QuickPurchase() {
|
||||
<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">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('landing.chooseTariff', 'Choose tariff')}
|
||||
className="grid gap-3 sm:grid-cols-2"
|
||||
>
|
||||
{config.tariffs.map((tariff) => {
|
||||
const period = tariff.periods.find((p) => p.days === selectedPeriodDays);
|
||||
return (
|
||||
@@ -792,7 +823,11 @@ export default function QuickPurchase() {
|
||||
<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">
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('landing.paymentMethod', 'Payment method')}
|
||||
className="space-y-2"
|
||||
>
|
||||
{config.payment_methods.map((method) => (
|
||||
<PaymentMethodCard
|
||||
key={method.method_id}
|
||||
|
||||
8
src/utils/api-error.ts
Normal file
8
src/utils/api-error.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export function getApiErrorMessage(err: unknown, fallback: string): string {
|
||||
if (axios.isAxiosError(err)) {
|
||||
return err.response?.data?.detail ?? fallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user