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:
50
src/App.tsx
50
src/App.tsx
@@ -37,6 +37,8 @@ const Info = lazy(() => import('./pages/Info'));
|
||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||
const Connection = lazy(() => import('./pages/Connection'));
|
||||
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
||||
const QuickPurchase = lazy(() => import('./pages/QuickPurchase'));
|
||||
const PurchaseSuccess = lazy(() => import('./pages/PurchaseSuccess'));
|
||||
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
||||
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
||||
const ConnectedAccounts = lazy(() => import('./pages/ConnectedAccounts'));
|
||||
@@ -104,6 +106,8 @@ const AdminRoleAssign = lazy(() => import('./pages/AdminRoleAssign'));
|
||||
const AdminPolicies = lazy(() => import('./pages/AdminPolicies'));
|
||||
const AdminPolicyEdit = lazy(() => import('./pages/AdminPolicyEdit'));
|
||||
const AdminAuditLog = lazy(() => import('./pages/AdminAuditLog'));
|
||||
const AdminLandings = lazy(() => import('./pages/AdminLandings'));
|
||||
const AdminLandingEditor = lazy(() => import('./pages/AdminLandingEditor'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
@@ -194,6 +198,22 @@ function App() {
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buy/success/:token"
|
||||
element={
|
||||
<LazyPage>
|
||||
<PurchaseSuccess />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/buy/:slug"
|
||||
element={
|
||||
<LazyPage>
|
||||
<QuickPurchase />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
@@ -476,6 +496,36 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/landings"
|
||||
element={
|
||||
<PermissionRoute permission="landings:read">
|
||||
<LazyPage>
|
||||
<AdminLandings />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/landings/create"
|
||||
element={
|
||||
<PermissionRoute permission="landings:create">
|
||||
<LazyPage>
|
||||
<AdminLandingEditor />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/landings/:id/edit"
|
||||
element={
|
||||
<PermissionRoute permission="landings:edit">
|
||||
<LazyPage>
|
||||
<AdminLandingEditor />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/servers"
|
||||
element={
|
||||
|
||||
@@ -77,6 +77,7 @@ const AUTH_ENDPOINTS = [
|
||||
'/cabinet/auth/oauth/',
|
||||
'/cabinet/auth/merge/',
|
||||
'/cabinet/auth/account/link/server-complete',
|
||||
'/cabinet/landing/',
|
||||
];
|
||||
|
||||
function isAuthEndpoint(url: string | undefined): boolean {
|
||||
|
||||
199
src/api/landings.ts
Normal file
199
src/api/landings.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// ============================================================
|
||||
// Public types
|
||||
// ============================================================
|
||||
|
||||
export interface LandingFeature {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface LandingTariffPeriod {
|
||||
days: number;
|
||||
label: string;
|
||||
price_kopeks: number;
|
||||
price_label: string;
|
||||
}
|
||||
|
||||
export interface LandingTariff {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
traffic_limit_gb: number;
|
||||
device_limit: number;
|
||||
tier_level: number;
|
||||
periods: LandingTariffPeriod[];
|
||||
}
|
||||
|
||||
export interface LandingPaymentMethod {
|
||||
method_id: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
icon_url: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface LandingConfig {
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
features: LandingFeature[];
|
||||
footer_text: string | null;
|
||||
tariffs: LandingTariff[];
|
||||
payment_methods: LandingPaymentMethod[];
|
||||
gift_enabled: boolean;
|
||||
custom_css: string | null;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
}
|
||||
|
||||
export interface PurchaseRequest {
|
||||
tariff_id: number;
|
||||
period_days: number;
|
||||
contact_type: 'email' | 'telegram';
|
||||
contact_value: string;
|
||||
payment_method: string;
|
||||
is_gift: boolean;
|
||||
gift_recipient_type?: 'email' | 'telegram';
|
||||
gift_recipient_value?: string;
|
||||
gift_message?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
purchase_token: string;
|
||||
payment_url: string;
|
||||
}
|
||||
|
||||
export interface PurchaseStatus {
|
||||
status: 'pending' | 'paid' | 'delivered' | 'failed' | 'expired';
|
||||
subscription_url: string | null;
|
||||
subscription_crypto_link: string | null;
|
||||
is_gift: boolean;
|
||||
contact_value: string | null;
|
||||
period_days: number | null;
|
||||
tariff_name: string | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Admin types
|
||||
// ============================================================
|
||||
|
||||
export interface LandingListItem {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
is_active: boolean;
|
||||
display_order: number;
|
||||
gift_enabled: boolean;
|
||||
tariff_count: number;
|
||||
method_count: number;
|
||||
purchase_stats: {
|
||||
total: number;
|
||||
paid: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface LandingDetail {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
is_active: boolean;
|
||||
features: LandingFeature[];
|
||||
footer_text: string | null;
|
||||
allowed_tariff_ids: number[];
|
||||
allowed_periods: Record<string, number[]>;
|
||||
payment_methods: LandingPaymentMethod[];
|
||||
gift_enabled: boolean;
|
||||
custom_css: string | null;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
display_order: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface LandingCreateRequest {
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
is_active?: boolean;
|
||||
features?: LandingFeature[];
|
||||
footer_text?: string;
|
||||
allowed_tariff_ids?: number[];
|
||||
allowed_periods?: Record<string, number[]>;
|
||||
payment_methods?: LandingPaymentMethod[];
|
||||
gift_enabled?: boolean;
|
||||
custom_css?: string;
|
||||
meta_title?: string;
|
||||
meta_description?: string;
|
||||
}
|
||||
|
||||
export type LandingUpdateRequest = Partial<LandingCreateRequest>;
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
export const landingApi = {
|
||||
getConfig: async (slug: string): Promise<LandingConfig> => {
|
||||
const response = await apiClient.get(`/cabinet/landing/${slug}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createPurchase: async (slug: string, data: PurchaseRequest): Promise<PurchaseResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/landing/${slug}/purchase`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPurchaseStatus: async (token: string): Promise<PurchaseStatus> => {
|
||||
const response = await apiClient.get(`/cabinet/landing/purchase/${token}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Admin API
|
||||
// ============================================================
|
||||
|
||||
export const adminLandingsApi = {
|
||||
list: async (): Promise<LandingListItem[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/landings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
get: async (id: number): Promise<LandingDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/landings/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (data: LandingCreateRequest): Promise<LandingDetail> => {
|
||||
const response = await apiClient.post('/cabinet/admin/landings', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, data: LandingUpdateRequest): Promise<LandingDetail> => {
|
||||
const response = await apiClient.put(`/cabinet/admin/landings/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/landings/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
toggle: async (id: number): Promise<{ id: number; is_active: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/landings/${id}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
reorder: async (landingIds: number[]): Promise<void> => {
|
||||
await apiClient.put('/cabinet/admin/landings/order', { landing_ids: landingIds });
|
||||
},
|
||||
};
|
||||
@@ -236,15 +236,24 @@ export default function SubscriptionCardActive({
|
||||
{t('dashboard.connectDevice')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('dashboard.devicesOfMax', {
|
||||
used: connectedDevices,
|
||||
max: subscription.device_limit,
|
||||
})}
|
||||
{subscription.device_limit === 0
|
||||
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
||||
: t('dashboard.devicesOfMax', {
|
||||
used: connectedDevices,
|
||||
max: subscription.device_limit,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device indicator */}
|
||||
{subscription.device_limit <= 10 ? (
|
||||
{subscription.device_limit === 0 ? (
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
∞
|
||||
</div>
|
||||
) : subscription.device_limit <= 10 ? (
|
||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||
<div
|
||||
|
||||
@@ -175,7 +175,10 @@ export default function TrialOfferCard({
|
||||
value: trialInfo.traffic_limit_gb === 0 ? '∞' : String(trialInfo.traffic_limit_gb),
|
||||
label: t('common.units.gb'),
|
||||
},
|
||||
{ value: String(trialInfo.device_limit), label: t('subscription.trial.devices') },
|
||||
{
|
||||
value: trialInfo.device_limit === 0 ? '∞' : String(trialInfo.device_limit),
|
||||
label: t('subscription.trial.devices'),
|
||||
},
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="text-center">
|
||||
<div className="text-4xl font-extrabold leading-none tracking-tight text-dark-50">
|
||||
|
||||
@@ -235,13 +235,13 @@
|
||||
"connectDevice": "Connect Device",
|
||||
"devicesConnected": "{{count}} connected",
|
||||
"devicesOfMax": "{{used}} of {{max}} connected",
|
||||
"devicesConnectedUnlimited": "{{used}} connected · unlimited",
|
||||
"devicesShort": "dev.",
|
||||
"trafficUsage": "{{used}} / {{limit}} GB",
|
||||
"trafficUsageTitle": "Traffic Usage",
|
||||
"usedTraffic": "used {{amount}}",
|
||||
"usedSuffix": "used",
|
||||
"unlimited": "Unlimited",
|
||||
"unlimitedTraffic": "Unlimited traffic",
|
||||
"tariff": "Tariff",
|
||||
"validUntil": "until {{date}}",
|
||||
"remaining": "Remaining",
|
||||
@@ -1043,7 +1043,8 @@
|
||||
"roleAssign": "Role Assignment",
|
||||
"policies": "Access Policies",
|
||||
"auditLog": "Audit Log",
|
||||
"salesStats": "Sales Statistics"
|
||||
"salesStats": "Sales Statistics",
|
||||
"landings": "Landings"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -1076,7 +1077,8 @@
|
||||
"roleAssignDesc": "Assign and revoke user roles",
|
||||
"policiesDesc": "Configure ABAC access policies",
|
||||
"auditLogDesc": "Review system activity and access history",
|
||||
"salesStatsDesc": "Sales analytics and trends"
|
||||
"salesStatsDesc": "Sales analytics and trends",
|
||||
"landingsDesc": "Quick purchase landing pages"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Sales Statistics",
|
||||
@@ -3138,6 +3140,51 @@
|
||||
"loadFailed": "Failed to load audit log",
|
||||
"retry": "Try again"
|
||||
}
|
||||
},
|
||||
"landings": {
|
||||
"title": "Landing Pages",
|
||||
"create": "Create Landing",
|
||||
"edit": "Edit",
|
||||
"slug": "URL Identifier",
|
||||
"slugHint": "Lowercase, numbers and hyphens",
|
||||
"pageTitle": "Title",
|
||||
"subtitle": "Subtitle",
|
||||
"footerText": "Footer (HTML)",
|
||||
"features": "Features",
|
||||
"addFeature": "Add",
|
||||
"featureIcon": "Icon",
|
||||
"featureTitle": "Title",
|
||||
"featureDesc": "Description",
|
||||
"tariffs": "Plans",
|
||||
"selectTariffs": "Select plans",
|
||||
"periods": "Periods",
|
||||
"paymentMethods": "Payment Methods",
|
||||
"addMethod": "Add method",
|
||||
"methodId": "Method ID",
|
||||
"methodName": "Name",
|
||||
"methodDesc": "Description",
|
||||
"methodIcon": "Icon URL",
|
||||
"gifts": "Gifts",
|
||||
"giftEnabled": "Gift purchases",
|
||||
"customCss": "CSS",
|
||||
"seo": "SEO",
|
||||
"metaTitle": "Meta Title",
|
||||
"metaDesc": "Meta Description",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"purchases": "purchases",
|
||||
"copyUrl": "Copy URL",
|
||||
"urlCopied": "URL copied",
|
||||
"deleteConfirm": "Delete landing \"{{title}}\"?",
|
||||
"created": "Landing created",
|
||||
"updated": "Landing updated",
|
||||
"deleted": "Landing deleted",
|
||||
"saveOrder": "Save order",
|
||||
"orderSaved": "Order saved",
|
||||
"general": "General",
|
||||
"content": "Content",
|
||||
"save": "Save",
|
||||
"back": "Back"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -3865,5 +3912,44 @@
|
||||
"error": "Failed to merge accounts. Please try again later.",
|
||||
"expiresIn": "Expires in {{minutes}}",
|
||||
"merging": "Merging..."
|
||||
},
|
||||
"landing": {
|
||||
"notFound": "Page not found",
|
||||
"forMe": "For me",
|
||||
"asGift": "As a gift",
|
||||
"contactLabel": "Your email or @username",
|
||||
"yourContact": "Your email or @username",
|
||||
"contactPlaceholder": "email@example.com or @username",
|
||||
"contactHint": "Enter email or Telegram @username",
|
||||
"recipientLabel": "Gift recipient",
|
||||
"recipientPlaceholder": "Recipient email or @username",
|
||||
"giftMessageLabel": "Greeting (optional)",
|
||||
"giftMessagePlaceholder": "Write a greeting...",
|
||||
"chooseTariff": "Choose a plan",
|
||||
"devices": "devices",
|
||||
"paymentMethod": "Payment method",
|
||||
"processing": "Processing...",
|
||||
"payButton": "Pay {{price}}",
|
||||
"accessFor": "Access for {{period}}",
|
||||
"purchaseError": "Failed to create payment",
|
||||
"purchaseNotFound": "Purchase not found",
|
||||
"awaitingPayment": "Awaiting payment",
|
||||
"awaitingPaymentDesc": "Complete the payment in the opened window",
|
||||
"purchaseSuccess": "Payment successful!",
|
||||
"keySentTo": "Subscription key sent to {{contact}}",
|
||||
"giftSentTo": "Gift sent to {{contact}}",
|
||||
"purchaseFailed": "Payment failed",
|
||||
"purchaseFailedDesc": "Payment was unsuccessful. Try again.",
|
||||
"subscriptionLink": "Subscription link",
|
||||
"daysAccess": "days of access",
|
||||
"error": "Error",
|
||||
"gb": "GB",
|
||||
"selectedTariff": "Tariff",
|
||||
"period": "Period",
|
||||
"total": "Total",
|
||||
"pay": "Pay",
|
||||
"choosePeriod": "Choose period",
|
||||
"copied": "Copied!",
|
||||
"copyLink": "Copy link"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,7 +866,8 @@
|
||||
"roleAssign": "تخصیص نقش",
|
||||
"policies": "سیاستهای دسترسی",
|
||||
"auditLog": "گزارش بازرسی",
|
||||
"salesStats": "آمار فروش"
|
||||
"salesStats": "آمار فروش",
|
||||
"landings": "صفحات فرود"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -899,7 +900,8 @@
|
||||
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||
"salesStatsDesc": "تحلیل فروش و روندها"
|
||||
"salesStatsDesc": "تحلیل فروش و روندها",
|
||||
"landingsDesc": "صفحات خرید سریع"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "آمار فروش",
|
||||
@@ -2864,6 +2866,51 @@
|
||||
"loadFailed": "بارگذاری گزارش بازرسی ناموفق بود",
|
||||
"retry": "تلاش مجدد"
|
||||
}
|
||||
},
|
||||
"landings": {
|
||||
"title": "صفحات فرود",
|
||||
"create": "ایجاد صفحه فرود",
|
||||
"edit": "ویرایش",
|
||||
"slug": "شناسه URL",
|
||||
"slugHint": "حروف کوچک، اعداد و خط تیره",
|
||||
"pageTitle": "عنوان",
|
||||
"subtitle": "زیرعنوان",
|
||||
"footerText": "متن پاورقی (HTML)",
|
||||
"features": "ویژگیها",
|
||||
"addFeature": "افزودن",
|
||||
"featureIcon": "آیکون",
|
||||
"featureTitle": "عنوان",
|
||||
"featureDesc": "توضیحات",
|
||||
"tariffs": "طرحها",
|
||||
"selectTariffs": "انتخاب طرحها",
|
||||
"periods": "دورهها",
|
||||
"paymentMethods": "روشهای پرداخت",
|
||||
"addMethod": "افزودن روش",
|
||||
"methodId": "شناسه روش",
|
||||
"methodName": "نام",
|
||||
"methodDesc": "توضیحات",
|
||||
"methodIcon": "URL آیکون",
|
||||
"gifts": "هدایا",
|
||||
"giftEnabled": "خرید هدیه",
|
||||
"customCss": "CSS",
|
||||
"seo": "SEO",
|
||||
"metaTitle": "عنوان متا",
|
||||
"metaDesc": "توضیحات متا",
|
||||
"active": "فعال",
|
||||
"inactive": "غیرفعال",
|
||||
"purchases": "خرید",
|
||||
"copyUrl": "کپی URL",
|
||||
"urlCopied": "URL کپی شد",
|
||||
"deleteConfirm": "حذف صفحه فرود «{{title}}»؟",
|
||||
"created": "صفحه فرود ایجاد شد",
|
||||
"updated": "صفحه فرود بهروز شد",
|
||||
"deleted": "صفحه فرود حذف شد",
|
||||
"saveOrder": "ذخیره ترتیب",
|
||||
"orderSaved": "ترتیب ذخیره شد",
|
||||
"general": "عمومی",
|
||||
"content": "محتوا",
|
||||
"save": "ذخیره",
|
||||
"back": "بازگشت"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -3416,5 +3463,44 @@
|
||||
"error": "ادغام ناموفق بود. لطفاً بعداً دوباره تلاش کنید.",
|
||||
"expiresIn": "{{minutes}} دقیقه باقی مانده",
|
||||
"merging": "در حال ادغام..."
|
||||
},
|
||||
"landing": {
|
||||
"notFound": "صفحه یافت نشد",
|
||||
"forMe": "برای خودم",
|
||||
"asGift": "به عنوان هدیه",
|
||||
"contactLabel": "ایمیل یا @نام کاربری شما",
|
||||
"yourContact": "ایمیل یا @نام کاربری شما",
|
||||
"contactPlaceholder": "email@example.com یا @username",
|
||||
"contactHint": "ایمیل یا @نام کاربری تلگرام را وارد کنید",
|
||||
"recipientLabel": "گیرنده هدیه",
|
||||
"recipientPlaceholder": "ایمیل یا @نام کاربری گیرنده",
|
||||
"giftMessageLabel": "پیام تبریک (اختیاری)",
|
||||
"giftMessagePlaceholder": "یک پیام تبریک بنویسید...",
|
||||
"chooseTariff": "یک طرح انتخاب کنید",
|
||||
"devices": "دستگاه",
|
||||
"paymentMethod": "روش پرداخت",
|
||||
"processing": "در حال پردازش...",
|
||||
"payButton": "پرداخت {{price}}",
|
||||
"accessFor": "دسترسی برای {{period}}",
|
||||
"purchaseError": "خطا در ایجاد پرداخت",
|
||||
"purchaseNotFound": "خرید یافت نشد",
|
||||
"awaitingPayment": "در انتظار پرداخت",
|
||||
"awaitingPaymentDesc": "پرداخت را در پنجره باز شده تکمیل کنید",
|
||||
"purchaseSuccess": "پرداخت موفق بود!",
|
||||
"keySentTo": "کلید اشتراک به {{contact}} ارسال شد",
|
||||
"giftSentTo": "هدیه به {{contact}} ارسال شد",
|
||||
"purchaseFailed": "پرداخت ناموفق",
|
||||
"purchaseFailedDesc": "پرداخت انجام نشد. دوباره تلاش کنید.",
|
||||
"subscriptionLink": "لینک اشتراک",
|
||||
"daysAccess": "روز دسترسی",
|
||||
"error": "خطا",
|
||||
"gb": "گیگابایت",
|
||||
"selectedTariff": "تعرفه",
|
||||
"period": "دوره",
|
||||
"total": "مجموع",
|
||||
"pay": "پرداخت",
|
||||
"choosePeriod": "انتخاب دوره",
|
||||
"copied": "کپی شد!",
|
||||
"copyLink": "کپی لینک"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,13 +247,13 @@
|
||||
"connectDevice": "Подключить устройство",
|
||||
"devicesConnected": "{{count}} подключено",
|
||||
"devicesOfMax": "{{used}} из {{max}} подключено",
|
||||
"devicesConnectedUnlimited": "{{used}} подключено · без лимита",
|
||||
"devicesShort": "устр.",
|
||||
"trafficUsage": "{{used}} / {{limit}} ГБ",
|
||||
"trafficUsageTitle": "Расход трафика",
|
||||
"usedTraffic": "использовано {{amount}}",
|
||||
"usedSuffix": "израсходовано",
|
||||
"unlimited": "Безлимит",
|
||||
"unlimitedTraffic": "Безлимитный трафик",
|
||||
"tariff": "Тариф",
|
||||
"validUntil": "до {{date}}",
|
||||
"remaining": "Осталось",
|
||||
@@ -1064,7 +1064,8 @@
|
||||
"roleAssign": "Назначение ролей",
|
||||
"policies": "Политики доступа",
|
||||
"auditLog": "Журнал аудита",
|
||||
"salesStats": "Статистика продаж"
|
||||
"salesStats": "Статистика продаж",
|
||||
"landings": "Лендинги"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -1097,7 +1098,8 @@
|
||||
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
||||
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
||||
"salesStatsDesc": "Аналитика продаж и тренды"
|
||||
"salesStatsDesc": "Аналитика продаж и тренды",
|
||||
"landingsDesc": "Страницы быстрой покупки"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Статистика продаж",
|
||||
@@ -3693,6 +3695,51 @@
|
||||
"loadFailed": "Не удалось загрузить журнал аудита",
|
||||
"retry": "Попробовать снова"
|
||||
}
|
||||
},
|
||||
"landings": {
|
||||
"title": "Лендинги",
|
||||
"create": "Создать лендинг",
|
||||
"edit": "Редактировать",
|
||||
"slug": "URL-идентификатор",
|
||||
"slugHint": "Латиница, цифры и дефис",
|
||||
"pageTitle": "Заголовок",
|
||||
"subtitle": "Подзаголовок",
|
||||
"footerText": "Текст внизу (HTML)",
|
||||
"features": "Преимущества",
|
||||
"addFeature": "Добавить",
|
||||
"featureIcon": "Иконка",
|
||||
"featureTitle": "Заголовок",
|
||||
"featureDesc": "Описание",
|
||||
"tariffs": "Тарифы",
|
||||
"selectTariffs": "Выберите тарифы",
|
||||
"periods": "Периоды",
|
||||
"paymentMethods": "Способы оплаты",
|
||||
"addMethod": "Добавить метод",
|
||||
"methodId": "ID метода",
|
||||
"methodName": "Название",
|
||||
"methodDesc": "Описание",
|
||||
"methodIcon": "URL иконки",
|
||||
"gifts": "Подарки",
|
||||
"giftEnabled": "Покупка в подарок",
|
||||
"customCss": "CSS",
|
||||
"seo": "SEO",
|
||||
"metaTitle": "Meta Title",
|
||||
"metaDesc": "Meta Description",
|
||||
"active": "Активен",
|
||||
"inactive": "Неактивен",
|
||||
"purchases": "покупок",
|
||||
"copyUrl": "Скопировать URL",
|
||||
"urlCopied": "URL скопирован",
|
||||
"deleteConfirm": "Удалить лендинг «{{title}}»?",
|
||||
"created": "Лендинг создан",
|
||||
"updated": "Лендинг обновлён",
|
||||
"deleted": "Лендинг удалён",
|
||||
"saveOrder": "Сохранить порядок",
|
||||
"orderSaved": "Порядок сохранён",
|
||||
"general": "Основное",
|
||||
"content": "Контент",
|
||||
"save": "Сохранить",
|
||||
"back": "Назад"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -4428,5 +4475,44 @@
|
||||
"error": "Ошибка при объединении. Попробуйте позже.",
|
||||
"expiresIn": "Действует ещё {{minutes}}",
|
||||
"merging": "Объединение..."
|
||||
},
|
||||
"landing": {
|
||||
"notFound": "Страница не найдена",
|
||||
"forMe": "Для себя",
|
||||
"asGift": "В подарок",
|
||||
"contactLabel": "Ваш email или @username Telegram",
|
||||
"yourContact": "Ваш email или @username",
|
||||
"contactPlaceholder": "email@example.com или @username",
|
||||
"contactHint": "Введите email или Telegram @username",
|
||||
"recipientLabel": "Кому подарить",
|
||||
"recipientPlaceholder": "email или @username получателя",
|
||||
"giftMessageLabel": "Поздравление (необязательно)",
|
||||
"giftMessagePlaceholder": "Напишите поздравление...",
|
||||
"chooseTariff": "Выберите тариф",
|
||||
"devices": "устройств",
|
||||
"paymentMethod": "Способ оплаты",
|
||||
"processing": "Обработка...",
|
||||
"payButton": "Оплатить {{price}}",
|
||||
"accessFor": "Доступ на {{period}}",
|
||||
"purchaseError": "Ошибка при создании оплаты",
|
||||
"purchaseNotFound": "Покупка не найдена",
|
||||
"awaitingPayment": "Ожидание оплаты",
|
||||
"awaitingPaymentDesc": "Завершите оплату в открывшемся окне",
|
||||
"purchaseSuccess": "Оплата прошла успешно!",
|
||||
"keySentTo": "Ключ подписки отправлен на {{contact}}",
|
||||
"giftSentTo": "Подарок отправлен на {{contact}}",
|
||||
"purchaseFailed": "Ошибка оплаты",
|
||||
"purchaseFailedDesc": "Оплата не прошла. Попробуйте снова.",
|
||||
"subscriptionLink": "Ссылка подписки",
|
||||
"daysAccess": "дней доступа",
|
||||
"error": "Ошибка",
|
||||
"gb": "ГБ",
|
||||
"selectedTariff": "Тариф",
|
||||
"period": "Период",
|
||||
"total": "Итого",
|
||||
"pay": "Оплатить",
|
||||
"choosePeriod": "Выберите период",
|
||||
"copied": "Скопировано!",
|
||||
"copyLink": "Скопировать ссылку"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,7 +866,8 @@
|
||||
"roleAssign": "角色分配",
|
||||
"policies": "访问策略",
|
||||
"auditLog": "审计日志",
|
||||
"salesStats": "销售统计"
|
||||
"salesStats": "销售统计",
|
||||
"landings": "落地页"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -899,7 +900,8 @@
|
||||
"roleAssignDesc": "分配和撤销用户角色",
|
||||
"policiesDesc": "配置ABAC访问控制策略",
|
||||
"auditLogDesc": "查看系统活动和访问历史",
|
||||
"salesStatsDesc": "销售分析与趋势"
|
||||
"salesStatsDesc": "销售分析与趋势",
|
||||
"landingsDesc": "快速购买落地页"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "销售统计",
|
||||
@@ -2863,6 +2865,51 @@
|
||||
"loadFailed": "加载审计日志失败",
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"landings": {
|
||||
"title": "落地页",
|
||||
"create": "创建落地页",
|
||||
"edit": "编辑",
|
||||
"slug": "URL标识符",
|
||||
"slugHint": "小写字母、数字和连字符",
|
||||
"pageTitle": "标题",
|
||||
"subtitle": "副标题",
|
||||
"footerText": "底部文本 (HTML)",
|
||||
"features": "功能特点",
|
||||
"addFeature": "添加",
|
||||
"featureIcon": "图标",
|
||||
"featureTitle": "标题",
|
||||
"featureDesc": "描述",
|
||||
"tariffs": "套餐",
|
||||
"selectTariffs": "选择套餐",
|
||||
"periods": "周期",
|
||||
"paymentMethods": "支付方式",
|
||||
"addMethod": "添加方式",
|
||||
"methodId": "方式ID",
|
||||
"methodName": "名称",
|
||||
"methodDesc": "描述",
|
||||
"methodIcon": "图标URL",
|
||||
"gifts": "礼物",
|
||||
"giftEnabled": "礼物购买",
|
||||
"customCss": "CSS",
|
||||
"seo": "SEO",
|
||||
"metaTitle": "Meta标题",
|
||||
"metaDesc": "Meta描述",
|
||||
"active": "已激活",
|
||||
"inactive": "未激活",
|
||||
"purchases": "次购买",
|
||||
"copyUrl": "复制URL",
|
||||
"urlCopied": "URL已复制",
|
||||
"deleteConfirm": "删除落地页「{{title}}」?",
|
||||
"created": "落地页已创建",
|
||||
"updated": "落地页已更新",
|
||||
"deleted": "落地页已删除",
|
||||
"saveOrder": "保存排序",
|
||||
"orderSaved": "排序已保存",
|
||||
"general": "基本设置",
|
||||
"content": "内容",
|
||||
"save": "保存",
|
||||
"back": "返回"
|
||||
}
|
||||
},
|
||||
"adminUpdates": {
|
||||
@@ -3415,5 +3462,44 @@
|
||||
"error": "合并失败,请稍后重试。",
|
||||
"expiresIn": "剩余 {{minutes}} 分钟",
|
||||
"merging": "合并中..."
|
||||
},
|
||||
"landing": {
|
||||
"notFound": "页面未找到",
|
||||
"forMe": "为自己",
|
||||
"asGift": "作为礼物",
|
||||
"contactLabel": "您的邮箱或 @用户名",
|
||||
"yourContact": "您的邮箱或 @用户名",
|
||||
"contactPlaceholder": "email@example.com 或 @username",
|
||||
"contactHint": "输入邮箱或 Telegram @用户名",
|
||||
"recipientLabel": "赠送对象",
|
||||
"recipientPlaceholder": "收件人邮箱或 @用户名",
|
||||
"giftMessageLabel": "祝福语(可选)",
|
||||
"giftMessagePlaceholder": "写一段祝福语...",
|
||||
"chooseTariff": "选择套餐",
|
||||
"devices": "设备",
|
||||
"paymentMethod": "支付方式",
|
||||
"processing": "处理中...",
|
||||
"payButton": "支付 {{price}}",
|
||||
"accessFor": "{{period}} 访问权限",
|
||||
"purchaseError": "创建支付失败",
|
||||
"purchaseNotFound": "未找到购买记录",
|
||||
"awaitingPayment": "等待支付",
|
||||
"awaitingPaymentDesc": "请在打开的窗口中完成支付",
|
||||
"purchaseSuccess": "支付成功!",
|
||||
"keySentTo": "订阅密钥已发送至 {{contact}}",
|
||||
"giftSentTo": "礼物已发送至 {{contact}}",
|
||||
"purchaseFailed": "支付失败",
|
||||
"purchaseFailedDesc": "支付未成功,请重试。",
|
||||
"subscriptionLink": "订阅链接",
|
||||
"daysAccess": "天访问权限",
|
||||
"error": "错误",
|
||||
"gb": "GB",
|
||||
"selectedTariff": "套餐",
|
||||
"period": "时长",
|
||||
"total": "总计",
|
||||
"pay": "支付",
|
||||
"choosePeriod": "选择时长",
|
||||
"copied": "已复制!",
|
||||
"copyLink": "复制链接"
|
||||
}
|
||||
}
|
||||
|
||||
829
src/pages/AdminLandingEditor.tsx
Normal file
829
src/pages/AdminLandingEditor.tsx
Normal file
@@ -0,0 +1,829 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
adminLandingsApi,
|
||||
LandingCreateRequest,
|
||||
LandingFeature,
|
||||
LandingPaymentMethod,
|
||||
} from '../api/landings';
|
||||
import { tariffsApi, TariffListItem } from '../api/tariffs';
|
||||
import { Toggle } from '../components/admin';
|
||||
import { useNotify } from '@/platform';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GripIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronDownIcon = ({ open }: { open: boolean }) => (
|
||||
<svg
|
||||
className={`h-5 w-5 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Sortable Feature Item ============
|
||||
|
||||
interface SortableFeatureProps {
|
||||
feature: LandingFeature;
|
||||
featureId: string;
|
||||
index: number;
|
||||
onUpdate: (index: number, field: keyof LandingFeature, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableFeatureItem({
|
||||
feature,
|
||||
featureId,
|
||||
index,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: SortableFeatureProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: featureId,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
position: isDragging ? 'relative' : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-start gap-2 rounded-lg border p-3 ${
|
||||
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={feature.icon}
|
||||
onChange={(e) => onUpdate(index, 'icon', e.target.value)}
|
||||
placeholder={t('admin.landings.featureIcon')}
|
||||
className="w-16 rounded-lg border border-dark-700 bg-dark-800 px-2 py-1.5 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={feature.title}
|
||||
onChange={(e) => onUpdate(index, 'title', e.target.value)}
|
||||
placeholder={t('admin.landings.featureTitle')}
|
||||
className="min-w-0 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={feature.description}
|
||||
onChange={(e) => onUpdate(index, 'description', e.target.value)}
|
||||
placeholder={t('admin.landings.featureDesc')}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Sortable Payment Method ============
|
||||
|
||||
interface SortableMethodProps {
|
||||
method: LandingPaymentMethod;
|
||||
methodId: string;
|
||||
index: number;
|
||||
onUpdate: (index: number, field: keyof LandingPaymentMethod, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableMethodItem({ method, methodId, index, onUpdate, onRemove }: SortableMethodProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: methodId,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
position: isDragging ? 'relative' : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-start gap-2 rounded-lg border p-3 ${
|
||||
isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-2 flex-shrink-0 cursor-grab touch-none text-dark-500 hover:text-dark-300 active:cursor-grabbing"
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={method.method_id}
|
||||
onChange={(e) => onUpdate(index, 'method_id', e.target.value)}
|
||||
placeholder={t('admin.landings.methodId')}
|
||||
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={method.display_name}
|
||||
onChange={(e) => onUpdate(index, 'display_name', e.target.value)}
|
||||
placeholder={t('admin.landings.methodName')}
|
||||
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={method.description}
|
||||
onChange={(e) => onUpdate(index, 'description', e.target.value)}
|
||||
placeholder={t('admin.landings.methodDesc')}
|
||||
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={method.icon_url}
|
||||
onChange={(e) => onUpdate(index, 'icon_url', e.target.value)}
|
||||
placeholder={t('admin.landings.methodIcon')}
|
||||
className="rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(index)}
|
||||
className="mt-2 flex-shrink-0 text-dark-500 hover:text-error-400"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Collapsible Section ============
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ title, open, onToggle, children }: SectionProps) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-dark-700 bg-dark-900/50">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm font-medium text-dark-100 hover:bg-dark-800/50"
|
||||
>
|
||||
{title}
|
||||
<ChevronDownIcon open={open} />
|
||||
</button>
|
||||
{open && <div className="border-t border-dark-700 px-4 py-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Editor ============
|
||||
|
||||
export default function AdminLandingEditor() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const queryClient = useQueryClient();
|
||||
const notify = useNotify();
|
||||
const { capabilities } = usePlatform();
|
||||
const isEdit = !!id;
|
||||
|
||||
// Section visibility
|
||||
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
|
||||
general: true,
|
||||
features: false,
|
||||
tariffs: false,
|
||||
methods: false,
|
||||
gifts: false,
|
||||
footer: false,
|
||||
});
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
// Form state
|
||||
const [slug, setSlug] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [subtitle, setSubtitle] = useState('');
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [metaTitle, setMetaTitle] = useState('');
|
||||
const [metaDescription, setMetaDescription] = useState('');
|
||||
const [features, setFeatures] = useState<LandingFeature[]>([]);
|
||||
const [selectedTariffIds, setSelectedTariffIds] = useState<number[]>([]);
|
||||
const [allowedPeriods, setAllowedPeriods] = useState<Record<string, number[]>>({});
|
||||
const [paymentMethods, setPaymentMethods] = useState<LandingPaymentMethod[]>([]);
|
||||
const [giftEnabled, setGiftEnabled] = useState(false);
|
||||
const [footerText, setFooterText] = useState('');
|
||||
const [customCss, setCustomCss] = useState('');
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Fetch tariffs for selection
|
||||
const { data: tariffsData } = useQuery({
|
||||
queryKey: ['admin-tariffs'],
|
||||
queryFn: () => tariffsApi.getTariffs(true),
|
||||
});
|
||||
|
||||
const allTariffs = tariffsData?.tariffs ?? [];
|
||||
|
||||
// Fetch landing for editing
|
||||
const { data: landingData } = useQuery({
|
||||
queryKey: ['admin-landing', id],
|
||||
queryFn: () => adminLandingsApi.get(Number(id)),
|
||||
enabled: isEdit,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// Populate form from fetched data (only once)
|
||||
const formPopulated = useRef(false);
|
||||
|
||||
// Reset formPopulated when navigating to a different landing
|
||||
useEffect(() => {
|
||||
formPopulated.current = false;
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!landingData || formPopulated.current) return;
|
||||
formPopulated.current = true;
|
||||
setSlug(landingData.slug);
|
||||
setTitle(landingData.title);
|
||||
setSubtitle(landingData.subtitle ?? '');
|
||||
setIsActive(landingData.is_active);
|
||||
setMetaTitle(landingData.meta_title ?? '');
|
||||
setMetaDescription(landingData.meta_description ?? '');
|
||||
setFeatures(landingData.features ?? []);
|
||||
setSelectedTariffIds(landingData.allowed_tariff_ids ?? []);
|
||||
setAllowedPeriods(landingData.allowed_periods ?? {});
|
||||
setPaymentMethods(landingData.payment_methods ?? []);
|
||||
setGiftEnabled(landingData.gift_enabled);
|
||||
setFooterText(landingData.footer_text ?? '');
|
||||
setCustomCss(landingData.custom_css ?? '');
|
||||
}, [landingData]);
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: adminLandingsApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||
notify.success(t('admin.landings.created'));
|
||||
navigate('/admin/landings');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail;
|
||||
notify.error(message || t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
// Update mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ landingId, data }: { landingId: number; data: LandingCreateRequest }) =>
|
||||
adminLandingsApi.update(landingId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-landing', id] });
|
||||
notify.success(t('admin.landings.updated'));
|
||||
navigate('/admin/landings');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail;
|
||||
notify.error(message || t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: LandingCreateRequest = {
|
||||
slug,
|
||||
title,
|
||||
subtitle: subtitle || undefined,
|
||||
is_active: isActive,
|
||||
features,
|
||||
footer_text: footerText || undefined,
|
||||
allowed_tariff_ids: selectedTariffIds,
|
||||
allowed_periods: allowedPeriods,
|
||||
payment_methods: paymentMethods,
|
||||
gift_enabled: giftEnabled,
|
||||
custom_css: customCss || undefined,
|
||||
meta_title: metaTitle || undefined,
|
||||
meta_description: metaDescription || undefined,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate({ landingId: Number(id), data });
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// ---- Features helpers ----
|
||||
const addFeature = () => {
|
||||
setFeatures((prev) => [...prev, { icon: '', title: '', description: '' }]);
|
||||
};
|
||||
|
||||
const updateFeature = (index: number, field: keyof LandingFeature, value: string) => {
|
||||
setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, [field]: value } : f)));
|
||||
};
|
||||
|
||||
const removeFeature = (index: number) => {
|
||||
setFeatures((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleFeatureDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setFeatures((prev) => {
|
||||
const oldIndex = prev.findIndex((_, i) => `feature-${i}` === active.id);
|
||||
const newIndex = prev.findIndex((_, i) => `feature-${i}` === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Payment methods helpers ----
|
||||
const addMethod = () => {
|
||||
setPaymentMethods((prev) => [
|
||||
...prev,
|
||||
{
|
||||
method_id: '',
|
||||
display_name: '',
|
||||
description: '',
|
||||
icon_url: '',
|
||||
sort_order: prev.length,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateMethod = (index: number, field: keyof LandingPaymentMethod, value: string) => {
|
||||
setPaymentMethods((prev) => prev.map((m, i) => (i === index ? { ...m, [field]: value } : m)));
|
||||
};
|
||||
|
||||
const removeMethod = (index: number) => {
|
||||
setPaymentMethods((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleMethodDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setPaymentMethods((prev) => {
|
||||
const oldIndex = prev.findIndex((_, i) => `method-${i}` === active.id);
|
||||
const newIndex = prev.findIndex((_, i) => `method-${i}` === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Tariff/period helpers ----
|
||||
const toggleTariff = (tariffId: number) => {
|
||||
setSelectedTariffIds((prev) =>
|
||||
prev.includes(tariffId) ? prev.filter((id) => id !== tariffId) : [...prev, tariffId],
|
||||
);
|
||||
};
|
||||
|
||||
const togglePeriod = (tariffId: number, days: number) => {
|
||||
const key = String(tariffId);
|
||||
setAllowedPeriods((prev) => {
|
||||
const current = prev[key] ?? [];
|
||||
const updated = current.includes(days)
|
||||
? current.filter((d) => d !== days)
|
||||
: [...current, days];
|
||||
return { ...prev, [key]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
// Feature IDs for DnD
|
||||
const featureIds = features.map((_, i) => `feature-${i}`);
|
||||
const methodIds = paymentMethods.map((_, i) => `method-${i}`);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate('/admin/landings')}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
)}
|
||||
<h1 className="text-xl font-semibold text-dark-100">
|
||||
{isEdit ? t('admin.landings.edit') : t('admin.landings.create')}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate('/admin/landings')}
|
||||
className="rounded-lg border border-dark-700 bg-dark-800 px-4 py-2 text-sm text-dark-300 transition-colors hover:border-dark-600"
|
||||
>
|
||||
{t('admin.landings.back')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !slug || !title}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-sm text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
)}
|
||||
{t('admin.landings.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* General Section */}
|
||||
<Section
|
||||
title={t('admin.landings.general')}
|
||||
open={openSections.general}
|
||||
onToggle={() => toggleSection('general')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">{t('admin.landings.slug')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
disabled={isEdit}
|
||||
placeholder="my-landing"
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500 disabled:opacity-50"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">{t('admin.landings.slugHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.pageTitle')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.subtitle')}
|
||||
</label>
|
||||
<textarea
|
||||
value={subtitle}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-dark-400">{t('admin.landings.active')}</label>
|
||||
<Toggle checked={isActive} onChange={() => setIsActive(!isActive)} />
|
||||
</div>
|
||||
|
||||
{/* SEO */}
|
||||
<div className="border-t border-dark-700 pt-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">{t('admin.landings.seo')}</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.metaTitle')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={metaTitle}
|
||||
onChange={(e) => setMetaTitle(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.metaDesc')}
|
||||
</label>
|
||||
<textarea
|
||||
value={metaDescription}
|
||||
onChange={(e) => setMetaDescription(e.target.value)}
|
||||
rows={2}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Features Section */}
|
||||
<Section
|
||||
title={t('admin.landings.features')}
|
||||
open={openSections.features}
|
||||
onToggle={() => toggleSection('features')}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<DndContext sensors={sensors} onDragEnd={handleFeatureDragEnd}>
|
||||
<SortableContext items={featureIds} strategy={verticalListSortingStrategy}>
|
||||
{features.map((feature, index) => (
|
||||
<SortableFeatureItem
|
||||
key={featureIds[index]}
|
||||
feature={feature}
|
||||
featureId={featureIds[index]}
|
||||
index={index}
|
||||
onUpdate={updateFeature}
|
||||
onRemove={removeFeature}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button
|
||||
onClick={addFeature}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-dark-600 px-4 py-2 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:text-dark-300"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.landings.addFeature')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Tariffs Section */}
|
||||
<Section
|
||||
title={t('admin.landings.tariffs')}
|
||||
open={openSections.tariffs}
|
||||
onToggle={() => toggleSection('tariffs')}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-dark-500">{t('admin.landings.selectTariffs')}</p>
|
||||
{allTariffs.map((tariff: TariffListItem) => (
|
||||
<div key={tariff.id} className="rounded-lg border border-dark-700 bg-dark-800/50 p-3">
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTariffIds.includes(tariff.id)}
|
||||
onChange={() => toggleTariff(tariff.id)}
|
||||
className="h-4 w-4 rounded border-dark-600 bg-dark-700 text-accent-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-dark-100">{tariff.name}</span>
|
||||
{!tariff.is_active && (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.landings.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
{/* Period checkboxes if tariff is selected and is not daily */}
|
||||
{selectedTariffIds.includes(tariff.id) && !tariff.is_daily && (
|
||||
<div className="ml-7 mt-2 flex flex-wrap gap-2">
|
||||
<span className="text-xs text-dark-500">{t('admin.landings.periods')}:</span>
|
||||
{/* We show known periods from the tariff detail. Since TariffListItem doesn't have period_prices,
|
||||
we let the user type periods or rely on the backend returning allowed_periods.
|
||||
For simplicity, show the allowed_periods for this tariff if any are set. */}
|
||||
{(allowedPeriods[String(tariff.id)] ?? []).map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
onClick={() => togglePeriod(tariff.id, days)}
|
||||
className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400 hover:bg-accent-500/30"
|
||||
>
|
||||
{days}d<span className="ml-1 text-accent-300">x</span>
|
||||
</button>
|
||||
))}
|
||||
<AddPeriodButton tariffId={tariff.id} onAdd={togglePeriod} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
<Section
|
||||
title={t('admin.landings.paymentMethods')}
|
||||
open={openSections.methods}
|
||||
onToggle={() => toggleSection('methods')}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<DndContext sensors={sensors} onDragEnd={handleMethodDragEnd}>
|
||||
<SortableContext items={methodIds} strategy={verticalListSortingStrategy}>
|
||||
{paymentMethods.map((method, index) => (
|
||||
<SortableMethodItem
|
||||
key={methodIds[index]}
|
||||
method={method}
|
||||
methodId={methodIds[index]}
|
||||
index={index}
|
||||
onUpdate={updateMethod}
|
||||
onRemove={removeMethod}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button
|
||||
onClick={addMethod}
|
||||
className="flex items-center gap-2 rounded-lg border border-dashed border-dark-600 px-4 py-2 text-sm text-dark-400 transition-colors hover:border-dark-500 hover:text-dark-300"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.landings.addMethod')}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Gift Settings Section */}
|
||||
<Section
|
||||
title={t('admin.landings.gifts')}
|
||||
open={openSections.gifts}
|
||||
onToggle={() => toggleSection('gifts')}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm text-dark-400">{t('admin.landings.giftEnabled')}</label>
|
||||
<Toggle checked={giftEnabled} onChange={() => setGiftEnabled(!giftEnabled)} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Footer & Custom CSS Section */}
|
||||
<Section
|
||||
title={t('admin.landings.content')}
|
||||
open={openSections.footer}
|
||||
onToggle={() => toggleSection('footer')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.footerText')}
|
||||
</label>
|
||||
<textarea
|
||||
value={footerText}
|
||||
onChange={(e) => setFooterText(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-400">
|
||||
{t('admin.landings.customCss')}
|
||||
</label>
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => setCustomCss(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-2 font-mono text-sm text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Tiny helper for adding a period ============
|
||||
|
||||
function AddPeriodButton({
|
||||
tariffId,
|
||||
onAdd,
|
||||
}: {
|
||||
tariffId: number;
|
||||
onAdd: (tariffId: number, days: number) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
|
||||
if (!showInput) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
className="rounded border border-dashed border-dark-600 px-2 py-0.5 text-xs text-dark-500 hover:border-dark-500 hover:text-dark-400"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
const days = parseInt(value, 10);
|
||||
if (days > 0) {
|
||||
onAdd(tariffId, days);
|
||||
}
|
||||
setValue('');
|
||||
setShowInput(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const days = parseInt(value, 10);
|
||||
if (days > 0) {
|
||||
onAdd(tariffId, days);
|
||||
}
|
||||
setValue('');
|
||||
setShowInput(false);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setValue('');
|
||||
setShowInput(false);
|
||||
}
|
||||
}}
|
||||
placeholder="30"
|
||||
className="w-16 rounded border border-dark-600 bg-dark-800 px-2 py-0.5 text-xs text-dark-100 outline-none focus:border-accent-500"
|
||||
/>
|
||||
);
|
||||
}
|
||||
457
src/pages/AdminLandings.tsx
Normal file
457
src/pages/AdminLandings.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminLandingsApi, LandingListItem } from '../api/landings';
|
||||
import { useNotify } from '@/platform';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
// Icons
|
||||
const PlusIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GiftIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const GripIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const SaveIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Sortable Landing Card ============
|
||||
|
||||
interface SortableLandingCardProps {
|
||||
landing: LandingListItem;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onToggle: () => void;
|
||||
onCopyUrl: () => void;
|
||||
isPendingDelete?: boolean;
|
||||
}
|
||||
|
||||
function SortableLandingCard({
|
||||
landing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggle,
|
||||
onCopyUrl,
|
||||
isPendingDelete,
|
||||
}: SortableLandingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: landing.id,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
position: isDragging ? 'relative' : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
|
||||
isDragging
|
||||
? 'border-accent-500/50 shadow-xl shadow-accent-500/20'
|
||||
: landing.is_active
|
||||
? 'border-dark-700'
|
||||
: 'border-dark-700/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="mt-1 flex-shrink-0 cursor-grab touch-none rounded-lg p-2.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing sm:p-1.5"
|
||||
title={t('admin.tariffs.dragToReorder')}
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="truncate font-medium text-dark-100">{landing.title}</h3>
|
||||
<span className="rounded bg-dark-800 px-2 py-0.5 text-xs text-dark-400">
|
||||
{landing.slug}
|
||||
</span>
|
||||
{landing.is_active ? (
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.landings.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.landings.inactive')}
|
||||
</span>
|
||||
)}
|
||||
{landing.gift_enabled && (
|
||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
<GiftIcon />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
<span>
|
||||
{landing.purchase_stats.paid} {t('admin.landings.purchases')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCopyUrl}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
title={t('admin.landings.copyUrl')}
|
||||
>
|
||||
<CopyIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
landing.is_active
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||
}`}
|
||||
title={landing.is_active ? t('admin.landings.inactive') : t('admin.landings.active')}
|
||||
>
|
||||
{landing.is_active ? <CheckIcon /> : <XIcon />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
title={t('admin.landings.edit')}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
isPendingDelete
|
||||
? 'bg-error-500/20 text-error-400 ring-1 ring-error-500/30'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-error-500/20 hover:text-error-400'
|
||||
}`}
|
||||
title={
|
||||
isPendingDelete
|
||||
? t('admin.landings.deleteConfirm', { title: landing.title })
|
||||
: t('common.delete')
|
||||
}
|
||||
>
|
||||
{isPendingDelete ? (
|
||||
<span className="px-1 text-xs font-medium">{t('common.delete')}?</span>
|
||||
) : (
|
||||
<TrashIcon />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminLandings() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const notify = useNotify();
|
||||
const { capabilities } = usePlatform();
|
||||
|
||||
const [localLandings, setLocalLandings] = useState<LandingListItem[]>([]);
|
||||
const [orderChanged, setOrderChanged] = useState(false);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<number | null>(null);
|
||||
const deleteTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Queries
|
||||
const { data: landingsData, isLoading } = useQuery({
|
||||
queryKey: ['admin-landings'],
|
||||
queryFn: () => adminLandingsApi.list(),
|
||||
});
|
||||
|
||||
// Sync fetched data to local state
|
||||
useEffect(() => {
|
||||
if (landingsData && !orderChanged) {
|
||||
setLocalLandings(landingsData);
|
||||
}
|
||||
}, [landingsData, orderChanged]);
|
||||
|
||||
// Save order mutation
|
||||
const saveOrderMutation = useMutation({
|
||||
mutationFn: (landingIds: number[]) => adminLandingsApi.reorder(landingIds),
|
||||
onSuccess: () => {
|
||||
setOrderChanged(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||
notify.success(t('admin.landings.orderSaved'));
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail;
|
||||
notify.error(message || t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: adminLandingsApi.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||
notify.success(t('admin.landings.deleted'));
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail;
|
||||
notify.error(message || t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = (landing: LandingListItem) => {
|
||||
if (pendingDeleteId === landing.id) {
|
||||
deleteMutation.mutate(landing.id);
|
||||
setPendingDeleteId(null);
|
||||
} else {
|
||||
setPendingDeleteId(landing.id);
|
||||
deleteTimeoutRef.current = setTimeout(
|
||||
() => setPendingDeleteId((prev) => (prev === landing.id ? null : prev)),
|
||||
3000,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: adminLandingsApi.toggle,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-landings'] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const message = (err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail;
|
||||
notify.error(message || t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyUrl = async (slug: string) => {
|
||||
const url = `${window.location.origin}/buy/${slug}`;
|
||||
try {
|
||||
await copyToClipboard(url);
|
||||
notify.success(t('admin.landings.urlCopied'));
|
||||
} catch {
|
||||
// Clipboard write failed silently
|
||||
}
|
||||
};
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setLocalLandings((prev) => {
|
||||
const oldIndex = prev.findIndex((l) => l.id === active.id);
|
||||
const newIndex = prev.findIndex((l) => l.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
setOrderChanged(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveOrder = () => {
|
||||
saveOrderMutation.mutate(localLandings.map((l) => l.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Show back button only on web, not in Telegram Mini App */}
|
||||
{!capabilities.hasBackButton && (
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.landings.title')}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{orderChanged && (
|
||||
<button
|
||||
onClick={handleSaveOrder}
|
||||
disabled={saveOrderMutation.isPending}
|
||||
className="flex items-center gap-2 rounded-lg bg-success-500 px-4 py-2 text-white transition-colors hover:bg-success-600"
|
||||
>
|
||||
{saveOrderMutation.isPending ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<SaveIcon />
|
||||
)}
|
||||
{t('admin.landings.saveOrder')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/admin/landings/create')}
|
||||
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
{t('admin.landings.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
|
||||
<GripIcon />
|
||||
{t('admin.tariffs.dragToReorder')}
|
||||
</div>
|
||||
|
||||
{/* Landings List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : localLandings.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">{t('common.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={localLandings.map((l) => l.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{localLandings.map((landing) => (
|
||||
<SortableLandingCard
|
||||
key={landing.id}
|
||||
landing={landing}
|
||||
onEdit={() => navigate(`/admin/landings/${landing.id}/edit`)}
|
||||
onDelete={() => handleDelete(landing)}
|
||||
onToggle={() => toggleMutation.mutate(landing.id)}
|
||||
onCopyUrl={() => handleCopyUrl(landing.slug)}
|
||||
isPendingDelete={pendingDeleteId === landing.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,6 +304,16 @@ const ClipboardDocumentListIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RectangleGroupIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface AdminItem {
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -499,6 +509,13 @@ export default function AdminPanel() {
|
||||
description: t('admin.panel.paymentMethodsDesc'),
|
||||
permission: 'payment_methods:read',
|
||||
},
|
||||
{
|
||||
to: '/admin/landings',
|
||||
icon: <RectangleGroupIcon />,
|
||||
title: t('admin.nav.landings'),
|
||||
description: t('admin.panel.landingsDesc'),
|
||||
permission: 'landings:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
283
src/pages/PurchaseSuccess.tsx
Normal file
283
src/pages/PurchaseSuccess.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { landingApi } from '../api/landings';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
// ============================================================
|
||||
// Sub-components
|
||||
// ============================================================
|
||||
|
||||
function Spinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-2 border-dark-600 border-t-accent-500',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
<Spinner className="h-16 w-16 border-[3px]" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-50">
|
||||
{t('landing.awaitingPayment', 'Awaiting payment')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-dark-400">{t('landing.awaitingPaymentDesc')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessState({
|
||||
subscriptionUrl,
|
||||
cryptoLink,
|
||||
contactValue,
|
||||
tariffName,
|
||||
periodDays,
|
||||
isGift,
|
||||
}: {
|
||||
subscriptionUrl: string | null;
|
||||
cryptoLink: string | null;
|
||||
contactValue: string | null;
|
||||
tariffName: string | null;
|
||||
periodDays: number | null;
|
||||
isGift: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const url = subscriptionUrl ?? cryptoLink;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
await copyToClipboard(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard write failed silently
|
||||
}
|
||||
}, [subscriptionUrl, cryptoLink]);
|
||||
|
||||
const displayUrl = subscriptionUrl ?? cryptoLink;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
{/* Animated checkmark */}
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 200, damping: 15, delay: 0.1 }}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-full bg-success-500/10"
|
||||
>
|
||||
<motion.svg
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.3 }}
|
||||
className="h-10 w-10 text-success-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<motion.path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: 1 }}
|
||||
transition={{ duration: 0.4, delay: 0.3 }}
|
||||
/>
|
||||
</motion.svg>
|
||||
</motion.div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseSuccess')}</h1>
|
||||
{tariffName && periodDays && (
|
||||
<p className="mt-1 text-sm text-dark-300">
|
||||
{tariffName} — {periodDays} {t('landing.daysAccess')}
|
||||
</p>
|
||||
)}
|
||||
{contactValue && (
|
||||
<p className="mt-2 text-sm text-dark-400">
|
||||
{isGift
|
||||
? t('landing.giftSentTo', { contact: contactValue })
|
||||
: t('landing.keySentTo', { contact: contactValue })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
{displayUrl && (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-white p-5">
|
||||
<QRCodeSVG
|
||||
value={displayUrl}
|
||||
size={200}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
className="h-[200px] w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Copy button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-xl px-4 py-3 text-sm font-medium transition-all duration-200',
|
||||
copied
|
||||
? 'bg-success-500/10 text-success-500'
|
||||
: 'bg-dark-800/50 text-dark-200 hover:bg-dark-700/50',
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{t('landing.copied', 'Copied!')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
{t('landing.copyLink', 'Copy link')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-error-500/10">
|
||||
<svg
|
||||
className="h-10 w-10 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-50">{t('landing.purchaseFailed')}</h1>
|
||||
<p className="mt-2 text-sm text-dark-400">{t('landing.purchaseFailedDesc')}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Main Component
|
||||
// ============================================================
|
||||
|
||||
export default function PurchaseSuccess() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const pollStart = useRef(Date.now());
|
||||
|
||||
// Referrer-Policy: prevent leaking payment token via referer header
|
||||
useEffect(() => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'referrer';
|
||||
meta.content = 'no-referrer';
|
||||
document.head.appendChild(meta);
|
||||
return () => {
|
||||
document.head.removeChild(meta);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: status, isError } = useQuery({
|
||||
queryKey: ['purchase-status', token],
|
||||
queryFn: () => landingApi.getPurchaseStatus(token!),
|
||||
enabled: !!token,
|
||||
refetchInterval: (query) => {
|
||||
const currentStatus = query.state.data?.status;
|
||||
if (currentStatus === 'pending' || currentStatus === 'paid') {
|
||||
if (Date.now() - pollStart.current > MAX_POLL_MS) {
|
||||
return false;
|
||||
}
|
||||
return 3_000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
const isSuccess = status?.status === 'delivered';
|
||||
const isFailed = status?.status === 'failed' || status?.status === 'expired';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-8">
|
||||
{isError ? (
|
||||
<FailedState />
|
||||
) : isSuccess ? (
|
||||
<SuccessState
|
||||
subscriptionUrl={status.subscription_url}
|
||||
cryptoLink={status.subscription_crypto_link}
|
||||
contactValue={status.contact_value}
|
||||
tariffName={status.tariff_name}
|
||||
periodDays={status.period_days}
|
||||
isGift={status.is_gift}
|
||||
/>
|
||||
) : isFailed ? (
|
||||
<FailedState />
|
||||
) : (
|
||||
<PendingState />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
831
src/pages/QuickPurchase.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -772,7 +772,9 @@ export default function SubscriptionPurchase() {
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-dark-300">
|
||||
{t('subscription.devices', { count: tariff.device_limit })}
|
||||
{tariff.device_limit === 0
|
||||
? '∞'
|
||||
: t('subscription.devices', { count: tariff.device_limit })}
|
||||
</span>
|
||||
</div>
|
||||
{tariff.traffic_reset_mode &&
|
||||
@@ -959,7 +961,7 @@ export default function SubscriptionPurchase() {
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.devices')}:</span>
|
||||
<span className="ml-2 text-dark-200">
|
||||
{selectedTariff.device_limit}
|
||||
{selectedTariff.device_limit === 0 ? '∞' : selectedTariff.device_limit}
|
||||
{selectedTariff.extra_devices_count > 0 && (
|
||||
<span className="ml-1 text-xs text-accent-400">
|
||||
(+{selectedTariff.extra_devices_count})
|
||||
|
||||
Reference in New Issue
Block a user