@@ -415,6 +471,7 @@ export function AppShell({ children }: AppShellProps) {
referralEnabled={referralEnabled}
hasContests={hasContests}
hasPolls={hasPolls}
+ giftEnabled={giftEnabled}
/>
{/* Desktop spacer */}
diff --git a/src/components/layout/AppShell/icons.tsx b/src/components/layout/AppShell/icons.tsx
index d8678c9..01c09b2 100644
--- a/src/components/layout/AppShell/icons.tsx
+++ b/src/components/layout/AppShell/icons.tsx
@@ -16,6 +16,7 @@ export {
InfoIcon,
CogIcon,
WheelIcon,
+ GiftIcon,
SearchIcon,
PlusIcon,
ArrowRightIcon,
diff --git a/src/components/ui/AnimatedCheckmark.tsx b/src/components/ui/AnimatedCheckmark.tsx
new file mode 100644
index 0000000..461d59d
--- /dev/null
+++ b/src/components/ui/AnimatedCheckmark.tsx
@@ -0,0 +1,37 @@
+import { motion } from 'framer-motion';
+import { cn } from '@/lib/utils';
+
+export function AnimatedCheckmark({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/AnimatedCrossmark.tsx b/src/components/ui/AnimatedCrossmark.tsx
new file mode 100644
index 0000000..895c7be
--- /dev/null
+++ b/src/components/ui/AnimatedCrossmark.tsx
@@ -0,0 +1,37 @@
+import { motion } from 'framer-motion';
+import { cn } from '@/lib/utils';
+
+export function AnimatedCrossmark({ className }: { className?: string }) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Spinner.tsx b/src/components/ui/Spinner.tsx
new file mode 100644
index 0000000..607c9a1
--- /dev/null
+++ b/src/components/ui/Spinner.tsx
@@ -0,0 +1,17 @@
+import { useTranslation } from 'react-i18next';
+import { cn } from '@/lib/utils';
+
+export function Spinner({ className }: { className?: string }) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts
index 356d357..8da43e4 100644
--- a/src/hooks/useFeatureFlags.ts
+++ b/src/hooks/useFeatureFlags.ts
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
+import { brandingApi } from '@/api/branding';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
@@ -40,10 +41,19 @@ export function useFeatureFlags() {
retry: false,
});
+ const { data: giftConfig } = useQuery({
+ queryKey: ['gift-enabled'],
+ queryFn: brandingApi.getGiftEnabled,
+ enabled: isAuthenticated,
+ staleTime: 60000,
+ retry: false,
+ });
+
return {
referralEnabled: referralTerms?.is_enabled,
wheelEnabled: wheelConfig?.is_enabled,
hasContests: (contestsCount?.count ?? 0) > 0,
hasPolls: (pollsCount?.count ?? 0) > 0,
+ giftEnabled: giftConfig?.enabled,
};
}
diff --git a/src/locales/en.json b/src/locales/en.json
index edd7a08..67f6f2b 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -44,7 +44,8 @@
"polls": "Polls",
"info": "Info",
"wheel": "Fortune Wheel",
- "menu": "Menu"
+ "menu": "Menu",
+ "gift": "Gift"
},
"notifications": {
"ticketNotifications": "Ticket Notifications",
@@ -235,13 +236,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",
@@ -254,11 +255,20 @@
"trialSubtitle": "Trial period ended",
"paidSubtitle": "Subscription has expired",
"renew": "Renew Subscription",
+ "quickRenew": "Quick Renew",
+ "insufficientFunds": "Insufficient balance",
+ "renewError": "Renewal error, try again",
+ "topUp": "Top up balance",
+ "balance": "Balance",
"tariffs": "Tariffs",
"traffic": "Traffic",
"devices": "Devices",
"expiredDate": "Expired"
},
+ "suspended": {
+ "title": "Subscription Suspended",
+ "resume": "Resume"
+ },
"trialOffer": {
"freeTitle": "Free Trial Available",
"paidTitle": "Trial Subscription",
@@ -336,6 +346,9 @@
"daysBeforeExpiry_one": "{{count}} day before expiry",
"daysBeforeExpiry_other": "{{count}} days before expiry",
"selectPeriod": "Select Period",
+ "noPeriodsAvailable": "No available periods for renewal",
+ "noPeriodsAvailableHint": "The administrator has changed this plan's settings. Please choose a different plan to renew your subscription.",
+ "chooseDifferentTariff": "Choose a different plan",
"selectTraffic": "Select Traffic",
"selectServers": "Select Servers",
"selectDevices": "Devices",
@@ -446,6 +459,7 @@
"pause": {
"title": "Subscription Pause",
"paused": "Paused",
+ "suspended": "Suspended (insufficient funds)",
"active": "Active",
"pauseBtn": "Pause",
"resumeBtn": "Resume",
@@ -690,6 +704,19 @@
"paymentSuccess": {
"title": "Payment Successful",
"message": "Your balance has been topped up successfully. The funds are now available."
+ },
+ "topUpResult": {
+ "awaitingPayment": "Awaiting Payment",
+ "awaitingPaymentDesc": "We are waiting for your payment confirmation. This may take a few minutes.",
+ "topUpAmount": "Top-up amount",
+ "success": "Balance Topped Up!",
+ "successDesc": "Your balance has been topped up successfully. The funds are now available.",
+ "failed": "Payment Failed",
+ "failedDesc": "Unfortunately, the payment was not completed. Please try again or choose a different payment method.",
+ "timeout": "Taking Longer Than Expected",
+ "timeoutDesc": "Payment processing is taking longer than usual. You can try checking the status again.",
+ "goToBalance": "Go to Balance",
+ "tryAgain": "Try Again"
}
},
"referral": {
@@ -1056,7 +1083,8 @@
"roleAssign": "Role Assignment",
"policies": "Access Policies",
"auditLog": "Audit Log",
- "salesStats": "Sales Statistics"
+ "salesStats": "Sales Statistics",
+ "landings": "Landings"
},
"panel": {
"title": "Admin Panel",
@@ -1089,7 +1117,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",
@@ -1683,6 +1712,8 @@
"darkTheme": "Dark theme",
"emailAuth": "Email auth",
"emailAuthDesc": "Allow login via email",
+ "giftEnabled": "Gift subscription",
+ "giftEnabledDesc": "Allow users to gift a subscription to another user",
"favoritesEmpty": "No favorites yet",
"favoritesHint": "Star settings to add them here",
"interfaceOptions": "Interface options",
@@ -1692,7 +1723,20 @@
"projectName": "Project name",
"quickPresets": "Quick presets",
"resetAllColors": "Reset all colors",
- "statusColors": "Status colors"
+ "statusColors": "Status colors",
+ "categories": {
+ "TELEGRAM_WIDGET": "Telegram Login Widget",
+ "TELEGRAM_OIDC": "Telegram Login (OIDC)"
+ },
+ "settingNames": {
+ "Telegram Widget Size": "Widget Size",
+ "Telegram Widget Radius": "Corner Radius",
+ "Telegram Widget Userpic": "Show User Photo",
+ "Telegram Widget Request Access": "Request Access",
+ "telegramOidcEnabled": "OIDC Enabled",
+ "telegramOidcClientId": "Client ID (Bot ID)",
+ "telegramOidcClientSecret": "Client Secret"
+ }
},
"theme": {
"accentColor": "Accent color",
@@ -1720,7 +1764,8 @@
"referral": "Referrals",
"support": "Support",
"info": "Info",
- "admin": "Admin Panel"
+ "admin": "Admin Panel",
+ "language": "Language"
},
"descriptions": {
"home": "Personal cabinet button",
@@ -1729,7 +1774,8 @@
"referral": "Referral program",
"support": "Support tickets",
"info": "Information section",
- "admin": "Web admin panel"
+ "admin": "Web admin panel",
+ "language": "Language selection"
},
"styles": {
"default": "No color",
@@ -1738,6 +1784,26 @@
"danger": "Red"
}
},
+ "menuEditor": {
+ "dragHint": "Drag rows to reorder them",
+ "dragToReorder": "Drag to reorder",
+ "row": "Row",
+ "addRow": "Add row",
+ "addButton": "Add button",
+ "addUrlButton": "URL button (link)",
+ "builtinButtons": "Built-in buttons",
+ "resetConfirm": "Reset menu layout to defaults?",
+ "buttonTextPlaceholder": "Custom button text",
+ "customLabelsHint": "Empty = default label",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
+ "openIn": "Open in",
+ "openMode": {
+ "external": "External browser",
+ "webapp": "Telegram miniapp"
+ },
+ "invalidUrl": "Please provide a valid URL (http:// or https://) for all custom buttons"
+ },
"apps": {
"title": "App Management",
"addApp": "Add App",
@@ -1921,6 +1987,10 @@
"noPeriodsHint": "No added periods. Add at least one period.",
"daysShort": "d.",
"serversTitle": "Servers",
+ "externalSquadTitle": "External Squad",
+ "externalSquadHint": "Assign a RemnaWave external squad for users on this tariff. When a subscription is created, the user will be automatically added to the selected squad.",
+ "noExternalSquad": "No external squad",
+ "externalSquadUsers": "users",
"serversTabHint": "Select servers available on this tariff.",
"noServersAvailable": "No servers available",
"promoGroupsTitle": "Promo Groups",
@@ -2025,6 +2095,7 @@
"subtitle": "Manage advertising links",
"createButton": "Create",
"noData": "No ad campaigns",
+ "loadMore": "Load more",
"bonusType": {
"balance": "Balance",
"subscription": "Subscription",
@@ -2494,6 +2565,7 @@
"form": {
"name": "Group name",
"namePlaceholder": "VIP customers",
+ "nameRequired": "Enter group name",
"categoryDiscounts": "Category discounts",
"periodDiscounts": "Period discounts",
"add": "Add",
@@ -2505,6 +2577,7 @@
"rub": "rub.",
"autoAssignHint": "0 = don't auto-assign",
"applyToAddons": "Apply to additional services",
+ "isDefault": "Default group",
"cancel": "Cancel",
"saving": "Saving...",
"save": "Save"
@@ -2880,6 +2953,7 @@
"apps": "Apps",
"email_templates": "Email templates",
"pinned_messages": "Pinned messages",
+ "landings": "Landing pages",
"updates": "Updates"
},
"permissionActions": {
@@ -3091,6 +3165,7 @@
"filters": {
"title": "Filters",
"active": "Active",
+ "user": "User",
"action": "Action",
"actionPlaceholder": "Search by action...",
"resource": "Resource type",
@@ -3152,6 +3227,121 @@
"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",
+ "purchaseCount": "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",
+ "invalidSlug": "Slug can only contain lowercase letters, numbers, and hyphens",
+ "titleRequired": "Title is required",
+ "noTariffs": "Select at least one tariff",
+ "noPaymentMethods": "Add at least one payment method",
+ "selectMethods": "Select available methods",
+ "noSystemMethods": "No payment methods configured in the system",
+ "methodOrder": "Drag to reorder",
+ "methodSubOptions": "Payment sub-options",
+ "loadingPeriods": "Loading...",
+ "periodDaySuffix": "d",
+ "localeTab": "Language",
+ "localeHint": "Switch language to edit text in that language",
+ "discount": "Discount",
+ "discountEnabled": "Enable discount",
+ "discountPercent": "Discount %",
+ "discountStartsAt": "Start date",
+ "discountEndsAt": "End date",
+ "discountBadge": "Banner text (optional)",
+ "discountBadgePlaceholder": "e.g. Spring sale!",
+ "discountOverrides": "Per-tariff overrides",
+ "discountOverridesHint": "Leave empty to use global discount",
+ "discountPreview": "Preview",
+ "discountActive": "Discount",
+ "statistics": "Statistics",
+ "stats": {
+ "title": "Landing Statistics",
+ "totalPurchases": "Purchases",
+ "revenue": "Revenue",
+ "giftPurchases": "Gifts",
+ "regularPurchases": "Regular",
+ "conversionRate": "Conversion",
+ "avgPurchase": "Avg. Check",
+ "dailyChart": "Purchases & Revenue by Day",
+ "tariffChart": "Tariff Distribution",
+ "giftBreakdown": "Gifts vs Regular",
+ "purchases": "Purchases",
+ "revenueLabel": "Revenue",
+ "gifts": "Gifts",
+ "regular": "Regular",
+ "created": "Created",
+ "successful": "Successful",
+ "funnel": "Funnel",
+ "loadError": "Failed to load statistics",
+ "noPurchases": "No purchases"
+ },
+ "purchases": {
+ "title": "Purchases",
+ "contact": "Contact",
+ "recipient": "Recipient",
+ "tariff": "Tariff",
+ "period": "Period",
+ "days": "days",
+ "price": "Price",
+ "method": "Method",
+ "date": "Date",
+ "gift": "Gift",
+ "forSelf": "For self",
+ "allStatuses": "All statuses",
+ "status_pending": "Pending",
+ "status_paid": "Paid",
+ "status_delivered": "Delivered",
+ "status_pending_activation": "Pending activation",
+ "status_failed": "Failed",
+ "status_expired": "Expired",
+ "noPurchases": "No purchases",
+ "showing": "Showing {{from}}–{{to}} of {{total}}",
+ "page": "Page {{current}} of {{total}}",
+ "prev": "Previous",
+ "next": "Next"
+ }
}
},
"adminUpdates": {
@@ -3879,5 +4069,152 @@
"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",
+ "copy": "Copy",
+ "copied": "Copied!",
+ "copyLink": "Copy link",
+ "giftToggleLabel": "Purchase type",
+ "pollTimedOut": "Taking longer than expected",
+ "pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again.",
+ "pendingActivation": "Subscription ready to activate",
+ "pendingActivationDesc": "You already have an active subscription. Activating will replace it.",
+ "activateNow": "Activate now",
+ "activating": "Activating...",
+ "activationFailed": "Activation failed",
+ "giftMessage": "Message",
+ "cabinetReady": "Your account is ready",
+ "cabinetEmail": "Email",
+ "cabinetPassword": "Password",
+ "goToCabinet": "Go to Cabinet",
+ "saveCredentials": "Save these login credentials",
+ "credentialsSentToEmail": "Login credentials sent to your email",
+ "giftSentSuccess": "Gift sent!",
+ "giftSentDesc": "Recipient will be notified by email",
+ "giftPendingActivationDesc": "The recipient already has an active subscription. They will receive a link to activate the gift.",
+ "giftTelegramSent": "Gift notification sent to recipient via Telegram",
+ "giftTelegramNotInBot": "Recipient hasn't started the bot yet. Send them this link:",
+ "giftTelegramPendingSent": "Activation request sent to recipient via Telegram",
+ "giftTelegramPendingNotInBot": "Recipient hasn't started the bot yet. Send them the bot link so they can activate the gift:",
+ "openBot": "Open bot",
+ "autoLoginFailed": "Auto-login failed",
+ "autoLoginProcessing": "Signing in...",
+ "discount": {
+ "days": "d",
+ "hours": "h",
+ "minutes": "m",
+ "seconds": "s"
+ },
+ "periodLabels": {
+ "d1": "1 day",
+ "d2": "2 days",
+ "d3": "3 days",
+ "d5": "5 days",
+ "d7": "1 week",
+ "d14": "2 weeks",
+ "d30": "1 month",
+ "d60": "2 months",
+ "d90": "3 months",
+ "d180": "6 months",
+ "d365": "1 year",
+ "d456": "1 year + 3 mo.",
+ "nDays_one": "{{count}} day",
+ "nDays_other": "{{count}} days",
+ "nMonths_one": "{{count}} month",
+ "nMonths_other": "{{count}} months"
+ }
+ },
+ "gift": {
+ "title": "Gift Subscription",
+ "subtitle": "Send a VPN subscription as a gift",
+ "choosePeriod": "Choose period",
+ "chooseTariff": "Choose tariff",
+ "recipient": "Recipient",
+ "recipientPlaceholder": "Email or @telegram",
+ "recipientHint": "Enter email or Telegram username",
+ "giftMessage": "Greeting",
+ "giftMessagePlaceholder": "Add a personal message (optional)",
+ "paymentMode": "Payment method",
+ "fromBalance": "From balance",
+ "viaGateway": "Via payment gateway",
+ "yourBalance": "Your balance",
+ "insufficientBalance": "Insufficient funds",
+ "topUpBalance": "Top up balance",
+ "total": "Total",
+ "giftButton": "Send Gift",
+ "sending": "Sending gift...",
+ "gb": "GB",
+ "devices": "devices",
+ "paymentMethod": "Payment method",
+ "processing": "Processing...",
+ "successTitle": "Gift sent!",
+ "successDesc": "Recipient {{contact}} will be notified",
+ "pendingTitle": "Awaiting payment",
+ "pendingDesc": "Complete the payment in the payment system",
+ "pendingActivationTitle": "Pending activation",
+ "pendingActivationDesc": "Recipient has an active subscription. Gift is pending activation.",
+ "failedTitle": "Error",
+ "failedDesc": "Failed to send gift. Please try again.",
+ "backToGift": "Go back",
+ "backToDashboard": "Back to dashboard",
+ "tryAgain": "Try again",
+ "pollTimeout": "Processing is taking longer than usual",
+ "pollTimeoutDesc": "Try checking the status later",
+ "pollErrorTitle": "Could not check gift status",
+ "pollErrorDesc": "Your purchase was successful. Check your dashboard for details.",
+ "retry": "Check again",
+ "notFound": "Gift configuration not found",
+ "noToken": "Invalid link",
+ "noTokenDesc": "This gift link is invalid or has expired.",
+ "days": "days",
+ "tariff": "Tariff",
+ "period": "Period",
+ "giftMessageLabel": "Message",
+ "recipientLabel": "Recipient",
+ "featureDisabled": "Gift feature is temporarily unavailable",
+ "redirecting": "Redirecting...",
+ "pending": {
+ "title": "You received a gift!",
+ "from": "from {{sender}}",
+ "activate": "Activate"
+ },
+ "warning": {
+ "telegram_unresolvable": "Could not verify this Telegram username. The recipient may not receive a notification about the gift."
+ }
}
}
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 40e443d..6adf80a 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -44,7 +44,8 @@
"polls": "نظرسنجی",
"info": "اطلاعات",
"wheel": "چرخ شانس",
- "menu": "منو"
+ "menu": "منو",
+ "gift": "هدیه"
},
"notifications": {
"ticketNotifications": "اعلانهای تیکت",
@@ -219,7 +220,27 @@
"noActiveSubscription": "اشتراک فعال ندارید",
"devicesUsed": "دستگاهها: {{used}} از {{total}}",
"trafficUsed": "ترافیک: {{used}} از {{total}} گیگ",
- "unlimitedTraffic": "ترافیک نامحدود"
+ "unlimitedTraffic": "ترافیک نامحدود",
+ "expired": {
+ "title": "اشتراک منقضی شده",
+ "trialTitle": "دوره آزمایشی منقضی شده",
+ "trialSubtitle": "دوره آزمایشی پایان یافته",
+ "paidSubtitle": "اشتراک منقضی شده",
+ "renew": "تمدید اشتراک",
+ "quickRenew": "تمدید سریع",
+ "insufficientFunds": "موجودی ناکافی",
+ "renewError": "خطا در تمدید، دوباره تلاش کنید",
+ "topUp": "شارژ موجودی",
+ "balance": "موجودی",
+ "tariffs": "تعرفهها",
+ "traffic": "ترافیک",
+ "devices": "دستگاهها",
+ "expiredDate": "منقضی شده"
+ },
+ "suspended": {
+ "title": "اشتراک معلق شده",
+ "resume": "از سرگیری"
+ }
},
"subscription": {
"title": "اشتراک",
@@ -276,6 +297,9 @@
"daysBeforeExpiry": "روز قبل از انقضا",
"daysBeforeExpiry_other": "{{count}} روز قبل از انقضا",
"selectPeriod": "انتخاب دوره",
+ "noPeriodsAvailable": "دورهای برای تمدید موجود نیست",
+ "noPeriodsAvailableHint": "مدیر تنظیمات این طرح را تغییر داده است. لطفاً طرح دیگری را برای تمدید اشتراک خود انتخاب کنید.",
+ "chooseDifferentTariff": "انتخاب طرح دیگر",
"selectTraffic": "انتخاب ترافیک",
"selectServers": "انتخاب سرورها",
"selectDevices": "انتخاب دستگاهها",
@@ -428,6 +452,7 @@
"nextCharge": "تا کسر بعدی",
"pauseBtn": "توقف",
"paused": "متوقف شده",
+ "suspended": "معلق شده (موجودی ناکافی)",
"pausedDescription": "کسر متوقف شد. اشتراک فعال خواهد بود تا",
"pausedInfo": "اشتراک متوقف شده",
"resumeBtn": "ادامه",
@@ -530,6 +555,19 @@
"paymentSuccess": {
"title": "پرداخت موفق",
"message": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است."
+ },
+ "topUpResult": {
+ "awaitingPayment": "در انتظار پرداخت",
+ "awaitingPaymentDesc": "ما منتظر تأیید پرداخت شما هستیم. این ممکن است چند دقیقه طول بکشد.",
+ "topUpAmount": "مبلغ شارژ",
+ "success": "شارژ موفق!",
+ "successDesc": "موجودی شما با موفقیت شارژ شد. وجوه اکنون در دسترس است.",
+ "failed": "پرداخت ناموفق",
+ "failedDesc": "متأسفانه پرداخت تکمیل نشد. لطفاً دوباره تلاش کنید یا روش پرداخت دیگری انتخاب کنید.",
+ "timeout": "بیشتر از حد معمول طول کشید",
+ "timeoutDesc": "پردازش پرداخت بیشتر از حد معمول طول میکشد. میتوانید دوباره وضعیت را بررسی کنید.",
+ "goToBalance": "مشاهده موجودی",
+ "tryAgain": "تلاش مجدد"
}
},
"referral": {
@@ -885,7 +923,8 @@
"roleAssign": "تخصیص نقش",
"policies": "سیاستهای دسترسی",
"auditLog": "گزارش بازرسی",
- "salesStats": "آمار فروش"
+ "salesStats": "آمار فروش",
+ "landings": "صفحات فرود"
},
"panel": {
"title": "پنل مدیریت",
@@ -918,7 +957,8 @@
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
- "salesStatsDesc": "تحلیل فروش و روندها"
+ "salesStatsDesc": "تحلیل فروش و روندها",
+ "landingsDesc": "صفحات خرید سریع"
},
"salesStats": {
"title": "آمار فروش",
@@ -1349,6 +1389,8 @@
"darkTheme": "پوسته تیره",
"emailAuth": "احراز هویت ایمیل",
"emailAuthDesc": "اجازه ورود با ایمیل",
+ "giftEnabled": "اشتراک هدیه",
+ "giftEnabledDesc": "امکان ارسال اشتراک به عنوان هدیه به کاربر دیگر",
"favoritesEmpty": "هنوز علاقهمندی ندارید",
"favoritesHint": "ستاره بزنید تا تنظیمات اینجا اضافه شوند",
"interfaceOptions": "گزینههای رابط کاربری",
@@ -1358,7 +1400,20 @@
"projectName": "نام پروژه",
"quickPresets": "پیشتنظیمهای سریع",
"resetAllColors": "بازنشانی همه رنگها",
- "statusColors": "رنگهای وضعیت"
+ "statusColors": "رنگهای وضعیت",
+ "categories": {
+ "TELEGRAM_WIDGET": "Telegram Login Widget",
+ "TELEGRAM_OIDC": "Telegram Login (OIDC)"
+ },
+ "settingNames": {
+ "Telegram Widget Size": "اندازه ویجت",
+ "Telegram Widget Radius": "شعاع گوشه",
+ "Telegram Widget Userpic": "نمایش تصویر کاربر",
+ "Telegram Widget Request Access": "درخواست دسترسی",
+ "telegramOidcEnabled": "فعالسازی OIDC",
+ "telegramOidcClientId": "Client ID (شناسه ربات)",
+ "telegramOidcClientSecret": "Client Secret"
+ }
},
"buttons": {
"color": "رنگ دکمه",
@@ -1377,7 +1432,8 @@
"referral": "معرفی",
"support": "پشتیبانی",
"info": "اطلاعات",
- "admin": "پنل مدیریت"
+ "admin": "پنل مدیریت",
+ "language": "زبان"
},
"descriptions": {
"home": "دکمه کابینت شخصی",
@@ -1386,7 +1442,8 @@
"referral": "برنامه معرفی",
"support": "تیکتهای پشتیبانی",
"info": "بخش اطلاعات",
- "admin": "پنل مدیریت وب"
+ "admin": "پنل مدیریت وب",
+ "language": "انتخاب زبان ربات"
},
"styles": {
"default": "بدون رنگ",
@@ -1395,6 +1452,26 @@
"danger": "قرمز"
}
},
+ "menuEditor": {
+ "dragHint": "ردیفها را بکشید تا ترتیب تغییر کند",
+ "dragToReorder": "برای مرتبسازی بکشید",
+ "row": "ردیف",
+ "addRow": "افزودن ردیف",
+ "addButton": "افزودن دکمه",
+ "addUrlButton": "دکمه URL (لینک)",
+ "builtinButtons": "بخشهای داخلی",
+ "resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
+ "buttonTextPlaceholder": "متن دکمه",
+ "customLabelsHint": "متن دکمه برای هر زبان ربات",
+ "moveUp": "انتقال به بالا",
+ "moveDown": "انتقال به پایین",
+ "openIn": "باز کردن در",
+ "openMode": {
+ "external": "مرورگر خارجی",
+ "webapp": "مینیاپ تلگرام"
+ },
+ "invalidUrl": "لطفاً برای تمام دکمههای سفارشی یک URL معتبر وارد کنید (http:// یا https://)"
+ },
"apps": {
"title": "مدیریت برنامهها",
"addApp": "افزودن برنامه",
@@ -1571,6 +1648,10 @@
"noPeriodsHint": "هیچ دورهای اضافه نشده. حداقل یک دوره اضافه کنید.",
"daysShort": "روز",
"serversTitle": "سرورها",
+ "externalSquadTitle": "گروه خارجی",
+ "externalSquadHint": "یک گروه خارجی RemnaWave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه میشود.",
+ "noExternalSquad": "بدون گروه خارجی",
+ "externalSquadUsers": "کاربران",
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
"noServersAvailable": "هیچ سروری در دسترس نیست",
"promoGroupsTitle": "گروههای تبلیغاتی",
@@ -2152,6 +2233,7 @@
"form": {
"name": "نام گروه",
"namePlaceholder": "مشتریان VIP",
+ "nameRequired": "نام گروه را وارد کنید",
"categoryDiscounts": "تخفیفهای دستهبندی",
"periodDiscounts": "تخفیفهای دورهای",
"add": "افزودن",
@@ -2163,6 +2245,7 @@
"rub": "روبل",
"autoAssignHint": "0 = تخصیص خودکار نشود",
"applyToAddons": "اعمال به خدمات اضافی",
+ "isDefault": "گروه پیشفرض",
"cancel": "انصراف",
"saving": "در حال ذخیره...",
"save": "ذخیره"
@@ -2619,6 +2702,7 @@
"apps": "برنامهها",
"email_templates": "قالبهای ایمیل",
"pinned_messages": "پیامهای سنجاقشده",
+ "landings": "صفحات فرود",
"updates": "بهروزرسانیها"
},
"permissionActions": {
@@ -2826,6 +2910,7 @@
"filters": {
"title": "فیلترها",
"active": "فعال",
+ "user": "کاربر",
"action": "عملیات",
"actionPlaceholder": "جستجو بر اساس عملیات...",
"resource": "نوع منبع",
@@ -2884,6 +2969,63 @@
"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": "غیرفعال",
+ "purchaseCount": "خرید",
+ "copyUrl": "کپی URL",
+ "urlCopied": "URL کپی شد",
+ "deleteConfirm": "حذف صفحه فرود «{{title}}»؟",
+ "created": "صفحه فرود ایجاد شد",
+ "updated": "صفحه فرود بهروز شد",
+ "deleted": "صفحه فرود حذف شد",
+ "saveOrder": "ذخیره ترتیب",
+ "orderSaved": "ترتیب ذخیره شد",
+ "general": "عمومی",
+ "content": "محتوا",
+ "save": "ذخیره",
+ "back": "بازگشت",
+ "invalidSlug": "Slug فقط میتواند شامل حروف کوچک، اعداد و خط تیره باشد",
+ "titleRequired": "عنوان الزامی است",
+ "noTariffs": "حداقل یک تعرفه انتخاب کنید",
+ "noPaymentMethods": "حداقل یک روش پرداخت اضافه کنید",
+ "selectMethods": "انتخاب روشهای پرداخت موجود",
+ "noSystemMethods": "هیچ روش پرداختی در سیستم پیکربندی نشده است",
+ "methodOrder": "برای تغییر ترتیب بکشید",
+ "methodSubOptions": "گزینههای فرعی پرداخت",
+ "loadingPeriods": "در حال بارگذاری...",
+ "periodDaySuffix": "روز",
+ "localeTab": "زبان",
+ "localeHint": "زبان را تغییر دهید تا متن را به آن زبان ویرایش کنید"
}
},
"adminUpdates": {
@@ -3436,5 +3578,144 @@
"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": "انتخاب دوره",
+ "copy": "کپی",
+ "copied": "کپی شد!",
+ "copyLink": "کپی لینک",
+ "giftToggleLabel": "نوع خرید",
+ "pollTimedOut": "زمان بیشتری طول کشید",
+ "pollTimedOutDesc": "پردازش پرداخت بیشتر از حد معمول طول کشیده است. میتوانید دوباره بررسی کنید.",
+ "pendingActivation": "اشتراک آماده فعالسازی",
+ "pendingActivationDesc": "شما قبلاً اشتراک فعالی دارید. فعالسازی اشتراک جدید جایگزین اشتراک فعلی میشود.",
+ "activateNow": "فعالسازی",
+ "activating": "در حال فعالسازی...",
+ "activationFailed": "خطا در فعالسازی",
+ "giftMessage": "پیام",
+ "cabinetReady": "حساب شما آماده است",
+ "cabinetEmail": "ایمیل",
+ "cabinetPassword": "رمز عبور",
+ "goToCabinet": "رفتن به پنل کاربری",
+ "saveCredentials": "این اطلاعات ورود را ذخیره کنید",
+ "credentialsSentToEmail": "اطلاعات ورود به ایمیل شما ارسال شد",
+ "giftSentSuccess": "هدیه ارسال شد!",
+ "giftSentDesc": "گیرنده از طریق ایمیل مطلع خواهد شد",
+ "giftPendingActivationDesc": "گیرنده در حال حاضر اشتراک فعال دارد. لینک فعالسازی هدیه برای او ارسال خواهد شد.",
+ "giftTelegramSent": "اطلاعیه هدیه از طریق تلگرام به گیرنده ارسال شد",
+ "giftTelegramNotInBot": "گیرنده هنوز ربات را شروع نکرده است. این لینک را برای او ارسال کنید:",
+ "giftTelegramPendingSent": "درخواست فعالسازی از طریق تلگرام به گیرنده ارسال شد",
+ "giftTelegramPendingNotInBot": "گیرنده هنوز در ربات نیست. لینک ربات را برایش ارسال کنید تا بتواند هدیه را فعال کند:",
+ "openBot": "باز کردن ربات",
+ "autoLoginFailed": "ورود خودکار ناموفق بود",
+ "autoLoginProcessing": "در حال ورود...",
+ "periodLabels": {
+ "d1": "۱ روز",
+ "d2": "۲ روز",
+ "d3": "۳ روز",
+ "d5": "۵ روز",
+ "d7": "۱ هفته",
+ "d14": "۲ هفته",
+ "d30": "۱ ماه",
+ "d60": "۲ ماه",
+ "d90": "۳ ماه",
+ "d180": "۶ ماه",
+ "d365": "۱ سال",
+ "d456": "۱ سال و ۳ ماه",
+ "nDays": "{{count}} روز",
+ "nMonths": "{{count}} ماه"
+ }
+ },
+ "gift": {
+ "title": "هدیه اشتراک",
+ "subtitle": "اشتراک VPN را به عنوان هدیه ارسال کنید",
+ "choosePeriod": "انتخاب مدت",
+ "chooseTariff": "انتخاب طرح",
+ "recipient": "گیرنده",
+ "recipientPlaceholder": "ایمیل یا @telegram",
+ "recipientHint": "ایمیل یا نام کاربری تلگرام را وارد کنید",
+ "giftMessage": "پیام تبریک",
+ "giftMessagePlaceholder": "پیام شخصی اضافه کنید (اختیاری)",
+ "paymentMode": "روش پرداخت",
+ "fromBalance": "از موجودی",
+ "viaGateway": "درگاه پرداخت",
+ "yourBalance": "موجودی شما",
+ "insufficientBalance": "موجودی ناکافی",
+ "topUpBalance": "شارژ موجودی",
+ "total": "جمع",
+ "giftButton": "ارسال هدیه",
+ "sending": "در حال ارسال هدیه...",
+ "gb": "گیگابایت",
+ "devices": "دستگاه",
+ "paymentMethod": "روش پرداخت",
+ "processing": "در حال پردازش...",
+ "successTitle": "هدیه ارسال شد!",
+ "successDesc": "گیرنده {{contact}} اطلاعرسانی خواهد شد",
+ "pendingTitle": "در انتظار پرداخت",
+ "pendingDesc": "پرداخت را در سیستم پرداخت تکمیل کنید",
+ "pendingActivationTitle": "در انتظار فعالسازی",
+ "pendingActivationDesc": "گیرنده اشتراک فعال دارد. هدیه منتظر فعالسازی است.",
+ "failedTitle": "خطا",
+ "failedDesc": "ارسال هدیه ناموفق بود. لطفاً دوباره تلاش کنید.",
+ "backToGift": "بازگشت",
+ "backToDashboard": "بازگشت به داشبورد",
+ "tryAgain": "تلاش مجدد",
+ "pollTimeout": "پردازش بیش از حد معمول طول کشیده",
+ "pollTimeoutDesc": "بعداً وضعیت را بررسی کنید",
+ "pollErrorTitle": "بررسی وضعیت هدیه امکانپذیر نیست",
+ "pollErrorDesc": "خرید شما موفقیتآمیز بود. وضعیت را در داشبورد بررسی کنید.",
+ "retry": "بررسی مجدد",
+ "notFound": "تنظیمات هدیه یافت نشد",
+ "noToken": "لینک نامعتبر",
+ "noTokenDesc": "این لینک هدیه نامعتبر است یا منقضی شده.",
+ "days": "روز",
+ "tariff": "طرح",
+ "period": "مدت",
+ "giftMessageLabel": "پیام",
+ "recipientLabel": "گیرنده",
+ "featureDisabled": "قابلیت هدیه موقتاً در دسترس نیست",
+ "redirecting": "در حال انتقال...",
+ "pending": {
+ "title": "شما یک هدیه دریافت کردید!",
+ "from": "از {{sender}}",
+ "activate": "فعالسازی"
+ },
+ "warning": {
+ "telegram_unresolvable": "نام کاربری تلگرام قابل تأیید نبود. ممکن است گیرنده اعلان هدیه را دریافت نکند."
+ }
}
}
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 2b8c431..68eded5 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -44,7 +44,8 @@
"polls": "Опросы",
"info": "Информация",
"wheel": "Колесо удачи",
- "menu": "Меню"
+ "menu": "Меню",
+ "gift": "Подарить"
},
"notifications": {
"ticketNotifications": "Уведомления о тикетах",
@@ -247,13 +248,13 @@
"connectDevice": "Подключить устройство",
"devicesConnected": "{{count}} подключено",
"devicesOfMax": "{{used}} из {{max}} подключено",
+ "devicesConnectedUnlimited": "{{used}} подключено · без лимита",
"devicesShort": "устр.",
"trafficUsage": "{{used}} / {{limit}} ГБ",
"trafficUsageTitle": "Расход трафика",
"usedTraffic": "использовано {{amount}}",
"usedSuffix": "израсходовано",
"unlimited": "Безлимит",
- "unlimitedTraffic": "Безлимитный трафик",
"tariff": "Тариф",
"validUntil": "до {{date}}",
"remaining": "Осталось",
@@ -266,11 +267,20 @@
"trialSubtitle": "Пробный период завершён",
"paidSubtitle": "Срок действия закончился",
"renew": "Продлить подписку",
+ "quickRenew": "Продлить",
+ "topUp": "Пополнить баланс",
+ "balance": "Баланс",
+ "insufficientFunds": "Недостаточно средств на балансе",
+ "renewError": "Ошибка продления, попробуйте ещё раз",
"tariffs": "Тарифы",
"traffic": "Трафик",
"devices": "Устройства",
"expiredDate": "Истекла"
},
+ "suspended": {
+ "title": "Подписка приостановлена",
+ "resume": "Возобновить"
+ },
"trialOffer": {
"freeTitle": "Бесплатный пробный период",
"paidTitle": "Пробная подписка",
@@ -355,6 +365,9 @@
"daysBeforeExpiry_few": "{{count}} дня до окончания",
"daysBeforeExpiry_many": "{{count}} дней до окончания",
"selectPeriod": "Выберите период",
+ "noPeriodsAvailable": "Нет доступных периодов для продления",
+ "noPeriodsAvailableHint": "Администратор изменил настройки этого тарифа. Выберите другой тариф для продления подписки.",
+ "chooseDifferentTariff": "Выбрать другой тариф",
"selectTraffic": "Выберите трафик",
"selectServers": "Выберите серверы",
"selectDevices": "Устройства",
@@ -469,6 +482,7 @@
"pause": {
"title": "Пауза подписки",
"paused": "На паузе",
+ "suspended": "Приостановлена (недостаточно средств)",
"active": "Активна",
"pauseBtn": "Приостановить",
"resumeBtn": "Возобновить",
@@ -718,6 +732,19 @@
"paymentSuccess": {
"title": "Оплата прошла успешно",
"message": "Ваш баланс успешно пополнен. Средства уже доступны."
+ },
+ "topUpResult": {
+ "awaitingPayment": "Ожидание оплаты",
+ "awaitingPaymentDesc": "Мы ожидаем подтверждение вашего платежа. Это может занять несколько минут.",
+ "topUpAmount": "Сумма пополнения",
+ "success": "Баланс пополнен!",
+ "successDesc": "Ваш баланс успешно пополнен. Средства уже доступны.",
+ "failed": "Оплата не прошла",
+ "failedDesc": "К сожалению, платёж не был завершён. Попробуйте ещё раз или выберите другой способ оплаты.",
+ "timeout": "Дольше, чем обычно",
+ "timeoutDesc": "Обработка платежа занимает больше времени. Вы можете проверить статус ещё раз.",
+ "goToBalance": "Перейти к балансу",
+ "tryAgain": "Попробовать снова"
}
},
"referral": {
@@ -1077,7 +1104,8 @@
"roleAssign": "Назначение ролей",
"policies": "Политики доступа",
"auditLog": "Журнал аудита",
- "salesStats": "Статистика продаж"
+ "salesStats": "Статистика продаж",
+ "landings": "Лендинги"
},
"panel": {
"title": "Панель администратора",
@@ -1110,7 +1138,8 @@
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
"policiesDesc": "Настройка политик контроля доступа ABAC",
"auditLogDesc": "Просмотр активности системы и истории доступа",
- "salesStatsDesc": "Аналитика продаж и тренды"
+ "salesStatsDesc": "Аналитика продаж и тренды",
+ "landingsDesc": "Страницы быстрой покупки"
},
"salesStats": {
"title": "Статистика продаж",
@@ -1693,6 +1722,8 @@
"autoFullscreenDesc": "В Telegram WebApp",
"emailAuth": "Email авторизация",
"emailAuthDesc": "Регистрация и вход через email",
+ "giftEnabled": "Подписка в подарок",
+ "giftEnabledDesc": "Возможность отправить подписку в подарок другому пользователю",
"availableThemes": "Доступные темы",
"darkTheme": "Тёмная",
"lightTheme": "Светлая",
@@ -1773,6 +1804,10 @@
"Subscription Show Devices": "Показывать устройства",
"Telegram Stars Enabled": "Stars включены",
"Telegram Stars Rate Rub": "Курс Stars (₽)",
+ "Telegram Widget Size": "Размер виджета",
+ "Telegram Widget Radius": "Скругление углов",
+ "Telegram Widget Userpic": "Показывать аватар",
+ "Telegram Widget Request Access": "Запрос доступа",
"Tribute Api Key": "API ключ",
"Tribute Donate Link": "Ссылка доната",
"Tribute Enabled": "Tribute включен",
@@ -2161,7 +2196,10 @@
"External Admin Token": "Токен внешней админки",
"External Admin Token Bot Id": "ID бота",
"Bot Username": "Username бота",
- "Debug": "Отладка"
+ "Debug": "Отладка",
+ "telegramOidcEnabled": "OIDC включён",
+ "telegramOidcClientId": "Client ID (ID бота)",
+ "telegramOidcClientSecret": "Client Secret"
},
"categories": {
"PAYMENT": "Платежи",
@@ -2175,6 +2213,8 @@
"PAL24": "PAL24",
"WATA": "Wata",
"TELEGRAM": "Telegram",
+ "TELEGRAM_WIDGET": "Telegram Login Widget",
+ "TELEGRAM_OIDC": "Telegram Login (OIDC)",
"SUBSCRIPTIONS_CORE": "Основные",
"SIMPLE_SUBSCRIPTION": "Простая подписка",
"PERIODS": "Периоды",
@@ -2235,7 +2275,8 @@
"referral": "Рефералы",
"support": "Поддержка",
"info": "Информация",
- "admin": "Админка"
+ "admin": "Админка",
+ "language": "Язык"
},
"descriptions": {
"home": "Кнопка личного кабинета",
@@ -2244,7 +2285,8 @@
"referral": "Реферальная программа",
"support": "Обращения в поддержку",
"info": "Информационный раздел",
- "admin": "Веб-админка"
+ "admin": "Веб-админка",
+ "language": "Выбор языка"
},
"styles": {
"default": "Без цвета",
@@ -2253,6 +2295,26 @@
"danger": "Красный"
}
},
+ "menuEditor": {
+ "dragHint": "Перетаскивайте ряды для изменения порядка",
+ "dragToReorder": "Перетащите для сортировки",
+ "row": "Ряд",
+ "addRow": "Добавить ряд",
+ "addButton": "Добавить кнопку",
+ "addUrlButton": "URL-кнопка (ссылка)",
+ "builtinButtons": "Встроенные кнопки",
+ "resetConfirm": "Сбросить компоновку меню к значениям по умолчанию?",
+ "buttonTextPlaceholder": "Свой текст кнопки",
+ "customLabelsHint": "Пустое поле = название по умолчанию",
+ "moveUp": "Переместить вверх",
+ "moveDown": "Переместить вниз",
+ "openIn": "Открывать в",
+ "openMode": {
+ "external": "Внешний браузер",
+ "webapp": "Telegram miniapp"
+ },
+ "invalidUrl": "Укажите корректный URL (http:// или https://) для всех пользовательских кнопок"
+ },
"apps": {
"title": "Управление приложениями",
"addApp": "Добавить приложение",
@@ -2439,6 +2501,10 @@
"noPeriodsHint": "Нет добавленных периодов. Добавьте хотя бы один период.",
"daysShort": "дн.",
"serversTitle": "Серверы",
+ "externalSquadTitle": "Внешний сквад",
+ "externalSquadHint": "Назначьте внешний сквад RemnaWave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.",
+ "noExternalSquad": "Без внешнего сквада",
+ "externalSquadUsers": "пользователей",
"serversTabHint": "Выберите серверы, доступные на этом тарифе.",
"noServersAvailable": "Нет доступных серверов",
"promoGroupsTitle": "Промо-группы",
@@ -2543,6 +2609,7 @@
"subtitle": "Управление рекламными ссылками",
"createButton": "Создать",
"noData": "Нет рекламных кампаний",
+ "loadMore": "Загрузить ещё",
"bonusType": {
"balance": "Баланс",
"subscription": "Подписка",
@@ -3019,6 +3086,7 @@
"form": {
"name": "Название группы",
"namePlaceholder": "VIP клиенты",
+ "nameRequired": "Введите название группы",
"categoryDiscounts": "Скидки по категориям",
"periodDiscounts": "Скидки по периодам",
"add": "Добавить",
@@ -3030,6 +3098,7 @@
"rub": "руб.",
"autoAssignHint": "0 = не назначать автоматически",
"applyToAddons": "Применять к дополнительным услугам",
+ "isDefault": "Группа по умолчанию",
"cancel": "Отмена",
"saving": "Сохранение...",
"save": "Сохранить"
@@ -3428,6 +3497,7 @@
"apps": "Приложения",
"email_templates": "Шаблоны писем",
"pinned_messages": "Закреплённые сообщения",
+ "landings": "Лендинги",
"updates": "Обновления"
},
"permissionActions": {
@@ -3643,6 +3713,7 @@
"filters": {
"title": "Фильтры",
"active": "Активны",
+ "user": "Пользователь",
"action": "Действие",
"actionPlaceholder": "Поиск по действию...",
"resource": "Тип ресурса",
@@ -3707,6 +3778,122 @@
"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": "Неактивен",
+ "purchaseCount": "покупок",
+ "copyUrl": "Скопировать URL",
+ "urlCopied": "URL скопирован",
+ "deleteConfirm": "Удалить лендинг «{{title}}»?",
+ "created": "Лендинг создан",
+ "updated": "Лендинг обновлён",
+ "deleted": "Лендинг удалён",
+ "saveOrder": "Сохранить порядок",
+ "orderSaved": "Порядок сохранён",
+ "general": "Основное",
+ "content": "Контент",
+ "save": "Сохранить",
+ "back": "Назад",
+ "invalidSlug": "Slug может содержать только строчные буквы, цифры и дефисы",
+ "titleRequired": "Заголовок обязателен",
+ "noTariffs": "Выберите хотя бы один тариф",
+ "noPaymentMethods": "Добавьте хотя бы один способ оплаты",
+ "selectMethods": "Выберите доступные методы",
+ "noSystemMethods": "В системе не настроено ни одного способа оплаты",
+ "methodOrder": "Перетащите для изменения порядка",
+ "methodSubOptions": "Варианты оплаты",
+ "loadingPeriods": "Загрузка...",
+ "periodDaySuffix": "д",
+ "localeTab": "Язык",
+ "localeHint": "Переключите язык для редактирования текста на этом языке",
+ "discount": "Скидка",
+ "discountEnabled": "Включить скидку",
+ "discountPercent": "Скидка %",
+ "discountStartsAt": "Дата начала",
+ "discountEndsAt": "Дата окончания",
+ "discountBadge": "Текст баннера (необязательно)",
+ "discountBadgePlaceholder": "напр. Весенняя распродажа!",
+ "discountOverrides": "Индивидуальные скидки по тарифам",
+ "discountOverridesHint": "Оставьте пустым для использования общей скидки",
+ "discountPreview": "Предпросмотр",
+ "discountActive": "Скидка",
+ "background": "Анимированный фон",
+ "statistics": "Статистика",
+ "stats": {
+ "title": "Статистика лендинга",
+ "totalPurchases": "Покупки",
+ "revenue": "Доход",
+ "giftPurchases": "Подарки",
+ "regularPurchases": "Обычные",
+ "conversionRate": "Конверсия",
+ "avgPurchase": "Средний чек",
+ "dailyChart": "Покупки и доход по дням",
+ "tariffChart": "Распределение по тарифам",
+ "giftBreakdown": "Подарки vs обычные",
+ "purchases": "Покупки",
+ "revenueLabel": "Доход",
+ "gifts": "Подарки",
+ "regular": "Обычные",
+ "created": "Создано",
+ "successful": "Успешных",
+ "funnel": "Воронка",
+ "loadError": "Не удалось загрузить статистику",
+ "noPurchases": "Нет покупок"
+ },
+ "purchases": {
+ "title": "Покупки",
+ "contact": "Контакт",
+ "recipient": "Получатель",
+ "tariff": "Тариф",
+ "period": "Период",
+ "days": "дн.",
+ "price": "Цена",
+ "method": "Метод",
+ "date": "Дата",
+ "gift": "Подарок",
+ "forSelf": "Для себя",
+ "allStatuses": "Все статусы",
+ "status_pending": "Ожидание",
+ "status_paid": "Оплачен",
+ "status_delivered": "Доставлен",
+ "status_pending_activation": "Ожидает активации",
+ "status_failed": "Ошибка",
+ "status_expired": "Истёк",
+ "noPurchases": "Нет покупок",
+ "showing": "Показано {{from}}–{{to}} из {{total}}",
+ "page": "Стр. {{current}} из {{total}}",
+ "prev": "Назад",
+ "next": "Далее"
+ }
}
},
"adminUpdates": {
@@ -4442,5 +4629,154 @@
"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": "Выберите период",
+ "copy": "Скопировать",
+ "copied": "Скопировано!",
+ "copyLink": "Скопировать ссылку",
+ "giftToggleLabel": "Тип покупки",
+ "pollTimedOut": "Занимает больше времени, чем ожидалось",
+ "pollTimedOutDesc": "Обработка платежа занимает больше времени, чем обычно. Вы можете попробовать проверить ещё раз.",
+ "pendingActivation": "Подписка готова к активации",
+ "pendingActivationDesc": "У вас уже есть активная подписка. Активация новой заменит текущую.",
+ "activateNow": "Активировать",
+ "activating": "Активация...",
+ "activationFailed": "Ошибка активации",
+ "giftMessage": "Сообщение",
+ "cabinetReady": "Ваш аккаунт готов",
+ "cabinetEmail": "Email",
+ "cabinetPassword": "Пароль",
+ "goToCabinet": "Перейти в кабинет",
+ "saveCredentials": "Сохраните эти данные для входа",
+ "credentialsSentToEmail": "Данные для входа отправлены на email",
+ "giftSentSuccess": "Подарок отправлен!",
+ "giftSentDesc": "Получатель получит уведомление на email",
+ "giftPendingActivationDesc": "У получателя уже есть активная подписка. Ему будет отправлена ссылка для активации подарка.",
+ "giftTelegramSent": "Мы отправили получателю уведомление о подарке в Telegram",
+ "giftTelegramNotInBot": "Получатель пока не пользуется ботом. Отправьте ему эту ссылку, чтобы он мог забрать подарок:",
+ "giftTelegramPendingSent": "Мы отправили получателю предложение активировать подарок в Telegram",
+ "giftTelegramPendingNotInBot": "Получатель пока не пользуется ботом. Отправьте ему ссылку, чтобы он мог активировать подарок:",
+ "openBot": "Ссылка на бота",
+ "autoLoginFailed": "Не удалось выполнить автоматический вход",
+ "autoLoginProcessing": "Выполняется вход...",
+ "discount": {
+ "days": "д",
+ "hours": "ч",
+ "minutes": "м",
+ "seconds": "с"
+ },
+ "periodLabels": {
+ "d1": "1 день",
+ "d2": "2 дня",
+ "d3": "3 дня",
+ "d5": "5 дней",
+ "d7": "1 неделя",
+ "d14": "2 недели",
+ "d30": "1 месяц",
+ "d60": "2 месяца",
+ "d90": "3 месяца",
+ "d180": "6 месяцев",
+ "d365": "1 год",
+ "d456": "1 год + 3 мес.",
+ "nDays_one": "{{count}} день",
+ "nDays_few": "{{count}} дня",
+ "nDays_many": "{{count}} дней",
+ "nMonths_one": "{{count}} месяц",
+ "nMonths_few": "{{count}} месяца",
+ "nMonths_many": "{{count}} месяцев"
+ }
+ },
+ "gift": {
+ "title": "Подарить подписку",
+ "subtitle": "Отправьте VPN-подписку в подарок",
+ "choosePeriod": "Выберите период",
+ "chooseTariff": "Выберите тариф",
+ "recipient": "Получатель",
+ "recipientPlaceholder": "Email или @telegram",
+ "recipientHint": "Введите email или юзернейм в Telegram",
+ "giftMessage": "Поздравление",
+ "giftMessagePlaceholder": "Добавьте личное сообщение (необязательно)",
+ "paymentMode": "Способ оплаты",
+ "fromBalance": "С баланса",
+ "viaGateway": "Через платёжку",
+ "yourBalance": "Ваш баланс",
+ "insufficientBalance": "Недостаточно средств",
+ "topUpBalance": "Пополнить баланс",
+ "total": "Итого",
+ "giftButton": "Подарить",
+ "sending": "Отправляем подарок...",
+ "gb": "ГБ",
+ "devices": "устройств",
+ "paymentMethod": "Способ оплаты",
+ "processing": "Обработка...",
+ "successTitle": "Подарок отправлен!",
+ "successDesc": "Получатель {{contact}} получит уведомление",
+ "pendingTitle": "Ожидание оплаты",
+ "pendingDesc": "Завершите оплату в платёжной системе",
+ "pendingActivationTitle": "Ожидает активации",
+ "pendingActivationDesc": "У получателя есть активная подписка. Подарок ожидает активации.",
+ "failedTitle": "Ошибка",
+ "failedDesc": "Не удалось отправить подарок. Попробуйте снова.",
+ "backToGift": "Вернуться",
+ "backToDashboard": "На главную",
+ "tryAgain": "Попробовать снова",
+ "pollTimeout": "Обработка занимает больше времени, чем обычно",
+ "pollTimeoutDesc": "Попробуйте проверить статус позже",
+ "pollErrorTitle": "Не удалось проверить статус подарка",
+ "pollErrorDesc": "Ваша покупка прошла успешно. Проверьте статус на главной странице.",
+ "retry": "Проверить снова",
+ "notFound": "Конфигурация подарков не найдена",
+ "noToken": "Ссылка недействительна",
+ "noTokenDesc": "Ссылка на подарок недействительна или просрочена.",
+ "days": "дн.",
+ "tariff": "Тариф",
+ "period": "Период",
+ "giftMessageLabel": "Сообщение",
+ "recipientLabel": "Получатель",
+ "featureDisabled": "Функция подарков временно недоступна",
+ "redirecting": "Перенаправляем...",
+ "pending": {
+ "title": "Вам подарили подписку!",
+ "from": "от {{sender}}",
+ "activate": "Активировать"
+ },
+ "warning": {
+ "telegram_unresolvable": "Не удалось проверить этот Telegram-юзернейм. Получатель может не получить уведомление о подарке."
+ }
}
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index f8e6886..9e8ddc7 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -44,7 +44,8 @@
"polls": "问卷",
"info": "信息",
"wheel": "幸运转盘",
- "menu": "菜单"
+ "menu": "菜单",
+ "gift": "赠送"
},
"notifications": {
"ticketNotifications": "工单通知",
@@ -219,7 +220,27 @@
"noActiveSubscription": "无有效订阅",
"devicesUsed": "设备:{{used}} / {{total}}",
"trafficUsed": "流量:{{used}} / {{total}} GB",
- "unlimitedTraffic": "无限流量"
+ "unlimitedTraffic": "无限流量",
+ "expired": {
+ "title": "订阅已过期",
+ "trialTitle": "试用已过期",
+ "trialSubtitle": "试用期已结束",
+ "paidSubtitle": "订阅已到期",
+ "renew": "续订",
+ "quickRenew": "快速续订",
+ "insufficientFunds": "余额不足",
+ "renewError": "续订失败,请重试",
+ "topUp": "充值",
+ "balance": "余额",
+ "tariffs": "套餐",
+ "traffic": "流量",
+ "devices": "设备",
+ "expiredDate": "过期时间"
+ },
+ "suspended": {
+ "title": "订阅已暂停",
+ "resume": "恢复"
+ }
},
"subscription": {
"title": "订阅",
@@ -276,6 +297,9 @@
"daysBeforeExpiry": "到期前天数",
"daysBeforeExpiry_other": "到期前 {{count}} 天",
"selectPeriod": "选择时长",
+ "noPeriodsAvailable": "没有可用的续订周期",
+ "noPeriodsAvailableHint": "管理员已更改此套餐的设置。请选择其他套餐续订您的订阅。",
+ "chooseDifferentTariff": "选择其他套餐",
"selectTraffic": "选择流量",
"selectServers": "选择服务器",
"selectDevices": "选择设备",
@@ -428,6 +452,7 @@
"nextCharge": "距下次扣费",
"pauseBtn": "暂停",
"paused": "已暂停",
+ "suspended": "已暂停(余额不足)",
"pausedDescription": "扣费已停止。订阅有效期至",
"pausedInfo": "订阅已暂停",
"resumeBtn": "恢复",
@@ -530,6 +555,19 @@
"paymentSuccess": {
"title": "支付成功",
"message": "您的余额已成功充值,资金现已可用。"
+ },
+ "topUpResult": {
+ "awaitingPayment": "等待付款",
+ "awaitingPaymentDesc": "我们正在等待您的付款确认。这可能需要几分钟。",
+ "topUpAmount": "充值金额",
+ "success": "充值成功!",
+ "successDesc": "您的余额已成功充值,资金现已可用。",
+ "failed": "付款失败",
+ "failedDesc": "很遗憾,付款未完成。请重试或选择其他付款方式。",
+ "timeout": "处理时间较长",
+ "timeoutDesc": "付款处理时间比平时长。您可以再次检查状态。",
+ "goToBalance": "查看余额",
+ "tryAgain": "重试"
}
},
"referral": {
@@ -885,7 +923,8 @@
"roleAssign": "角色分配",
"policies": "访问策略",
"auditLog": "审计日志",
- "salesStats": "销售统计"
+ "salesStats": "销售统计",
+ "landings": "落地页"
},
"panel": {
"title": "管理面板",
@@ -918,7 +957,8 @@
"roleAssignDesc": "分配和撤销用户角色",
"policiesDesc": "配置ABAC访问控制策略",
"auditLogDesc": "查看系统活动和访问历史",
- "salesStatsDesc": "销售分析与趋势"
+ "salesStatsDesc": "销售分析与趋势",
+ "landingsDesc": "快速购买落地页"
},
"salesStats": {
"title": "销售统计",
@@ -1387,6 +1427,8 @@
"darkTheme": "暗色主题",
"emailAuth": "邮箱认证",
"emailAuthDesc": "允许通过邮箱登录",
+ "giftEnabled": "赠送订阅",
+ "giftEnabledDesc": "允许用户将订阅作为礼物赠送给其他用户",
"favoritesEmpty": "暂无收藏",
"favoritesHint": "点击星标将设置添加到这里",
"interfaceOptions": "界面选项",
@@ -1396,7 +1438,20 @@
"projectName": "项目名称",
"quickPresets": "快速预设",
"resetAllColors": "重置所有颜色",
- "statusColors": "状态颜色"
+ "statusColors": "状态颜色",
+ "categories": {
+ "TELEGRAM_WIDGET": "Telegram Login Widget",
+ "TELEGRAM_OIDC": "Telegram Login (OIDC)"
+ },
+ "settingNames": {
+ "Telegram Widget Size": "小部件大小",
+ "Telegram Widget Radius": "圆角半径",
+ "Telegram Widget Userpic": "显示头像",
+ "Telegram Widget Request Access": "请求访问权限",
+ "telegramOidcEnabled": "启用 OIDC",
+ "telegramOidcClientId": "Client ID(机器人 ID)",
+ "telegramOidcClientSecret": "Client Secret"
+ }
},
"buttons": {
"color": "按钮颜色",
@@ -1415,7 +1470,8 @@
"referral": "推荐",
"support": "支持",
"info": "信息",
- "admin": "管理面板"
+ "admin": "管理面板",
+ "language": "语言"
},
"descriptions": {
"home": "个人中心按钮",
@@ -1424,7 +1480,8 @@
"referral": "推荐计划",
"support": "支持工单",
"info": "信息板块",
- "admin": "网页管理面板"
+ "admin": "网页管理面板",
+ "language": "机器人语言选择"
},
"styles": {
"default": "无颜色",
@@ -1433,6 +1490,26 @@
"danger": "红色"
}
},
+ "menuEditor": {
+ "dragHint": "拖拽行以重新排序",
+ "dragToReorder": "拖拽排序",
+ "row": "行",
+ "addRow": "添加行",
+ "addButton": "添加按钮",
+ "addUrlButton": "URL按钮(链接)",
+ "builtinButtons": "内置栏目",
+ "resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
+ "buttonTextPlaceholder": "按钮文本",
+ "customLabelsHint": "每种语言的按钮文本",
+ "moveUp": "上移",
+ "moveDown": "下移",
+ "openIn": "打开方式",
+ "openMode": {
+ "external": "外部浏览器",
+ "webapp": "Telegram 小程序"
+ },
+ "invalidUrl": "请为所有自定义按钮提供有效的URL(http:// 或 https://)"
+ },
"apps": {
"title": "应用管理",
"addApp": "添加应用",
@@ -1608,6 +1685,10 @@
"noPeriodsHint": "没有添加的周期。请至少添加一个周期。",
"daysShort": "天",
"serversTitle": "服务器",
+ "externalSquadTitle": "外部小组",
+ "externalSquadHint": "为此套餐的用户分配RemnaWave外部小组。创建订阅时,用户将自动添加到所选小组。",
+ "noExternalSquad": "无外部小组",
+ "externalSquadUsers": "用户",
"serversTabHint": "选择此套餐上可用的服务器。",
"noServersAvailable": "没有可用的服务器",
"promoGroupsTitle": "促销组",
@@ -2151,6 +2232,7 @@
"form": {
"name": "组名称",
"namePlaceholder": "VIP客户",
+ "nameRequired": "请输入组名称",
"categoryDiscounts": "分类折扣",
"periodDiscounts": "期间折扣",
"add": "添加",
@@ -2162,6 +2244,7 @@
"rub": "卢布",
"autoAssignHint": "0 = 不自动分配",
"applyToAddons": "应用于附加服务",
+ "isDefault": "默认组",
"cancel": "取消",
"saving": "保存中...",
"save": "保存"
@@ -2618,6 +2701,7 @@
"apps": "应用",
"email_templates": "邮件模板",
"pinned_messages": "置顶消息",
+ "landings": "落地页",
"updates": "更新"
},
"permissionActions": {
@@ -2825,6 +2909,7 @@
"filters": {
"title": "筛选",
"active": "已激活",
+ "user": "用户",
"action": "操作",
"actionPlaceholder": "按操作搜索...",
"resource": "资源类型",
@@ -2883,6 +2968,63 @@
"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": "未激活",
+ "purchaseCount": "次购买",
+ "copyUrl": "复制URL",
+ "urlCopied": "URL已复制",
+ "deleteConfirm": "删除落地页「{{title}}」?",
+ "created": "落地页已创建",
+ "updated": "落地页已更新",
+ "deleted": "落地页已删除",
+ "saveOrder": "保存排序",
+ "orderSaved": "排序已保存",
+ "general": "基本设置",
+ "content": "内容",
+ "save": "保存",
+ "back": "返回",
+ "invalidSlug": "Slug只能包含小写字母、数字和连字符",
+ "titleRequired": "标题为必填项",
+ "noTariffs": "请至少选择一个套餐",
+ "noPaymentMethods": "请至少添加一个支付方式",
+ "selectMethods": "选择可用的支付方式",
+ "noSystemMethods": "系统中未配置任何支付方式",
+ "methodOrder": "拖动以调整顺序",
+ "methodSubOptions": "支付子选项",
+ "loadingPeriods": "加载中...",
+ "periodDaySuffix": "天",
+ "localeTab": "语言",
+ "localeHint": "切换语言以编辑该语言的文本"
}
},
"adminUpdates": {
@@ -3435,5 +3577,144 @@
"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": "选择时长",
+ "copy": "复制",
+ "copied": "已复制!",
+ "copyLink": "复制链接",
+ "giftToggleLabel": "购买类型",
+ "pollTimedOut": "处理时间较长",
+ "pollTimedOutDesc": "付款处理时间比平时长。您可以尝试再次检查。",
+ "pendingActivation": "订阅准备激活",
+ "pendingActivationDesc": "您已有活跃订阅。激活新订阅将替换当前订阅。",
+ "activateNow": "立即激活",
+ "activating": "激活中...",
+ "activationFailed": "激活失败",
+ "giftMessage": "留言",
+ "cabinetReady": "您的账户已准备就绪",
+ "cabinetEmail": "邮箱",
+ "cabinetPassword": "密码",
+ "goToCabinet": "前往个人中心",
+ "saveCredentials": "请保存这些登录信息",
+ "credentialsSentToEmail": "登录信息已发送到您的邮箱",
+ "giftSentSuccess": "礼物已发送!",
+ "giftSentDesc": "收件人将通过邮件收到通知",
+ "giftPendingActivationDesc": "收件人已有活跃订阅。他们将收到激活礼物的链接。",
+ "giftTelegramSent": "已通过 Telegram 向收件人发送通知",
+ "giftTelegramNotInBot": "收件人尚未启动机器人。请将此链接发送给他们:",
+ "giftTelegramPendingSent": "已通过 Telegram 向收件人发送激活请求",
+ "giftTelegramPendingNotInBot": "收件人尚未启动机器人。请将机器人链接发送给他们以激活礼物:",
+ "openBot": "打开机器人",
+ "autoLoginFailed": "自动登录失败",
+ "autoLoginProcessing": "正在登录...",
+ "periodLabels": {
+ "d1": "1天",
+ "d2": "2天",
+ "d3": "3天",
+ "d5": "5天",
+ "d7": "1周",
+ "d14": "2周",
+ "d30": "1个月",
+ "d60": "2个月",
+ "d90": "3个月",
+ "d180": "6个月",
+ "d365": "1年",
+ "d456": "1年3个月",
+ "nDays": "{{count}}天",
+ "nMonths": "{{count}}个月"
+ }
+ },
+ "gift": {
+ "title": "赠送订阅",
+ "subtitle": "将VPN订阅作为礼物发送",
+ "choosePeriod": "选择时长",
+ "chooseTariff": "选择套餐",
+ "recipient": "收件人",
+ "recipientPlaceholder": "邮箱或 @telegram",
+ "recipientHint": "输入邮箱或 Telegram 用户名",
+ "giftMessage": "祝福语",
+ "giftMessagePlaceholder": "添加个人消息(可选)",
+ "paymentMode": "支付方式",
+ "fromBalance": "余额支付",
+ "viaGateway": "在线支付",
+ "yourBalance": "您的余额",
+ "insufficientBalance": "余额不足",
+ "topUpBalance": "充值",
+ "total": "合计",
+ "giftButton": "赠送",
+ "sending": "正在发送礼物...",
+ "gb": "GB",
+ "devices": "设备",
+ "paymentMethod": "支付方式",
+ "processing": "处理中...",
+ "successTitle": "礼物已发送!",
+ "successDesc": "收件人 {{contact}} 将收到通知",
+ "pendingTitle": "等待支付",
+ "pendingDesc": "请在支付系统中完成支付",
+ "pendingActivationTitle": "等待激活",
+ "pendingActivationDesc": "收件人有活跃订阅,礼物等待激活。",
+ "failedTitle": "错误",
+ "failedDesc": "礼物发送失败,请重试。",
+ "backToGift": "返回",
+ "backToDashboard": "返回首页",
+ "tryAgain": "重试",
+ "pollTimeout": "处理时间超出预期",
+ "pollTimeoutDesc": "请稍后再查看状态",
+ "pollErrorTitle": "无法检查礼物状态",
+ "pollErrorDesc": "您的购买已成功。请在仪表板上查看详情。",
+ "retry": "再次检查",
+ "notFound": "未找到礼物配置",
+ "noToken": "无效链接",
+ "noTokenDesc": "此礼物链接无效或已过期。",
+ "days": "天",
+ "tariff": "套餐",
+ "period": "时长",
+ "giftMessageLabel": "消息",
+ "recipientLabel": "收件人",
+ "featureDisabled": "礼物功能暂时不可用",
+ "redirecting": "正在跳转...",
+ "pending": {
+ "title": "您收到了一份礼物!",
+ "from": "来自 {{sender}}",
+ "activate": "激活"
+ },
+ "warning": {
+ "telegram_unresolvable": "无法验证此 Telegram 用户名。收件人可能不会收到礼物通知。"
+ }
}
}
diff --git a/src/pages/AdminAuditLog.tsx b/src/pages/AdminAuditLog.tsx
index 6d27586..96fec9b 100644
--- a/src/pages/AdminAuditLog.tsx
+++ b/src/pages/AdminAuditLog.tsx
@@ -1,4 +1,4 @@
-import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
+import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -84,6 +84,7 @@ const RESOURCE_TYPES = [
'users',
'tickets',
'stats',
+ 'sales_stats',
'broadcasts',
'tariffs',
'promocodes',
@@ -106,6 +107,7 @@ const RESOURCE_TYPES = [
'apps',
'email_templates',
'pinned_messages',
+ 'landings',
'updates',
] as const;
@@ -116,6 +118,7 @@ const PAGE_SIZE_OPTIONS = [20, 50, 100] as const;
const AUTO_REFRESH_INTERVAL = 30_000;
interface FiltersState {
+ userId: string;
action: string;
resource: string;
status: string;
@@ -124,6 +127,7 @@ interface FiltersState {
}
const INITIAL_FILTERS: FiltersState = {
+ userId: '',
action: '',
resource: '',
status: '',
@@ -431,9 +435,6 @@ export default function AdminAuditLog() {
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState
(null);
- // Auto-refresh interval ref
- const intervalRef = useRef | null>(null);
-
// Build query params
const queryParams = useMemo((): AuditLogFilters => {
const params: AuditLogFilters = {
@@ -441,6 +442,10 @@ export default function AdminAuditLog() {
offset: page * pageSize,
};
+ const parsedUserId = parseInt(appliedFilters.userId, 10);
+ if (!isNaN(parsedUserId) && parsedUserId > 0) {
+ params.user_id = parsedUserId;
+ }
if (appliedFilters.action.trim()) {
params.action = appliedFilters.action.trim();
}
@@ -467,21 +472,12 @@ export default function AdminAuditLog() {
refetchInterval: autoRefresh ? AUTO_REFRESH_INTERVAL : false,
});
- // Auto-refresh visual indicator
- useEffect(() => {
- if (autoRefresh) {
- intervalRef.current = setInterval(() => {
- // The visual indicator updates are driven by isFetching from react-query
- }, AUTO_REFRESH_INTERVAL);
- }
-
- return () => {
- if (intervalRef.current) {
- clearInterval(intervalRef.current);
- intervalRef.current = null;
- }
- };
- }, [autoRefresh]);
+ // RBAC users for filter chips
+ const { data: rbacUsers } = useQuery({
+ queryKey: ['rbac-users'],
+ queryFn: () => rbacApi.getRbacUsers(),
+ staleTime: 5 * 60 * 1000,
+ });
const entries = data?.items ?? [];
const total = data?.total ?? 0;
@@ -517,8 +513,11 @@ export default function AdminAuditLog() {
setExporting(true);
try {
const exportParams: AuditLogFilters = {};
+ const exportUserId = parseInt(appliedFilters.userId, 10);
+ if (!isNaN(exportUserId) && exportUserId > 0) exportParams.user_id = exportUserId;
if (appliedFilters.action.trim()) exportParams.action = appliedFilters.action.trim();
if (appliedFilters.resource) exportParams.resource_type = appliedFilters.resource;
+ if (appliedFilters.status) exportParams.status = appliedFilters.status;
if (appliedFilters.dateFrom) exportParams.date_from = appliedFilters.dateFrom;
if (appliedFilters.dateTo) exportParams.date_to = appliedFilters.dateTo;
@@ -548,6 +547,7 @@ export default function AdminAuditLog() {
const hasActiveFilters = useMemo(() => {
return (
+ appliedFilters.userId.trim() !== '' ||
appliedFilters.action.trim() !== '' ||
appliedFilters.resource !== '' ||
appliedFilters.status !== '' ||
@@ -649,6 +649,51 @@ export default function AdminAuditLog() {
{filtersOpen && (
+ {/* User filter */}
+ {rbacUsers && rbacUsers.length > 0 && (
+
+
+
+ {rbacUsers.map((ru) => {
+ const isSelected = filters.userId === String(ru.user_id);
+ const displayName =
+ ru.first_name || ru.email || ru.username || `#${ru.user_id}`;
+ return (
+
+ );
+ })}
+
+
+ )}
+
{/* Action search */}
))}
+
+ {/* Load more */}
+ {hasNextPage && (
+
+ )}
)}
diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx
new file mode 100644
index 0000000..39dc68f
--- /dev/null
+++ b/src/pages/AdminLandingEditor.tsx
@@ -0,0 +1,1117 @@
+import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
+import { useNavigate, useParams } from 'react-router';
+import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+ adminLandingsApi,
+ type LandingCreateRequest,
+ type AdminLandingFeature,
+ type EditableMethodField,
+ type LocaleDict,
+ type SupportedLocale,
+ toLocaleDict,
+} from '../api/landings';
+import { tariffsApi, TariffListItem, PeriodPrice } from '../api/tariffs';
+import { formatPrice } from '../utils/format';
+import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
+import { Toggle, LocaleTabs, LocalizedInput } from '../components/admin';
+import { BackgroundConfigEditor } from '../components/admin/BackgroundConfigEditor';
+import type { AnimationConfig } from '@/components/ui/backgrounds/types';
+import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
+import { SortableFeatureItem, type FeatureWithId } from '../components/admin/SortableFeatureItem';
+import {
+ SortableSelectedMethodCard,
+ type MethodWithId,
+} from '../components/admin/SortableSelectedMethodCard';
+import { useNotify } from '@/platform';
+import { usePlatform } from '../platform/hooks/usePlatform';
+import { getApiErrorMessage } from '../utils/api-error';
+import { BackIcon, PlusIcon } from '../components/icons/LandingIcons';
+import {
+ DndContext,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ type DragEndEvent,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ sortableKeyboardCoordinates,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { cn } from '../lib/utils';
+import type { PaymentMethodSubOptionInfo } from '../types';
+
+function isoToDatetimeLocal(iso: string): string {
+ const d = new Date(iso);
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
+}
+
+const ChevronDownIcon = ({ open }: { open: boolean }) => (
+
+);
+
+// ============ Collapsible Section ============
+
+interface SectionProps {
+ title: string;
+ open: boolean;
+ onToggle: () => void;
+ children: React.ReactNode;
+}
+
+function Section({ title, open, onToggle, children }: SectionProps) {
+ return (
+
+
+ {open &&
{children}
}
+
+ );
+}
+
+// ============ 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
>({
+ general: true,
+ features: false,
+ tariffs: false,
+ discount: false,
+ methods: false,
+ gifts: false,
+ background: false,
+ footer: false,
+ });
+
+ const toggleSection = useCallback((key: string) => {
+ setOpenSections((prev) => ({ ...prev, [key]: !prev[key] }));
+ }, []);
+
+ // Locale editing state
+ const [editingLocale, setEditingLocale] = useState('ru');
+
+ // Form state — text fields are now LocaleDict
+ const [slug, setSlug] = useState('');
+ const [title, setTitle] = useState({ ru: '' });
+ const [subtitle, setSubtitle] = useState({});
+ const [isActive, setIsActive] = useState(true);
+ const [metaTitle, setMetaTitle] = useState({});
+ const [metaDescription, setMetaDescription] = useState({});
+ const [features, setFeatures] = useState([]);
+ const [selectedTariffIds, setSelectedTariffIds] = useState([]);
+ const [allowedPeriods, setAllowedPeriods] = useState>({});
+ const [paymentMethods, setPaymentMethods] = useState([]);
+ const [giftEnabled, setGiftEnabled] = useState(false);
+ const [footerText, setFooterText] = useState({});
+ const [customCss, setCustomCss] = useState('');
+
+ // Background config state
+ const [backgroundConfig, setBackgroundConfig] = useState({
+ ...DEFAULT_ANIMATION_CONFIG,
+ enabled: false,
+ });
+
+ // Discount state
+ const [discountPercent, setDiscountPercent] = useState(null);
+ const [discountOverrides, setDiscountOverrides] = useState>({});
+ const [discountStartsAt, setDiscountStartsAt] = useState('');
+ const [discountEndsAt, setDiscountEndsAt] = useState('');
+ const [discountBadgeText, setDiscountBadgeText] = 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),
+ staleTime: 30_000,
+ });
+
+ const allTariffs = tariffsData?.tariffs ?? [];
+
+ // Fetch system payment methods
+ const { data: systemMethods } = useQuery({
+ queryKey: ['admin-payment-methods'],
+ queryFn: () => adminPaymentMethodsApi.getAll(),
+ staleTime: 30_000,
+ });
+
+ const availablePaymentMethods = useMemo(
+ () => (systemMethods ?? []).filter((m) => m.is_enabled && m.is_provider_configured),
+ [systemMethods],
+ );
+
+ const subOptionsMap = useMemo(() => {
+ const map: Record = {};
+ for (const m of availablePaymentMethods) {
+ if (m.available_sub_options && m.available_sub_options.length > 0) {
+ map[m.method_id] = m.available_sub_options;
+ }
+ }
+ return map;
+ }, [availablePaymentMethods]);
+
+ // Fetch tariff details for period info
+ const tariffDetailQueries = useQueries({
+ queries: selectedTariffIds.map((tariffId) => ({
+ queryKey: ['admin-tariff-detail', tariffId],
+ queryFn: () => tariffsApi.getTariff(tariffId),
+ staleTime: 60_000,
+ enabled: selectedTariffIds.includes(tariffId),
+ })),
+ });
+
+ const tariffPeriodsData = tariffDetailQueries.map((q) => q.data);
+ const tariffPeriodsMap = useMemo(() => {
+ const map: Record = {};
+ tariffPeriodsData.forEach((data) => {
+ if (data) {
+ map[data.id] = data.period_prices;
+ }
+ });
+ return map;
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [JSON.stringify(tariffPeriodsData.map((d) => d?.id)), selectedTariffIds]);
+
+ // 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(toLocaleDict(landingData.title, { ru: '' }));
+ setSubtitle(toLocaleDict(landingData.subtitle));
+ setIsActive(landingData.is_active);
+ setMetaTitle(toLocaleDict(landingData.meta_title));
+ setMetaDescription(toLocaleDict(landingData.meta_description));
+ setFeatures(
+ (landingData.features ?? []).map((f) => ({
+ ...f,
+ _id: crypto.randomUUID(),
+ title: toLocaleDict(f.title),
+ description: toLocaleDict(f.description),
+ })),
+ );
+ setSelectedTariffIds(landingData.allowed_tariff_ids ?? []);
+ setAllowedPeriods(landingData.allowed_periods ?? {});
+ setPaymentMethods(
+ (landingData.payment_methods ?? []).map((m) => ({
+ ...m,
+ _id: crypto.randomUUID(),
+ min_amount_kopeks: m.min_amount_kopeks ?? null,
+ max_amount_kopeks: m.max_amount_kopeks ?? null,
+ currency: m.currency ?? null,
+ return_url: m.return_url ?? null,
+ sub_options: m.sub_options ?? null,
+ })),
+ );
+ setGiftEnabled(landingData.gift_enabled);
+ setFooterText(toLocaleDict(landingData.footer_text));
+ setCustomCss(landingData.custom_css ?? '');
+ if (landingData.background_config) {
+ setBackgroundConfig(landingData.background_config);
+ }
+ setDiscountPercent(landingData.discount_percent ?? null);
+ setDiscountOverrides(landingData.discount_overrides ?? {});
+ setDiscountStartsAt(
+ landingData.discount_starts_at ? isoToDatetimeLocal(landingData.discount_starts_at) : '',
+ );
+ setDiscountEndsAt(
+ landingData.discount_ends_at ? isoToDatetimeLocal(landingData.discount_ends_at) : '',
+ );
+ setDiscountBadgeText(toLocaleDict(landingData.discount_badge_text));
+ }, [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) => {
+ notify.error(getApiErrorMessage(err, 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) => {
+ notify.error(getApiErrorMessage(err, t('common.error')));
+ },
+ });
+
+ /** Return a LocaleDict only if it has at least one non-empty value, else undefined */
+ const nonEmptyDict = (dict: LocaleDict): LocaleDict | undefined => {
+ const filtered = Object.fromEntries(
+ Object.entries(dict).filter(([, v]) => typeof v === 'string' && v.trim().length > 0),
+ );
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
+ };
+
+ const handleSubmit = () => {
+ // Client-side validation
+ if (!isEdit && !/^[a-z0-9-]+$/.test(slug)) {
+ notify.error(
+ t(
+ 'admin.landings.invalidSlug',
+ 'Slug can only contain lowercase letters, numbers, and hyphens',
+ ),
+ );
+ return;
+ }
+ const titleHasContent = Object.values(title).some((v) => v.trim().length > 0);
+ if (!titleHasContent) {
+ notify.error(t('admin.landings.titleRequired', 'Title is required'));
+ return;
+ }
+ if (selectedTariffIds.length === 0) {
+ notify.error(t('admin.landings.noTariffs', 'Select at least one tariff'));
+ return;
+ }
+ if (paymentMethods.length === 0) {
+ notify.error(t('admin.landings.noPaymentMethods', 'Add at least one payment method'));
+ return;
+ }
+
+ // Strip _id before sending to API
+ const cleanFeatures: AdminLandingFeature[] = features.map(({ _id: _, ...rest }) => rest);
+ const cleanMethods = paymentMethods.map(({ _id: _, ...rest }) => ({
+ ...rest,
+ description: rest.description || null,
+ icon_url: rest.icon_url || null,
+ min_amount_kopeks: rest.min_amount_kopeks ?? null,
+ max_amount_kopeks: rest.max_amount_kopeks ?? null,
+ currency: rest.currency || null,
+ return_url: rest.return_url || null,
+ sub_options: rest.sub_options ?? null,
+ }));
+
+ // Filter out empty period arrays and periods for non-selected tariffs
+ const cleanPeriods = Object.fromEntries(
+ Object.entries(allowedPeriods).filter(
+ ([key, days]) => days.length > 0 && selectedTariffIds.includes(Number(key)),
+ ),
+ );
+
+ const data: LandingCreateRequest = {
+ slug,
+ title,
+ subtitle: nonEmptyDict(subtitle),
+ is_active: isActive,
+ features: cleanFeatures,
+ footer_text: nonEmptyDict(footerText),
+ allowed_tariff_ids: selectedTariffIds,
+ allowed_periods: cleanPeriods,
+ payment_methods: cleanMethods,
+ gift_enabled: giftEnabled,
+ custom_css: customCss || undefined,
+ meta_title: nonEmptyDict(metaTitle),
+ meta_description: nonEmptyDict(metaDescription),
+ discount_percent: discountPercent ?? null,
+ discount_overrides:
+ discountPercent !== null && Object.keys(discountOverrides).length > 0
+ ? discountOverrides
+ : null,
+ discount_starts_at:
+ discountPercent !== null && discountStartsAt
+ ? new Date(discountStartsAt).toISOString()
+ : null,
+ discount_ends_at:
+ discountPercent !== null && discountEndsAt ? new Date(discountEndsAt).toISOString() : null,
+ discount_badge_text:
+ discountPercent !== null ? (nonEmptyDict(discountBadgeText) ?? null) : null,
+ background_config: backgroundConfig.enabled ? backgroundConfig : null,
+ };
+
+ 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,
+ { _id: crypto.randomUUID(), icon: '', title: {}, description: {} },
+ ]);
+ };
+
+ const updateFeatureIcon = useCallback((index: number, value: string) => {
+ setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, icon: value } : f)));
+ }, []);
+
+ const updateFeatureLocalized = useCallback(
+ (index: number, field: 'title' | 'description', value: LocaleDict) => {
+ setFeatures((prev) => prev.map((f, i) => (i === index ? { ...f, [field]: value } : f)));
+ },
+ [],
+ );
+
+ const removeFeature = useCallback((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((f) => f._id === active.id);
+ const newIndex = prev.findIndex((f) => f._id === over.id);
+ if (oldIndex === -1 || newIndex === -1) return prev;
+ return arrayMove(prev, oldIndex, newIndex);
+ });
+ }
+ }, []);
+
+ // ---- Payment methods helpers ----
+ const togglePaymentMethod = useCallback(
+ (methodId: string) => {
+ setPaymentMethods((prev) => {
+ const exists = prev.find((m) => m.method_id === methodId);
+ if (exists) {
+ return prev.filter((m) => m.method_id !== methodId);
+ }
+ const systemMethod = availablePaymentMethods.find((m) => m.method_id === methodId);
+ if (!systemMethod) return prev;
+ // Seed sub_options from global config defaults; if no global overrides exist,
+ // explicitly set all available sub-options to enabled
+ const subOptions =
+ systemMethod.available_sub_options && systemMethod.available_sub_options.length > 0
+ ? systemMethod.sub_options
+ ? { ...systemMethod.sub_options }
+ : Object.fromEntries(systemMethod.available_sub_options.map((o) => [o.id, true]))
+ : null;
+ return [
+ ...prev,
+ {
+ _id: crypto.randomUUID(),
+ method_id: systemMethod.method_id,
+ display_name: systemMethod.display_name ?? systemMethod.default_display_name,
+ description: null,
+ icon_url: null,
+ sort_order: prev.length,
+ min_amount_kopeks: systemMethod.min_amount_kopeks ?? null,
+ max_amount_kopeks: systemMethod.max_amount_kopeks ?? null,
+ currency: null,
+ return_url: null,
+ sub_options: subOptions,
+ },
+ ];
+ });
+ },
+ [availablePaymentMethods],
+ );
+
+ const updatePaymentMethod = useCallback(
+ (methodId: string, field: EditableMethodField, value: string | number | null) => {
+ setPaymentMethods((prev) =>
+ prev.map((m) => (m.method_id === methodId ? { ...m, [field]: value } : m)),
+ );
+ },
+ [],
+ );
+
+ const updateSubOptions = useCallback((methodId: string, subOptions: Record) => {
+ setPaymentMethods((prev) =>
+ prev.map((m) => (m.method_id === methodId ? { ...m, sub_options: subOptions } : m)),
+ );
+ }, []);
+
+ const removePaymentMethod = useCallback((methodId: string) => {
+ setPaymentMethods((prev) => prev.filter((m) => m.method_id !== methodId));
+ }, []);
+
+ const handleMethodDragEnd = useCallback((event: DragEndEvent) => {
+ const { active, over } = event;
+ if (over && active.id !== over.id) {
+ setPaymentMethods((prev) => {
+ const oldIndex = prev.findIndex((m) => m._id === active.id);
+ const newIndex = prev.findIndex((m) => m._id === over.id);
+ if (oldIndex === -1 || newIndex === -1) return prev;
+ return arrayMove(prev, oldIndex, newIndex);
+ });
+ }
+ }, []);
+
+ // ---- Tariff/period helpers ----
+ const toggleTariff = (tariffId: number) => {
+ setSelectedTariffIds((prev) => {
+ if (prev.includes(tariffId)) {
+ const key = String(tariffId);
+ // Clean up allowedPeriods for unchecked tariff
+ setAllowedPeriods((ap) => {
+ if (!(key in ap)) return ap;
+ const { [key]: _, ...rest } = ap;
+ return rest;
+ });
+ // Clean up discount override for unchecked tariff
+ setDiscountOverrides((overrides) => {
+ if (!(key in overrides)) return overrides;
+ const { [key]: _, ...rest } = overrides;
+ return rest;
+ });
+ return prev.filter((id) => id !== tariffId);
+ }
+ return [...prev, tariffId];
+ });
+ };
+
+ const togglePeriodFromTariff = (tariffId: number, days: number, allPeriods: PeriodPrice[]) => {
+ const key = String(tariffId);
+ const allDays = allPeriods.map((p) => p.days);
+
+ setAllowedPeriods((prev) => {
+ const current = prev[key];
+ if (!current) {
+ // No override yet -- all periods allowed. Remove this one.
+ const updated = allDays.filter((d) => d !== days);
+ return { ...prev, [key]: updated };
+ }
+
+ const hasDay = current.includes(days);
+ const updated = hasDay ? current.filter((d) => d !== days) : [...current, days];
+
+ // If all periods are selected again, remove the override
+ if (updated.length === allDays.length) {
+ const { [key]: _, ...rest } = prev;
+ return rest;
+ }
+
+ return { ...prev, [key]: updated };
+ });
+ };
+
+ // Feature IDs for DnD
+ const featureIds = useMemo(() => features.map((f) => f._id), [features]);
+ const methodIds = useMemo(() => paymentMethods.map((m) => m._id), [paymentMethods]);
+
+ return (
+
+ {/* Header */}
+
+
+ {!capabilities.hasBackButton && (
+
+ )}
+
+ {isEdit ? t('admin.landings.edit') : t('admin.landings.create')}
+
+
+
+
+
+
+
+
+ {/* Locale tabs — always visible above sections */}
+
[f.title, f.description]),
+ ]}
+ className="mb-4"
+ />
+
+
+ {/* General Section */}
+
toggleSection('general')}
+ >
+
+
+
+
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"
+ />
+
{t('admin.landings.slugHint')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setIsActive(!isActive)} />
+
+
+ {/* SEO */}
+
+
{t('admin.landings.seo')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Features Section */}
+
toggleSection('features')}
+ >
+
+
+
+ {features.map((feature, index) => (
+
+ ))}
+
+
+
+
+
+
+ {/* Tariffs Section */}
+
toggleSection('tariffs')}
+ >
+
+
{t('admin.landings.selectTariffs')}
+ {allTariffs.map((tariff: TariffListItem) => (
+
+
+ {/* Period checkboxes from tariff detail */}
+ {selectedTariffIds.includes(tariff.id) && !tariff.is_daily && (
+
+
{t('admin.landings.periods')}:
+ {tariffPeriodsMap[tariff.id] ? (
+
+ {tariffPeriodsMap[tariff.id].map((period) => {
+ const override = allowedPeriods[String(tariff.id)];
+ const isAllowed = !override || override.includes(period.days);
+ return (
+
+ );
+ })}
+
+ ) : (
+
+ {t('admin.landings.loadingPeriods')}
+
+ )}
+
+ )}
+
+ ))}
+
+
+
+ {/* Discount Section */}
+
toggleSection('discount')}
+ >
+
+ {/* Enable/disable discount */}
+
+
+ {
+ if (discountPercent !== null) {
+ setDiscountPercent(null);
+ setDiscountOverrides({});
+ setDiscountStartsAt('');
+ setDiscountEndsAt('');
+ setDiscountBadgeText({});
+ } else {
+ setDiscountPercent(10);
+ }
+ }}
+ />
+
+
+ {discountPercent !== null && (
+
+ {/* Global percent */}
+
+
+
+
+
+ {/* Date range */}
+
+
+ {/* Badge text */}
+
+
+
+
+
+ {/* Per-tariff overrides */}
+ {selectedTariffIds.length > 0 && (
+
+
+
+ {t(
+ 'admin.landings.discountOverridesHint',
+ 'Leave empty to use global discount',
+ )}
+
+
+ {selectedTariffIds.map((tariffId) => {
+ const tariff = allTariffs.find((tr) => tr.id === tariffId);
+ if (!tariff) return null;
+ const override = discountOverrides[String(tariffId)];
+ const hasOverride = override !== undefined;
+ return (
+
+
+ {tariff.name}
+
+
+ {hasOverride ? `${override}%` : `${discountPercent}%`}
+
+ {
+ const val = e.target.value;
+ setDiscountOverrides((prev) => {
+ if (!val) {
+ const { [String(tariffId)]: _, ...rest } = prev;
+ return rest;
+ }
+ return {
+ ...prev,
+ [String(tariffId)]: Math.min(99, Math.max(1, Number(val))),
+ };
+ });
+ }}
+ className="w-16 rounded border border-dark-600 bg-dark-700 px-2 py-1 text-center text-sm text-dark-100 outline-none focus:border-accent-500"
+ />
+
+ );
+ })}
+
+
+ )}
+
+ {/* Preview */}
+ {selectedTariffIds.length > 0 && (
+
+
+ {t('admin.landings.discountPreview', 'Preview')}
+
+ {selectedTariffIds.slice(0, 3).map((tariffId) => {
+ const tariff = allTariffs.find((tr) => tr.id === tariffId);
+ if (!tariff) return null;
+ const override = discountOverrides[String(tariffId)];
+ const pct = override ?? discountPercent ?? 0;
+ const periods = tariffPeriodsMap[tariffId];
+ if (!periods || periods.length === 0) return null;
+ const firstPeriod = periods[0];
+ const discounted = Math.max(
+ 1,
+ firstPeriod.price_kopeks -
+ Math.floor((firstPeriod.price_kopeks * pct) / 100),
+ );
+ return (
+
+ {tariff.name}:
+
+ {formatPrice(firstPeriod.price_kopeks)}
+
+
+ {formatPrice(discounted)}
+
+
+ -{pct}%
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+
+
+ {/* Payment Methods Section */}
+
toggleSection('methods')}
+ >
+
+ {/* Available system methods as toggleable list */}
+
+
{t('admin.landings.selectMethods')}
+
+ {availablePaymentMethods.map((sysMethod) => {
+ const isSelected = paymentMethods.some(
+ (m) => m.method_id === sysMethod.method_id,
+ );
+ return (
+
+ );
+ })}
+ {availablePaymentMethods.length === 0 && (
+
{t('admin.landings.noSystemMethods')}
+ )}
+
+
+
+ {/* Selected methods with drag-to-reorder */}
+ {paymentMethods.length > 0 && (
+
+
{t('admin.landings.methodOrder')}
+
+
+
+ {paymentMethods.map((method) => (
+
+ ))}
+
+
+
+
+ )}
+
+
+
+ {/* Gift Settings Section */}
+
toggleSection('gifts')}
+ >
+
+
+ setGiftEnabled(!giftEnabled)} />
+
+
+
+ {/* Background Section */}
+
toggleSection('background')}
+ >
+
+
+
+ {/* Footer & Custom CSS Section */}
+
toggleSection('footer')}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/AdminLandingStats.tsx b/src/pages/AdminLandingStats.tsx
new file mode 100644
index 0000000..4f41625
--- /dev/null
+++ b/src/pages/AdminLandingStats.tsx
@@ -0,0 +1,747 @@
+import { useState, useMemo } from 'react';
+import { useParams, useNavigate } from 'react-router';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+ Area,
+ AreaChart,
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ Pie,
+ PieChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import {
+ adminLandingsApi,
+ resolveLocaleDisplay,
+ type PurchaseItemStatus,
+ type LandingPurchaseItem,
+} from '../api/landings';
+import { useCurrency } from '../hooks/useCurrency';
+import { useChartColors } from '../hooks/useChartColors';
+import { CHART_COMMON } from '../constants/charts';
+import { AdminBackButton } from '../components/admin';
+
+// Icons
+const ChartIcon = () => (
+
+);
+
+const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6'];
+const GIFT_COLOR = '#a855f7';
+
+const PURCHASE_STATUS_STYLES: Record = {
+ pending: 'bg-warning-500/20 text-warning-400',
+ paid: 'bg-accent-500/20 text-accent-400',
+ delivered: 'bg-success-500/20 text-success-400',
+ pending_activation: 'bg-accent-500/20 text-accent-400',
+ failed: 'bg-error-500/20 text-error-400',
+ expired: 'bg-dark-500/20 text-dark-400',
+};
+
+const PURCHASE_STATUS_OPTIONS: Array = [
+ 'all',
+ 'pending',
+ 'paid',
+ 'delivered',
+ 'pending_activation',
+ 'failed',
+ 'expired',
+];
+
+const PURCHASES_PAGE_SIZE = 20;
+
+// Small icons for the purchase cards
+
+const EmailIcon = () => (
+
+);
+
+const TelegramSmallIcon = () => (
+
+);
+
+const ArrowRightIcon = () => (
+
+);
+
+const GiftIcon = () => (
+
+);
+
+const ChevronLeftSmall = () => (
+
+);
+
+const ChevronRightSmall = () => (
+
+);
+
+// Contact display helper
+function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) {
+ return (
+
+ {type === 'email' ? : }
+ {value}
+
+ );
+}
+
+// Purchase card component
+interface PurchaseCardProps {
+ item: LandingPurchaseItem;
+ formatPrice: (kopeks: number) => string;
+ lang: string;
+ t: (key: string, opts?: Record) => string;
+}
+
+function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
+ const statusStyle = PURCHASE_STATUS_STYLES[item.status] || 'bg-dark-600 text-dark-300';
+ const dateStr = new Date(item.created_at).toLocaleDateString(lang, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+
+ return (
+
+ {/* Mobile: stacked | Desktop: horizontal */}
+
+ {/* Status badge */}
+
+
+ {t(`admin.landings.purchases.status_${item.status}`)}
+
+
+
+ {/* Contact info */}
+
+
+
+ {item.is_gift && item.gift_recipient_type && item.gift_recipient_value && (
+
+
+
+
+ )}
+
+
+
+ {/* Tariff + period */}
+
+ {item.tariff_name}
+
+ {' '}
+ · {item.period_days} {t('admin.landings.purchases.days')}
+
+
+
+ {/* Price */}
+
+ {formatPrice(item.amount_kopeks)}
+
+
+ {/* Payment method */}
+
{item.payment_method}
+
+ {/* Gift badge */}
+ {item.is_gift && (
+
+
+
+ {t('admin.landings.purchases.gift')}
+
+
+ )}
+
+ {/* Date */}
+
{dateStr}
+
+
+ );
+}
+
+export default function AdminLandingStats() {
+ const { t, i18n } = useTranslation();
+ const { id } = useParams<{ id: string }>();
+ const numericId = id ? Number(id) : NaN;
+ const isValidId = !isNaN(numericId);
+ const navigate = useNavigate();
+ const { formatWithCurrency } = useCurrency();
+ const colors = useChartColors();
+
+ // Purchases list state
+ const [purchaseOffset, setPurchaseOffset] = useState(0);
+ const [purchaseStatusFilter, setPurchaseStatusFilter] = useState(
+ 'all',
+ );
+
+ // Fetch stats
+ const {
+ data: stats,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['landing-stats', numericId],
+ queryFn: () => adminLandingsApi.getStats(numericId),
+ enabled: isValidId,
+ staleTime: CHART_COMMON.STALE_TIME,
+ });
+
+ // Fetch landing detail for header
+ const { data: landing } = useQuery({
+ queryKey: ['admin-landing', numericId],
+ queryFn: () => adminLandingsApi.get(numericId),
+ enabled: isValidId,
+ staleTime: CHART_COMMON.STALE_TIME,
+ });
+
+ // Fetch purchases list
+ const { data: purchasesData, isLoading: purchasesLoading } = useQuery({
+ queryKey: [
+ 'landing-purchases',
+ numericId,
+ purchaseOffset,
+ PURCHASES_PAGE_SIZE,
+ purchaseStatusFilter,
+ ],
+ queryFn: () =>
+ adminLandingsApi.getPurchases(
+ numericId,
+ purchaseOffset,
+ PURCHASES_PAGE_SIZE,
+ purchaseStatusFilter === 'all' ? undefined : purchaseStatusFilter,
+ ),
+ enabled: isValidId,
+ staleTime: CHART_COMMON.STALE_TIME,
+ });
+
+ const purchaseItems = purchasesData?.items ?? [];
+ const purchaseTotal = purchasesData?.total ?? 0;
+ const purchaseTotalPages = Math.ceil(purchaseTotal / PURCHASES_PAGE_SIZE);
+ const purchaseCurrentPage = Math.floor(purchaseOffset / PURCHASES_PAGE_SIZE) + 1;
+
+ // Prepare daily chart data
+ const dailyData = useMemo(() => {
+ if (!stats) return [];
+ return stats.daily_stats.map((item) => ({
+ label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
+ month: 'short',
+ day: 'numeric',
+ }),
+ purchases: item.purchases,
+ revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
+ gifts: item.gifts,
+ }));
+ }, [stats, i18n.language]);
+
+ // Prepare tariff chart data
+ const tariffData = useMemo(() => {
+ if (!stats) return [];
+ return stats.tariff_stats.map((item) => ({
+ name: item.tariff_name,
+ purchases: item.purchases,
+ revenue: item.revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR,
+ }));
+ }, [stats]);
+
+ // Donut data for gift vs regular
+ const donutData = useMemo(() => {
+ if (!stats) return [];
+ return [
+ {
+ name: t('admin.landings.stats.regular'),
+ value: stats.total_regular,
+ color: colors.referrals,
+ },
+ { name: t('admin.landings.stats.gifts'), value: stats.total_gifts, color: GIFT_COLOR },
+ ];
+ }, [stats, t, colors.referrals]);
+
+ // Bar chart height based on tariff count
+ const barChartHeight = useMemo(() => {
+ const count = tariffData.length;
+ return Math.max(220, count * 45 + 40);
+ }, [tariffData.length]);
+
+ // Loading state
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ // Error state
+ if (error || !stats) {
+ return (
+
+
+
+
{t('admin.landings.stats.title')}
+
+
+
{t('admin.landings.stats.loadError')}
+
+
+
+ );
+ }
+
+ const landingTitle = landing ? resolveLocaleDisplay(landing.title) : `#${numericId}`;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+
{landingTitle}
+
+ {landing?.is_active ? (
+
+ {t('admin.landings.active')}
+
+ ) : (
+
+ {t('admin.landings.inactive')}
+
+ )}
+
+
+
+
+
+
+ {/* Summary Cards */}
+
+
+
+ {stats.total_purchases}
+
+
{t('admin.landings.stats.totalPurchases')}
+
+
+
+ {formatWithCurrency(stats.total_revenue_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
+
+
{t('admin.landings.stats.revenue')}
+
+
+
{stats.total_gifts}
+
{t('admin.landings.stats.giftPurchases')}
+
+
+
+ {stats.conversion_rate}%
+
+
{t('admin.landings.stats.conversionRate')}
+
+
+
+ {/* Charts */}
+
+ {/* Daily Purchases & Revenue */}
+
+
+ {t('admin.landings.stats.dailyChart')}
+
+ {dailyData.length === 0 ? (
+
+ {t('admin.landings.stats.noPurchases')}
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Tariff Distribution */}
+
+
+ {t('admin.landings.stats.tariffChart')}
+
+ {tariffData.length === 0 ? (
+
+ {t('admin.landings.stats.noPurchases')}
+
+ ) : (
+
+
+
+
+
+ {
+ return [value ?? 0, t('admin.landings.stats.purchases')];
+ }}
+ />
+
+ {tariffData.map((_, index) => (
+ |
+ ))}
+
+
+
+ )}
+
+
+
+ {/* Additional Stats Row */}
+
+
+
+ {t('admin.landings.stats.avgPurchase')}
+
+
+ {formatWithCurrency(stats.avg_purchase_kopeks / CHART_COMMON.KOPEKS_DIVISOR)}
+
+
+
+
+ {t('admin.landings.stats.regularPurchases')}
+
+
{stats.total_regular}
+
+
+
{t('admin.landings.stats.funnel')}
+
+ {stats.total_created}{' '}
+ {t('admin.landings.stats.created')}
+ {' / '}
+ {stats.total_successful}{' '}
+ {t('admin.landings.stats.successful')}
+
+
+
+
+ {/* Gift vs Regular Donut */}
+ {stats.total_purchases > 0 && (
+
+
+ {t('admin.landings.stats.giftBreakdown')}
+
+
+
+
+
+
+ {donutData.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+ {/* Center text */}
+
+ {stats.total_purchases}
+
+
+
+
+
+
+ {t('admin.landings.stats.regular')}: {stats.total_regular}
+
+
+
+
+
+ {t('admin.landings.stats.gifts')}: {stats.total_gifts}
+
+
+
+
+
+ )}
+
+ {/* Purchases List */}
+
+ {/* Header row: title + status filter */}
+
+
{t('admin.landings.purchases.title')}
+
+
+
+ {/* Content */}
+ {purchasesLoading ? (
+
+ ) : purchaseItems.length === 0 ? (
+
+ {t('admin.landings.purchases.noPurchases')}
+
+ ) : (
+ <>
+
+ {purchaseItems.map((item) => (
+
+ formatWithCurrency(kopeks / CHART_COMMON.KOPEKS_DIVISOR)
+ }
+ lang={i18n.language}
+ t={t}
+ />
+ ))}
+
+
+ {/* Pagination */}
+ {purchaseTotalPages > 1 && (
+
+
+ {t('admin.landings.purchases.showing', {
+ from: purchaseOffset + 1,
+ to: Math.min(purchaseOffset + PURCHASES_PAGE_SIZE, purchaseTotal),
+ total: purchaseTotal,
+ })}
+
+
+
+
+
+ {t('admin.landings.purchases.page', {
+ current: purchaseCurrentPage,
+ total: purchaseTotalPages,
+ })}
+
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/src/pages/AdminLandings.tsx b/src/pages/AdminLandings.tsx
new file mode 100644
index 0000000..568e9b7
--- /dev/null
+++ b/src/pages/AdminLandings.tsx
@@ -0,0 +1,514 @@
+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, resolveLocaleDisplay } from '../api/landings';
+import { useNotify } from '@/platform';
+import { copyToClipboard } from '../utils/clipboard';
+import { getApiErrorMessage } from '../utils/api-error';
+import { usePlatform } from '../platform/hooks/usePlatform';
+import { cn } from '../lib/utils';
+import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
+import {
+ DndContext,
+ KeyboardSensor,
+ 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 (non-shared, page-specific)
+const EditIcon = () => (
+
+);
+
+const CheckIcon = () => (
+
+);
+
+const XIcon = () => (
+
+);
+
+const GiftIcon = () => (
+
+);
+
+const SaveIcon = () => (
+
+);
+
+const CopyIcon = () => (
+
+);
+
+const StatsChartIcon = () => (
+
+);
+
+// ============ Sortable Landing Card ============
+
+interface SortableLandingCardProps {
+ landing: LandingListItem;
+ onEdit: () => void;
+ onStats: () => void;
+ onDelete: () => void;
+ onToggle: () => void;
+ onCopyUrl: () => void;
+ isPendingDelete?: boolean;
+}
+
+function SortableLandingCard({
+ landing,
+ onEdit,
+ onStats,
+ 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 (
+
+
+ {/* Drag handle */}
+
+
+ {/* Content + Actions wrapper */}
+
+ {/* Top row: title/slug + actions (desktop) */}
+
+
+
+
+ {resolveLocaleDisplay(landing.title)}
+
+
+ {landing.slug}
+
+ {landing.is_active ? (
+
+ {t('admin.landings.active')}
+
+ ) : (
+
+ {t('admin.landings.inactive')}
+
+ )}
+ {landing.gift_enabled && (
+
+
+
+ )}
+ {landing.has_active_discount && (
+
+ {t('admin.landings.discountActive', 'Discount')}
+
+ )}
+
+
+
+ {landing.purchase_stats.total} {t('admin.landings.purchaseCount')}
+
+
+
+
+ {/* Actions: hidden on mobile, shown on desktop */}
+
+
+
+
+
+
+
+
+
+ {/* Actions: shown on mobile only */}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+// ============ 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([]);
+ const [orderChanged, setOrderChanged] = useState(false);
+ const [pendingDeleteId, setPendingDeleteId] = useState(null);
+ const deleteTimeoutRef = useRef | undefined>(undefined);
+
+ useEffect(() => {
+ return () => {
+ if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
+ };
+ }, []);
+
+ // Queries
+ const { data: landingsData, isLoading } = useQuery({
+ queryKey: ['admin-landings'],
+ queryFn: () => adminLandingsApi.list(),
+ staleTime: 30_000,
+ });
+
+ // 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) => {
+ notify.error(getApiErrorMessage(err, 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) => {
+ notify.error(getApiErrorMessage(err, t('common.error')));
+ },
+ });
+
+ const handleDelete = (landing: LandingListItem) => {
+ if (pendingDeleteId === landing.id) {
+ deleteMutation.mutate(landing.id);
+ setPendingDeleteId(null);
+ } else {
+ setPendingDeleteId(landing.id);
+ if (deleteTimeoutRef.current) clearTimeout(deleteTimeoutRef.current);
+ 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) => {
+ notify.error(getApiErrorMessage(err, 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 (
+
+ {/* Header */}
+
+
+ {/* Show back button only on web, not in Telegram Mini App */}
+ {!capabilities.hasBackButton && (
+
+ )}
+
+
{t('admin.landings.title')}
+
+
+
+ {orderChanged && (
+
+ )}
+
+
+
+
+ {/* Drag hint */}
+
+
+ {t('admin.tariffs.dragToReorder')}
+
+
+ {/* Landings List */}
+ {isLoading ? (
+
+ ) : localLandings.length === 0 ? (
+
+ ) : (
+
+ l.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {localLandings.map((landing) => (
+ navigate(`/admin/landings/${landing.id}/edit`)}
+ onStats={() => navigate(`/admin/landings/${landing.id}/stats`)}
+ onDelete={() => handleDelete(landing)}
+ onToggle={() => toggleMutation.mutate(landing.id)}
+ onCopyUrl={() => handleCopyUrl(landing.slug)}
+ isPendingDelete={pendingDeleteId === landing.id}
+ />
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx
index 32f90b1..c95f1bd 100644
--- a/src/pages/AdminPanel.tsx
+++ b/src/pages/AdminPanel.tsx
@@ -304,6 +304,16 @@ const ClipboardDocumentListIcon = () => (
);
+const RectangleGroupIcon = () => (
+
+);
+
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: ,
+ title: t('admin.nav.landings'),
+ description: t('admin.panel.landingsDesc'),
+ permission: 'landings:read',
+ },
],
},
{
diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx
index 3769529..95b8a42 100644
--- a/src/pages/AdminPayments.tsx
+++ b/src/pages/AdminPayments.tsx
@@ -228,7 +228,7 @@ export default function AdminPayments() {
{t('admin.payments.openLink')}
diff --git a/src/pages/AdminPromoGroupCreate.tsx b/src/pages/AdminPromoGroupCreate.tsx
index a10a793..a9ee620 100644
--- a/src/pages/AdminPromoGroupCreate.tsx
+++ b/src/pages/AdminPromoGroupCreate.tsx
@@ -61,6 +61,7 @@ export default function AdminPromoGroupCreate() {
const [trafficDiscount, setTrafficDiscount] = useState(0);
const [deviceDiscount, setDeviceDiscount] = useState(0);
const [applyToAddons, setApplyToAddons] = useState(true);
+ const [isDefault, setIsDefault] = useState(false);
const [autoAssignSpent, setAutoAssignSpent] = useState(0);
const [periodDiscounts, setPeriodDiscounts] = useState([]);
@@ -79,6 +80,7 @@ export default function AdminPromoGroupCreate() {
setTrafficDiscount(data.traffic_discount_percent || 0);
setDeviceDiscount(data.device_discount_percent || 0);
setApplyToAddons(data.apply_discounts_to_addons ?? true);
+ setIsDefault(data.is_default ?? false);
setAutoAssignSpent(
data.auto_assign_total_spent_kopeks ? data.auto_assign_total_spent_kopeks / 100 : 0,
);
@@ -151,7 +153,8 @@ export default function AdminPromoGroupCreate() {
period_discounts:
Object.keys(periodDiscountsRecord).length > 0 ? periodDiscountsRecord : undefined,
apply_discounts_to_addons: applyToAddons,
- auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : null,
+ auto_assign_total_spent_kopeks: autoAssignVal > 0 ? Math.round(autoAssignVal * 100) : 0,
+ is_default: isDefault,
};
if (isEdit) {
@@ -388,6 +391,24 @@ export default function AdminPromoGroupCreate() {
{t('admin.promoGroups.form.applyToAddons')}
+
+ {/* Default group */}
+
{/* Footer */}
diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx
index 9b429ed..7525a96 100644
--- a/src/pages/AdminSettings.tsx
+++ b/src/pages/AdminSettings.tsx
@@ -9,7 +9,7 @@ import { MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
import { usePlatform } from '../platform/hooks/usePlatform';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
-import { ButtonsTab } from '../components/admin/ButtonsTab';
+import { MenuEditorTab } from '../components/admin/MenuEditorTab';
import { ThemeTab } from '../components/admin/ThemeTab';
import { FavoritesTab } from '../components/admin/FavoritesTab';
import { SettingsTab } from '../components/admin/SettingsTab';
@@ -176,7 +176,7 @@ export default function AdminSettings() {
case 'theme':
return ;
case 'buttons':
- return ;
+ return ;
case 'favorites':
return (
(1);
const [periodPrices, setPeriodPrices] = useState([]);
const [selectedSquads, setSelectedSquads] = useState([]);
+ const [selectedExternalSquad, setSelectedExternalSquad] = useState(null);
const [selectedPromoGroups, setSelectedPromoGroups] = useState([]);
const [dailyPriceKopeks, setDailyPriceKopeks] = useState(0);
@@ -138,6 +140,12 @@ export default function AdminTariffCreate() {
queryFn: () => tariffsApi.getAvailableServers(),
});
+ // Fetch external squads
+ const { data: externalSquads = [] } = useQuery({
+ queryKey: ['admin-tariffs-external-squads'],
+ queryFn: () => tariffsApi.getAvailableExternalSquads(),
+ });
+
// Fetch promo groups
const { data: promoGroups = [] } = useQuery({
queryKey: ['admin-tariffs-promo-groups'],
@@ -165,6 +173,7 @@ export default function AdminTariffCreate() {
setTierLevel(data.tier_level || 1);
setPeriodPrices(data.period_prices?.length ? data.period_prices : []);
setSelectedSquads(data.allowed_squads || []);
+ setSelectedExternalSquad(data.external_squad_uuid || null);
setSelectedPromoGroups(
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
);
@@ -211,6 +220,7 @@ export default function AdminTariffCreate() {
tier_level: toNumber(tierLevel, 1),
period_prices: isDaily ? [] : periodPrices.filter((p) => p.price_kopeks >= 0),
allowed_squads: selectedSquads,
+ external_squad_uuid: selectedExternalSquad || null,
promo_group_ids: selectedPromoGroups.length > 0 ? selectedPromoGroups : undefined,
traffic_topup_enabled: trafficTopupEnabled,
traffic_topup_packages: trafficTopupPackages,
@@ -660,6 +670,77 @@ export default function AdminTariffCreate() {
{activeTab === 'servers' && (
+ {/* External Squad */}
+ {externalSquads.length > 0 && (
+
+
+ {t('admin.tariffs.externalSquadTitle')}
+
+
{t('admin.tariffs.externalSquadHint')}
+
+
+ {externalSquads.map((squad: ExternalSquadInfo) => {
+ const isSelected = selectedExternalSquad === squad.uuid;
+ return (
+
+ );
+ })}
+
+
+ )}
+
{/* Servers */}
{t('admin.tariffs.serversTitle')}
diff --git a/src/pages/AutoLogin.tsx b/src/pages/AutoLogin.tsx
new file mode 100644
index 0000000..0587951
--- /dev/null
+++ b/src/pages/AutoLogin.tsx
@@ -0,0 +1,82 @@
+import { useEffect, useState, useRef } from 'react';
+import { useSearchParams, useNavigate } from 'react-router';
+import { useTranslation } from 'react-i18next';
+import { authApi } from '../api/auth';
+import { useAuthStore } from '../store/auth';
+
+export default function AutoLogin() {
+ const { t } = useTranslation();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const { setTokens, setUser, checkAdminStatus } = useAuthStore();
+ const [error, setError] = useState(false);
+ const attemptedRef = useRef(false);
+
+ const token = searchParams.get('token');
+
+ useEffect(() => {
+ // Prevent referrer leaking the token
+ const meta = document.createElement('meta');
+ meta.name = 'referrer';
+ meta.content = 'no-referrer';
+ document.head.appendChild(meta);
+ return () => {
+ document.head.removeChild(meta);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!token || attemptedRef.current) {
+ if (!token) setError(true);
+ return;
+ }
+ attemptedRef.current = true;
+
+ authApi
+ .autoLogin(token)
+ .then(async (response) => {
+ setTokens(response.access_token, response.refresh_token);
+ setUser(response.user);
+ await checkAdminStatus();
+ navigate('/', { replace: true });
+ })
+ .catch(() => {
+ setError(true);
+ });
+ }, [token, navigate, setTokens, setUser, checkAdminStatus]);
+
+ return (
+
+
+ {error ? (
+
+
+
{t('landing.autoLoginFailed')}
+
+
+ ) : (
+
+
+
{t('landing.autoLoginProcessing')}
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx
index a02d30a..1ef1bb8 100644
--- a/src/pages/Balance.tsx
+++ b/src/pages/Balance.tsx
@@ -8,12 +8,12 @@ import { useAuthStore } from '../store/auth';
import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import { API } from '../config/constants';
-import { useToast } from '../components/Toast';
import type { PaginatedResponse, Transaction } from '../types';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
+import { isPaidStatus, isFailedStatus } from '../utils/paymentStatus';
// Icons
const ChevronDownIcon = ({ className = 'h-5 w-5' }: { className?: string }) => (
@@ -51,7 +51,6 @@ export default function Balance() {
const { formatAmount, currencySymbol } = useCurrency();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
- const { showToast } = useToast();
const paymentHandledRef = useRef(false);
// Fetch balance from API
@@ -72,30 +71,19 @@ export default function Balance() {
if (paymentHandledRef.current) return;
const paymentStatus = searchParams.get('payment') || searchParams.get('status');
- const isSuccess =
- paymentStatus === 'success' ||
- paymentStatus === 'paid' ||
- paymentStatus === 'completed' ||
- searchParams.get('success') === 'true';
+
+ const normalised = paymentStatus?.toLowerCase() ?? '';
+ const isSuccess = isPaidStatus(normalised) || searchParams.get('success') === 'true';
+ const isFailed = isFailedStatus(normalised);
if (isSuccess) {
paymentHandledRef.current = true;
-
- refetchBalance();
- refreshUser();
- queryClient.invalidateQueries({ queryKey: ['transactions'] });
- queryClient.invalidateQueries({ queryKey: ['subscription'] });
-
- showToast({
- type: 'success',
- title: t('balance.paymentSuccess.title'),
- message: t('balance.paymentSuccess.message'),
- duration: 6000,
- });
-
- navigate('/balance', { replace: true });
+ navigate('/balance/top-up/result?status=success', { replace: true });
+ } else if (isFailed) {
+ paymentHandledRef.current = true;
+ navigate('/balance/top-up/result?status=failed', { replace: true });
}
- }, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
+ }, [searchParams, navigate]);
const [promocode, setPromocode] = useState('');
const [promocodeLoading, setPromocodeLoading] = useState(false);
@@ -175,6 +163,7 @@ export default function Balance() {
await refetchBalance();
await refreshUser();
queryClient.invalidateQueries({ queryKey: ['transactions'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
}
} catch (error: unknown) {
const axiosError = error as { response?: { data?: { detail?: string } } };
diff --git a/src/pages/ConnectedAccounts.tsx b/src/pages/ConnectedAccounts.tsx
index 94f3ada..2543da6 100644
--- a/src/pages/ConnectedAccounts.tsx
+++ b/src/pages/ConnectedAccounts.tsx
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { authApi } from '../api/auth';
+import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
import { useToast } from '../components/Toast';
import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button';
@@ -24,47 +25,191 @@ const isLinkableProvider = (provider: string): boolean =>
// SessionStorage key for Telegram link CSRF state
export const LINK_TELEGRAM_STATE_KEY = 'link_telegram_state';
-/** Compact Telegram Login Widget for account linking (browser only). */
+/** Telegram account linking widget (browser only). Supports OIDC popup and legacy widget. */
function TelegramLinkWidget() {
const containerRef = useRef
(null);
- const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
+ const navigate = useNavigate();
+ const { showToast } = useToast();
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [oidcLoading, setOidcLoading] = useState(false);
+ const [scriptLoaded, setScriptLoaded] = useState(false);
+ const mountedRef = useRef(true);
+
+ const { data: widgetConfig } = useQuery({
+ queryKey: ['telegram-widget-config'],
+ queryFn: brandingApi.getTelegramWidgetConfig,
+ staleTime: 60000,
+ });
+
+ const botUsername =
+ widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
+ const isOIDC = Boolean(widgetConfig?.oidc_enabled && widgetConfig?.oidc_client_id);
useEffect(() => {
- if (!containerRef.current || !botUsername) return;
+ mountedRef.current = true;
+ return () => {
+ mountedRef.current = false;
+ };
+ }, []);
+
+ // Shared handler for link result
+ const handleLinkResult = async (response: Awaited>) => {
+ if (response.merge_required && response.merge_token) {
+ navigate(`/merge/${response.merge_token}`, { replace: true });
+ } else {
+ queryClient.invalidateQueries({ queryKey: ['linked-providers'] });
+ showToast({ type: 'success', message: t('profile.accounts.linkSuccess') });
+ }
+ };
+
+ // OIDC callback handler (ref pattern to avoid stale closures)
+ const handleOIDCCallbackRef =
+ useRef<(data: { id_token?: string; error?: string }) => void>(undefined);
+
+ handleOIDCCallbackRef.current = async (data: { id_token?: string; error?: string }) => {
+ if (!mountedRef.current) return;
+ if (data.error || !data.id_token) {
+ setOidcLoading(false);
+ showToast({
+ type: 'error',
+ message: data.error || t('profile.accounts.linkError'),
+ });
+ return;
+ }
+ try {
+ setOidcLoading(true);
+ const response = await authApi.linkTelegram({ id_token: data.id_token });
+ if (mountedRef.current) await handleLinkResult(response);
+ } catch (err: unknown) {
+ if (mountedRef.current) {
+ showToast({
+ type: 'error',
+ message: getErrorDetail(err) || t('profile.accounts.linkError'),
+ });
+ }
+ } finally {
+ if (mountedRef.current) setOidcLoading(false);
+ }
+ };
+
+ // Load OIDC script and init
+ useEffect(() => {
+ if (!isOIDC || !widgetConfig?.oidc_client_id) return;
+
+ const scriptId = 'telegram-login-oidc-script';
+ let script = document.getElementById(scriptId) as HTMLScriptElement | null;
+
+ const initTelegramLogin = () => {
+ if (window.Telegram?.Login) {
+ window.Telegram.Login.init(
+ {
+ client_id: Number(widgetConfig.oidc_client_id) || widgetConfig.oidc_client_id,
+ request_access: widgetConfig.request_access ? ['write'] : undefined,
+ lang: document.documentElement.lang || 'en',
+ },
+ (data) => handleOIDCCallbackRef.current?.(data),
+ );
+ setScriptLoaded(true);
+ }
+ };
+
+ if (!script) {
+ script = document.createElement('script');
+ script.id = scriptId;
+ script.src = 'https://oauth.telegram.org/js/telegram-login.js?3';
+ script.async = true;
+ script.onload = () => initTelegramLogin();
+ script.onerror = () => {
+ if (mountedRef.current) {
+ showToast({ type: 'error', message: t('profile.accounts.linkError') });
+ }
+ };
+ document.head.appendChild(script);
+ } else {
+ initTelegramLogin();
+ }
+ }, [isOIDC, widgetConfig?.oidc_client_id, widgetConfig?.request_access]);
+
+ // Legacy widget effect (only when NOT OIDC)
+ useEffect(() => {
+ if (isOIDC || !containerRef.current || !botUsername) return;
const container = containerRef.current;
while (container.firstChild) {
container.removeChild(container.firstChild);
}
- // Generate CSRF state token and store in sessionStorage
- const csrfState = crypto.randomUUID();
- sessionStorage.setItem(LINK_TELEGRAM_STATE_KEY, csrfState);
-
- const redirectUrl = `${window.location.origin}/auth/link/telegram/callback?csrf_state=${encodeURIComponent(csrfState)}`;
+ const callbackName = '__onTelegramLinkAuth';
+ (window as unknown as Record)[callbackName] = async (
+ user: Record,
+ ) => {
+ if (!mountedRef.current) return;
+ try {
+ const response = await authApi.linkTelegram({
+ id: user.id as number,
+ first_name: user.first_name as string,
+ last_name: (user.last_name as string) || undefined,
+ username: (user.username as string) || undefined,
+ photo_url: (user.photo_url as string) || undefined,
+ auth_date: user.auth_date as number,
+ hash: user.hash as string,
+ });
+ if (mountedRef.current) await handleLinkResult(response);
+ } catch (err: unknown) {
+ if (mountedRef.current) {
+ showToast({
+ type: 'error',
+ message: getErrorDetail(err) || t('profile.accounts.linkError'),
+ });
+ }
+ }
+ };
const script = document.createElement('script');
- script.src = 'https://telegram.org/js/telegram-widget.js?22';
+ script.src = 'https://telegram.org/js/telegram-widget.js?23';
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'small');
script.setAttribute('data-radius', '8');
- script.setAttribute('data-auth-url', redirectUrl);
+ script.setAttribute('data-onauth', `${callbackName}(user)`);
script.setAttribute('data-request-access', 'write');
script.async = true;
container.appendChild(script);
return () => {
+ delete (window as unknown as Record)[callbackName];
while (container.firstChild) {
container.removeChild(container.firstChild);
}
};
- }, [botUsername]);
+ }, [isOIDC, botUsername, navigate, showToast, t, queryClient]);
- if (!botUsername) {
+ if (!botUsername && !isOIDC) {
return null;
}
+ if (isOIDC) {
+ return (
+
+ );
+ }
+
return ;
}
diff --git a/src/pages/Connection.tsx b/src/pages/Connection.tsx
index 21e7e9b..1907721 100644
--- a/src/pages/Connection.tsx
+++ b/src/pages/Connection.tsx
@@ -37,6 +37,7 @@ export default function Connection() {
const handleOpenQR = useCallback(() => {
navigate('/connection/qr', {
+ replace: !isTelegramWebApp,
state: {
url: appConfig?.subscriptionUrl,
hideLink: appConfig?.hideLink ?? false,
diff --git a/src/pages/ConnectionQR.tsx b/src/pages/ConnectionQR.tsx
index b09cffe..5671a2a 100644
--- a/src/pages/ConnectionQR.tsx
+++ b/src/pages/ConnectionQR.tsx
@@ -2,8 +2,8 @@ import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { QRCodeSVG } from 'qrcode.react';
-import { useAuthStore } from '../store/auth';
import { useBranding } from '../hooks/useBranding';
+import { AdminBackButton } from '@/components/admin';
interface ConnectionQRState {
url: string;
@@ -20,96 +20,56 @@ export default function ConnectionQR() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
- const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
- const isLoading = useAuthStore((state) => state.isLoading);
const { appName } = useBranding();
const state = location.state as unknown;
const validState = isValidState(state) ? state : null;
useEffect(() => {
- if (!isLoading && !isAuthenticated) {
- navigate('/login', { replace: true });
- }
- }, [isAuthenticated, isLoading, navigate]);
-
- useEffect(() => {
- if (!isLoading && isAuthenticated && !validState) {
+ if (!validState) {
navigate('/connection', { replace: true });
}
- }, [isLoading, isAuthenticated, validState, navigate]);
+ }, [validState, navigate]);
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- e.preventDefault();
- navigate(-1);
- }
- };
- document.addEventListener('keydown', handleKeyDown);
- return () => document.removeEventListener('keydown', handleKeyDown);
- }, [navigate]);
-
- if (isLoading || !validState) {
+ if (!validState) {
return null;
}
return (
-
- {/* Close button */}
-
+
+
+
+
{t('subscription.connection.qrTitle')}
+
- {/* Content */}
-
- {/* Branding name */}
- {appName && (
-
- {appName}
+
+
+ {appName && (
+
+ {appName}
+
+ )}
+
+
+ {t('subscription.connection.qrScanHint')}
- )}
- {/* Title */}
-
- {t('subscription.connection.qrTitle')}
-
+
+
+
- {/* Hint */}
-
- {t('subscription.connection.qrScanHint')}
-
-
- {/* QR code container */}
-
-
+ {!validState.hideLink && (
+
+ {validState.url}
+
+ )}
-
- {/* URL display */}
- {!validState.hideLink && (
-
- {validState.url}
-
- )}
);
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index b355b6d..7564778 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -14,6 +14,8 @@ import SubscriptionCardActive from '../components/dashboard/SubscriptionCardActi
import SubscriptionCardExpired from '../components/dashboard/SubscriptionCardExpired';
import TrialOfferCard from '../components/dashboard/TrialOfferCard';
import StatsGrid from '../components/dashboard/StatsGrid';
+import { giftApi } from '../api/gift';
+import PendingGiftCard from '../components/dashboard/PendingGiftCard';
import { API } from '../config/constants';
const ChevronRightIcon = () => (
@@ -80,6 +82,13 @@ export default function Dashboard() {
retry: false,
});
+ const { data: pendingGifts } = useQuery({
+ queryKey: ['pending-gifts'],
+ queryFn: giftApi.getPendingGifts,
+ staleTime: 30_000,
+ retry: false,
+ });
+
const activateTrialMutation = useMutation({
mutationFn: subscriptionApi.activateTrial,
onSuccess: () => {
@@ -87,6 +96,7 @@ export default function Dashboard() {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['trial-info'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
refreshUser();
},
onError: (error: { response?: { data?: { detail?: string } } }) => {
@@ -220,6 +230,9 @@ export default function Dashboard() {
{t('dashboard.yourSubscription')}
+ {/* Pending Gift Activations */}
+ {pendingGifts && pendingGifts.length > 0 &&
}
+
{/* Subscription Status Card */}
{subLoading ? (
@@ -234,8 +247,12 @@ export default function Dashboard() {
- ) : subscription?.is_expired ? (
-
+ ) : subscription?.is_expired || subscription?.status === 'disabled' ? (
+
) : subscription ? (
+
+
+
+ {t('gift.processing', 'Processing your gift...')}
+
+
+ {t('gift.pendingDesc', 'Please wait while we process your payment')}
+
+
+
+ );
+}
+
+function DeliveredState({
+ recipientContact,
+ tariffName,
+ periodDays,
+ giftMessage,
+ warning,
+}: {
+ recipientContact: string | null;
+ tariffName: string | null;
+ periodDays: number | null;
+ giftMessage: string | null;
+ warning: string | null;
+}) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
{t('gift.successTitle', 'Gift sent!')}
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('gift.days', 'days')}
+
+ )}
+ {recipientContact && (
+
+ {t('gift.successDesc', {
+ contact: recipientContact,
+ defaultValue: `Sent to ${recipientContact}`,
+ })}
+
+ )}
+ {giftMessage && (
+
“{giftMessage}”
+ )}
+
+
+ {warning && (
+
+
{t(`gift.warning.${warning}`)}
+
+ )}
+
+
+
+ );
+}
+
+function PendingActivationState({
+ recipientContact,
+ tariffName,
+ periodDays,
+ warning,
+}: {
+ recipientContact: string | null;
+ tariffName: string | null;
+ periodDays: number | null;
+ warning: string | null;
+}) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+ {/* Info icon */}
+
+
+
+
+ {t('gift.pendingActivationTitle', 'Gift pending activation')}
+
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('gift.days', 'days')}
+
+ )}
+ {recipientContact && (
+
+ {t('gift.successDesc', {
+ contact: recipientContact,
+ defaultValue: `Sent to ${recipientContact}`,
+ })}
+
+ )}
+
+ {t(
+ 'gift.pendingActivationDesc',
+ 'The recipient currently has an active subscription. Your gift will be activated once their current subscription expires.',
+ )}
+
+
+
+ {warning && (
+
+
{t(`gift.warning.${warning}`)}
+
+ )}
+
+
+
+ );
+}
+
+function FailedState() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+ {t('gift.failedTitle', 'Something went wrong')}
+
+
+ {t('gift.failedDesc', 'Your gift could not be processed. Please try again.')}
+
+
+
+
+
+ );
+}
+
+function PollErrorState() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
+
+ {t('gift.pollErrorTitle', 'Could not check gift status')}
+
+
+ {t(
+ 'gift.pollErrorDesc',
+ 'Your purchase was successful. Check your dashboard for details.',
+ )}
+
+
+
+
+
+ );
+}
+
+function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t('gift.pollTimeout', 'Taking longer than expected')}
+
+
+ {t(
+ 'gift.pollTimeoutDesc',
+ 'Payment processing is taking longer than usual. You can try checking again.',
+ )}
+
+
+
+
+ );
+}
+
+function NoTokenState() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ return (
+
+
+
+
{t('gift.noToken', 'Invalid link')}
+
+ {t('gift.noTokenDesc', 'This gift link is invalid or has expired.')}
+
+
+
+
+ );
+}
+
+// ============================================================
+// Main Component
+// ============================================================
+
+export default function GiftResult() {
+ const [searchParams] = useSearchParams();
+ const token = searchParams.get('token');
+ const mode = searchParams.get('mode');
+ const rawUrlWarning = searchParams.get('warning');
+ const urlWarning = rawUrlWarning && KNOWN_WARNINGS.has(rawUrlWarning) ? rawUrlWarning : null;
+
+ const pollStart = useRef(Date.now());
+ const [pollTimedOut, setPollTimedOut] = useState(false);
+
+ const isBalanceMode = mode === 'balance';
+
+ const {
+ data: status,
+ isError,
+ refetch,
+ } = useQuery({
+ queryKey: ['gift-status', token],
+ queryFn: () => giftApi.getPurchaseStatus(token!),
+ enabled: !!token && !pollTimedOut,
+ refetchInterval: (query) => {
+ // Balance mode: fetch once, no polling
+ if (isBalanceMode) return false;
+
+ const s = query.state.data?.status;
+ if (s === 'delivered' || s === 'failed' || s === 'pending_activation' || s === 'expired')
+ return false;
+
+ // Check poll timeout
+ if (Date.now() - pollStart.current > MAX_POLL_MS) {
+ setPollTimedOut(true);
+ return false;
+ }
+
+ return 3000;
+ },
+ retry: 2,
+ });
+
+ const handleRetryPoll = useCallback(() => {
+ pollStart.current = Date.now();
+ setPollTimedOut(false);
+ refetch();
+ }, [refetch]);
+
+ // No token
+ if (!token) {
+ return (
+
+ );
+ }
+
+ const isDelivered = status?.status === 'delivered';
+ const isPendingActivation = status?.status === 'pending_activation';
+ const isFailed = status?.status === 'failed' || status?.status === 'expired';
+
+ // Warning from status response (persisted on purchase) takes priority over URL param
+ const statusWarning =
+ status?.warning && KNOWN_WARNINGS.has(status.warning) ? status.warning : null;
+ const warning = statusWarning ?? urlWarning;
+
+ return (
+
+
+ {isError ? (
+
+ ) : isDelivered ? (
+
+ ) : isPendingActivation ? (
+
+ ) : isFailed ? (
+
+ ) : pollTimedOut ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx
new file mode 100644
index 0000000..649e653
--- /dev/null
+++ b/src/pages/GiftSubscription.tsx
@@ -0,0 +1,985 @@
+import { useState, useMemo, useEffect } from 'react';
+import { useNavigate, Link } from 'react-router';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { motion, AnimatePresence } from 'framer-motion';
+import { giftApi } from '../api/gift';
+import type {
+ GiftConfig,
+ GiftTariff,
+ GiftTariffPeriod,
+ GiftPaymentMethod,
+ GiftPurchaseRequest,
+} from '../api/gift';
+
+import { cn } from '../lib/utils';
+import { getApiErrorMessage } from '../utils/api-error';
+import { formatPrice } from '../utils/format';
+
+// ============================================================
+// Helpers
+// ============================================================
+
+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 /^@[a-zA-Z][a-zA-Z0-9_]{4,31}$/.test(trimmed);
+ }
+ return /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(trimmed);
+}
+
+function formatPeriodLabel(
+ days: number,
+ t: (key: string, options?: Record) => string,
+): string {
+ const key = `landing.periodLabels.d${days}`;
+ const result = t(key);
+ if (result !== key) return result;
+
+ const months = Math.floor(days / 30);
+ const remainder = days % 30;
+ if (months > 0 && remainder === 0) {
+ return t('landing.periodLabels.nMonths', { count: months });
+ }
+ return t('landing.periodLabels.nDays', { count: days });
+}
+
+// ============================================================
+// Sub-components
+// ============================================================
+
+function LoadingSkeleton() {
+ return (
+
+ );
+}
+
+function ErrorState({ message }: { message: string }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
{t('gift.failedTitle', 'Error')}
+
{message}
+
+
+ );
+}
+
+function DisabledState() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const timer = setTimeout(() => navigate('/'), 3000);
+ return () => clearTimeout(timer);
+ }, [navigate]);
+
+ return (
+
+
+
+
+ {t('gift.featureDisabled', 'Gift subscriptions are currently unavailable')}
+
+
{t('gift.redirecting', 'Redirecting...')}
+
+
+ );
+}
+
+function PeriodTabs({
+ periods,
+ selectedDays,
+ onSelect,
+}: {
+ periods: GiftTariffPeriod[];
+ selectedDays: number;
+ onSelect: (days: number) => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {periods.map((period) => (
+
+ ))}
+
+ );
+}
+
+function TariffCard({
+ tariff,
+ isSelected,
+ selectedPeriod,
+ onSelect,
+}: {
+ tariff: GiftTariff;
+ isSelected: boolean;
+ selectedPeriod: GiftTariffPeriod | undefined;
+ onSelect: () => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
+
+function PaymentModeToggle({
+ mode,
+ onToggle,
+ balanceLabel,
+}: {
+ mode: 'balance' | 'gateway';
+ onToggle: (mode: 'balance' | 'gateway') => void;
+ balanceLabel: string;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ );
+}
+
+function PaymentMethodCard({
+ method,
+ isSelected,
+ selectedSubOption,
+ onSelect,
+ onSelectSubOption,
+}: {
+ method: GiftPaymentMethod;
+ isSelected: boolean;
+ selectedSubOption: string | null;
+ onSelect: () => void;
+ onSelectSubOption: (subOptionId: string) => void;
+}) {
+ const hasSubOptions = method.sub_options && method.sub_options.length > 1;
+
+ return (
+
+
+
+ {/* Sub-options */}
+ {isSelected && hasSubOptions && (
+
+
+ {method.sub_options!.map((opt) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function RecipientSection({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
onChange(e.target.value)}
+ placeholder={t('gift.recipientPlaceholder', '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"
+ />
+
+ {t(
+ 'gift.recipientHint',
+ 'Enter the email or Telegram username of the person you want to gift',
+ )}
+
+
+
+ );
+}
+
+function GiftMessageSection({ value, onChange }: { value: string; onChange: (v: string) => void }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+function GiftSummaryCard({
+ config,
+ selectedTariff,
+ selectedPeriod,
+ currentPrice,
+ paymentMode,
+ isSubmitting,
+ canSubmit,
+ submitError,
+ insufficientBalance,
+ onSubmit,
+}: {
+ config: GiftConfig;
+ selectedTariff: GiftTariff | undefined;
+ selectedPeriod: GiftTariffPeriod | undefined;
+ currentPrice: number;
+ paymentMode: 'balance' | 'gateway';
+ isSubmitting: boolean;
+ canSubmit: boolean;
+ submitError: string | null;
+ insufficientBalance: boolean;
+ onSubmit: () => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {/* Summary */}
+
+ {selectedTariff && (
+
+
+ {t('gift.tariff', 'Tariff')}
+
+
{selectedTariff.name}
+
+ )}
+ {selectedPeriod && (
+
+
+ {t('gift.period', 'Period')}
+
+
+ {formatPeriodLabel(selectedPeriod.days, t)}
+
+
+ )}
+
+
+ {t('gift.total', 'Total')}
+
+
+ {formatPrice(currentPrice)}
+ {selectedPeriod?.original_price_kopeks != null &&
+ selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
+ <>
+
+ {formatPrice(selectedPeriod.original_price_kopeks)}
+
+ {selectedPeriod.discount_percent != null && (
+
+ -{selectedPeriod.discount_percent}%
+
+ )}
+ >
+ )}
+
+
+
+ {/* Balance info for balance mode */}
+ {paymentMode === 'balance' && (
+
+
+ {t('gift.yourBalance', 'Your balance')}
+
+
+ {formatPrice(config.balance_kopeks)}
+
+
+ )}
+
+
+ {/* Insufficient balance warning */}
+
+ {insufficientBalance && (
+
+
+ {t('gift.insufficientBalance', 'Insufficient balance.')}{' '}
+
+ {t('gift.topUpBalance', 'Top up balance')}
+
+
+
+ )}
+
+
+ {/* Error */}
+
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+ {/* Gift button */}
+
+
+ );
+}
+
+// ============================================================
+// Main Component
+// ============================================================
+
+export default function GiftSubscription() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+
+ // Fetch config
+ const {
+ data: config,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['gift-config'],
+ queryFn: giftApi.getConfig,
+ staleTime: 30_000,
+ });
+
+ // Selection state
+ const [selectedTariffId, setSelectedTariffId] = useState(null);
+ const [selectedPeriodDays, setSelectedPeriodDays] = useState(null);
+ const [recipientValue, setRecipientValue] = useState('');
+ const [giftMessage, setGiftMessage] = useState('');
+ const [paymentMode, setPaymentMode] = useState<'balance' | 'gateway'>('balance');
+ const [selectedMethod, setSelectedMethod] = useState(null);
+ const [selectedSubOption, setSelectedSubOption] = useState(null);
+ const [submitError, setSubmitError] = useState(null);
+
+ // Collect ALL unique periods across ALL tariffs
+ const allPeriods = useMemo(() => {
+ if (!config) return [];
+ const periodMap = new Map();
+ for (const tariff of config.tariffs) {
+ for (const period of tariff.periods) {
+ if (!periodMap.has(period.days)) {
+ periodMap.set(period.days, period);
+ }
+ }
+ }
+ return Array.from(periodMap.values()).sort((a, b) => a.days - b.days);
+ }, [config]);
+
+ // Filter tariffs to only those that have the selected period
+ const visibleTariffs = useMemo(() => {
+ if (!config || !selectedPeriodDays) return config?.tariffs ?? [];
+ return config.tariffs.filter((tariff) =>
+ tariff.periods.some((p) => p.days === selectedPeriodDays),
+ );
+ }, [config, selectedPeriodDays]);
+
+ // Auto-select first tariff, period, method on config load
+ useEffect(() => {
+ if (!config) return;
+
+ if (allPeriods.length > 0 && selectedPeriodDays === null) {
+ setSelectedPeriodDays(allPeriods[0].days);
+ }
+
+ if (visibleTariffs.length > 0 && selectedTariffId === null) {
+ setSelectedTariffId(visibleTariffs[0].id);
+ }
+
+ if (config.payment_methods.length > 0 && selectedMethod === null) {
+ const firstMethod = config.payment_methods[0];
+ setSelectedMethod(firstMethod.method_id);
+ if (firstMethod.sub_options && firstMethod.sub_options.length >= 1) {
+ setSelectedSubOption(firstMethod.sub_options[0].id);
+ } else {
+ setSelectedSubOption(null);
+ }
+ }
+ }, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
+
+ // When period changes, auto-select first visible tariff if current is hidden
+ useEffect(() => {
+ if (!visibleTariffs.length) return;
+ const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
+ if (!currentVisible) {
+ setSelectedTariffId(visibleTariffs[0].id);
+ }
+ }, [visibleTariffs, selectedTariffId]);
+
+ // Derived data
+ const selectedTariff = useMemo(
+ () => config?.tariffs.find((tr) => tr.id === selectedTariffId),
+ [config?.tariffs, selectedTariffId],
+ );
+
+ const selectedPeriod = useMemo(
+ () => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays),
+ [selectedTariff, selectedPeriodDays],
+ );
+
+ const currentPrice = selectedPeriod?.price_kopeks ?? 0;
+
+ const insufficientBalance =
+ paymentMode === 'balance' && config != null && config.balance_kopeks < currentPrice;
+
+ // Validation
+ const canSubmit = useMemo(() => {
+ if (!selectedTariffId || !selectedPeriodDays) return false;
+ if (!isValidContact(recipientValue)) return false;
+ if (paymentMode === 'gateway' && !selectedMethod) return false;
+ if (insufficientBalance) return false;
+ return true;
+ }, [
+ selectedTariffId,
+ selectedPeriodDays,
+ recipientValue,
+ paymentMode,
+ selectedMethod,
+ insufficientBalance,
+ ]);
+
+ // Purchase mutation
+ const purchaseMutation = useMutation({
+ mutationFn: (data: GiftPurchaseRequest) => giftApi.createPurchase(data),
+ onSuccess: (result) => {
+ if (result.payment_url) {
+ window.location.href = result.payment_url;
+ } else {
+ // Balance mode - show success
+ queryClient.invalidateQueries({ queryKey: ['balance'] });
+ queryClient.invalidateQueries({ queryKey: ['gift-config'] });
+ const params = new URLSearchParams({ token: result.purchase_token, mode: 'balance' });
+ if (result.warning) {
+ params.set('warning', result.warning);
+ }
+ navigate('/gift/result?' + params.toString());
+ }
+ },
+ onError: (err) => {
+ const msg = getApiErrorMessage(
+ err,
+ t('gift.failedDesc', 'Something went wrong. Please try again.'),
+ );
+ setSubmitError(msg);
+ },
+ });
+
+ // Submit handler
+ const handleSubmit = () => {
+ if (!selectedTariffId || !selectedPeriodDays || !canSubmit || purchaseMutation.isPending)
+ return;
+
+ setSubmitError(null);
+
+ let paymentMethod: string | undefined;
+ if (paymentMode === 'gateway' && selectedMethod) {
+ paymentMethod = selectedMethod;
+ if (selectedSubOption) {
+ paymentMethod = `${paymentMethod}_${selectedSubOption}`;
+ }
+ }
+
+ const data: GiftPurchaseRequest = {
+ tariff_id: selectedTariffId,
+ period_days: selectedPeriodDays,
+ recipient_type: detectContactType(recipientValue),
+ recipient_value: recipientValue.trim(),
+ gift_message: giftMessage.trim() || undefined,
+ payment_mode: paymentMode,
+ payment_method: paymentMethod,
+ };
+
+ purchaseMutation.mutate(data);
+ };
+
+ // Balance label with current amount
+ const balanceLabel = useMemo(() => {
+ if (!config) return t('gift.fromBalance', 'From balance');
+ return `${t('gift.fromBalance', 'From balance')} (${formatPrice(config.balance_kopeks)})`;
+ }, [config, t]);
+
+ // Loading state
+ if (isLoading) {
+ return ;
+ }
+
+ // Error state
+ if (error || !config) {
+ const errMsg = getApiErrorMessage(error, t('gift.notFound', 'Gift configuration not found'));
+ return ;
+ }
+
+ // Disabled state
+ if (!config.is_enabled) {
+ return ;
+ }
+
+ const showTariffCards = visibleTariffs.length > 1;
+
+ return (
+
+
+ {/* Header */}
+
+
+ {t('gift.title', 'Gift Subscription')}
+
+
+ {t('gift.subtitle', 'Give the gift of secure internet to someone special')}
+
+
+
+ {/* Two-column layout */}
+
+ {/* Left column */}
+
+ {/* Period tabs */}
+ {allPeriods.length > 0 && (
+
+
+ {t('gift.choosePeriod', 'Choose period')}
+
+
+
+ )}
+
+ {/* Tariff cards */}
+ {showTariffCards && (
+
+
+ {t('gift.chooseTariff', 'Choose tariff')}
+
+
+ {visibleTariffs.map((tariff) => {
+ const period = tariff.periods.find((p) => p.days === selectedPeriodDays);
+ return (
+ setSelectedTariffId(tariff.id)}
+ />
+ );
+ })}
+
+
+ )}
+
+ {/* Recipient */}
+
+
+ {t('gift.recipientLabel', 'Recipient')}
+
+ {
+ setRecipientValue(v);
+ setSubmitError(null);
+ }}
+ />
+
+
+ {/* Gift message */}
+
+
+ {t('gift.giftMessageLabel', 'Message')}
+
+
+
+
+ {/* Payment mode toggle */}
+
+
+ {t('gift.paymentMode', 'Payment method')}
+
+
+
+
+ {/* Payment method cards (gateway mode only) */}
+
+ {paymentMode === 'gateway' && config.payment_methods.length > 0 && (
+
+
+ {config.payment_methods.map((method) => (
+
{
+ setSelectedMethod(method.method_id);
+ if (method.sub_options && method.sub_options.length >= 1) {
+ setSelectedSubOption(method.sub_options[0].id);
+ } else {
+ setSelectedSubOption(null);
+ }
+ }}
+ onSelectSubOption={setSelectedSubOption}
+ />
+ ))}
+
+
+ )}
+
+
+
+ {/* Right column (sticky sidebar / bottom on mobile) */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index e346551..0b06de2 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -485,10 +485,7 @@ export default function Login() {
) : (
-
+
)}
diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx
new file mode 100644
index 0000000..9474b12
--- /dev/null
+++ b/src/pages/PurchaseSuccess.tsx
@@ -0,0 +1,756 @@
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { useParams, useNavigate, useSearchParams } from 'react-router';
+import { useQuery, useQueryClient } 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 { authApi } from '../api/auth';
+import { useAuthStore } from '../store/auth';
+import { copyToClipboard } from '../utils/clipboard';
+import { Spinner } from '@/components/ui/Spinner';
+import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
+import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark';
+import { cn } from '../lib/utils';
+
+const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes
+
+// ============================================================
+// Sub-components
+// ============================================================
+
+function PendingState() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t('landing.awaitingPayment', 'Awaiting payment')}
+
+
{t('landing.awaitingPaymentDesc')}
+
+
+ );
+}
+
+function CopyableField({ label, value }: { label: string; value: string }) {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ const timeoutRef = useRef
>(undefined);
+
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ };
+ }, []);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await copyToClipboard(value);
+ setCopied(true);
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
+ timeoutRef.current = setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Clipboard write failed silently
+ }
+ }, [value]);
+
+ return (
+
+
+
+
+ );
+}
+
+function CabinetCredentialsState({
+ cabinetEmail,
+ cabinetPassword,
+ autoLoginToken,
+ tariffName,
+ periodDays,
+}: {
+ cabinetEmail: string;
+ cabinetPassword: string | null;
+ autoLoginToken: string | null;
+ tariffName: string | null;
+ periodDays: number | null;
+}) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { setTokens, setUser, checkAdminStatus } = useAuthStore();
+ const [isLoggingIn, setIsLoggingIn] = useState(false);
+ const [loginError, setLoginError] = useState(false);
+
+ const handleGoToCabinet = useCallback(async () => {
+ if (!autoLoginToken) {
+ navigate('/login');
+ return;
+ }
+ setIsLoggingIn(true);
+ setLoginError(false);
+ try {
+ const response = await authApi.autoLogin(autoLoginToken);
+ setTokens(response.access_token, response.refresh_token);
+ setUser(response.user);
+ await checkAdminStatus();
+ navigate('/');
+ } catch {
+ setLoginError(true);
+ setIsLoggingIn(false);
+ }
+ }, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
+
+ return (
+
+
+
+ {/* Title */}
+
+
{t('landing.cabinetReady')}
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('landing.daysAccess')}
+
+ )}
+
+
+ {/* Credentials */}
+
+
+ {cabinetPassword && (
+
+ )}
+ {cabinetPassword &&
{t('landing.saveCredentials')}
}
+ {!cabinetPassword && (
+
{t('landing.credentialsSentToEmail')}
+ )}
+
+
+ {/* Go to Cabinet button */}
+
+ {loginError && {t('landing.autoLoginFailed')}
}
+
+ );
+}
+
+function SuccessState({
+ subscriptionUrl,
+ cryptoLink,
+ contactValue,
+ recipientContactValue,
+ tariffName,
+ periodDays,
+ isGift,
+ giftMessage,
+ recipientInBot,
+ botLink,
+ contactType,
+}: {
+ subscriptionUrl: string | null;
+ cryptoLink: string | null;
+ contactValue: string | null;
+ recipientContactValue: string | null;
+ tariffName: string | null;
+ periodDays: number | null;
+ isGift: boolean;
+ giftMessage: string | null;
+ recipientInBot: boolean | null;
+ botLink: string | null;
+ contactType: string | null;
+}) {
+ const { t } = useTranslation();
+ const [copied, setCopied] = useState(false);
+ const copyTimeoutRef = useRef>(undefined);
+
+ useEffect(() => {
+ return () => {
+ if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
+ };
+ }, []);
+
+ const handleCopy = useCallback(async () => {
+ const url = subscriptionUrl ?? cryptoLink;
+ if (!url) return;
+
+ try {
+ await copyToClipboard(url);
+ setCopied(true);
+ if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current);
+ copyTimeoutRef.current = setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Clipboard write failed silently
+ }
+ }, [subscriptionUrl, cryptoLink]);
+
+ const displayUrl = subscriptionUrl ?? cryptoLink;
+ const displayContact = isGift ? recipientContactValue : contactValue;
+
+ return (
+
+
+
+ {/* Title */}
+
+
+ {isGift ? t('landing.giftSentSuccess') : t('landing.purchaseSuccess')}
+
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('landing.daysAccess')}
+
+ )}
+ {isGift && contactType === 'telegram' && recipientInBot === true && (
+
{t('landing.giftTelegramSent')}
+ )}
+ {isGift && contactType === 'telegram' && recipientInBot !== true && (
+
{t('landing.giftTelegramNotInBot')}
+ )}
+ {!(isGift && contactType === 'telegram') && displayContact && (
+
+ {isGift
+ ? t('landing.giftSentTo', { contact: displayContact })
+ : t('landing.keySentTo', { contact: displayContact })}
+
+ )}
+ {isGift && giftMessage && (
+
+ {t('landing.giftMessage')}: {giftMessage}
+
+ )}
+
+
+ {/* Bot link for telegram gifts where recipient is not in bot */}
+ {isGift && contactType === 'telegram' && recipientInBot !== true && botLink && (
+
+ {t('landing.openBot')}
+
+ )}
+
+ {/* QR Code */}
+ {displayUrl && (
+
+
+
+
+
+ {/* Copy button */}
+
+
+ )}
+
+ );
+}
+
+function PendingActivationState({
+ tariffName,
+ periodDays,
+ giftMessage,
+ isGift,
+ isActivating,
+ onActivate,
+ autoLoginToken,
+}: {
+ tariffName: string | null;
+ periodDays: number | null;
+ giftMessage: string | null;
+ isGift: boolean;
+ isActivating: boolean;
+ onActivate: () => void;
+ autoLoginToken: string | null;
+}) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { setTokens, setUser, checkAdminStatus } = useAuthStore();
+ const [isLoggingIn, setIsLoggingIn] = useState(false);
+
+ const handleGoToCabinet = useCallback(async () => {
+ if (!autoLoginToken) {
+ navigate('/login');
+ return;
+ }
+ setIsLoggingIn(true);
+ try {
+ const response = await authApi.autoLogin(autoLoginToken);
+ setTokens(response.access_token, response.refresh_token);
+ setUser(response.user);
+ await checkAdminStatus();
+ navigate('/');
+ } catch {
+ setIsLoggingIn(false);
+ navigate('/login');
+ }
+ }, [autoLoginToken, navigate, setTokens, setUser, checkAdminStatus]);
+
+ return (
+
+ {/* Warning icon */}
+
+
+
+
{t('landing.pendingActivation')}
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('landing.daysAccess')}
+
+ )}
+
{t('landing.pendingActivationDesc')}
+ {isGift && giftMessage && (
+
+ {t('landing.giftMessage')}: {giftMessage}
+
+ )}
+
+
+
+
+
+ {autoLoginToken && (
+
+ )}
+
+
+ );
+}
+
+function GiftPendingActivationState({
+ tariffName,
+ periodDays,
+ recipientContactValue,
+ giftMessage,
+ recipientInBot,
+ botLink,
+ contactType,
+}: {
+ tariffName: string | null;
+ periodDays: number | null;
+ recipientContactValue: string | null;
+ giftMessage: string | null;
+ recipientInBot: boolean | null;
+ botLink: string | null;
+ contactType: string | null;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
{t('landing.giftSentSuccess')}
+ {tariffName && periodDays !== null && (
+
+ {tariffName} — {periodDays} {t('landing.daysAccess')}
+
+ )}
+ {contactType === 'telegram' && recipientInBot === true && (
+
{t('landing.giftTelegramPendingSent')}
+ )}
+ {contactType === 'telegram' && recipientInBot !== true && (
+
{t('landing.giftTelegramPendingNotInBot')}
+ )}
+ {contactType !== 'telegram' && (
+
{t('landing.giftPendingActivationDesc')}
+ )}
+ {contactType !== 'telegram' && recipientContactValue && (
+
+ {t('landing.giftSentTo', { contact: recipientContactValue })}
+
+ )}
+ {giftMessage && (
+
+ {t('landing.giftMessage')}: {giftMessage}
+
+ )}
+
+
+ {/* Bot link for telegram gifts where recipient is not in bot */}
+ {contactType === 'telegram' && recipientInBot !== true && botLink && (
+
+ {t('landing.openBot')}
+
+ )}
+
+ );
+}
+
+function FailedState() {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
{t('landing.purchaseFailed')}
+
{t('landing.purchaseFailedDesc')}
+
+
+ );
+}
+
+function PollTimedOutState({ onRetry }: { onRetry: () => void }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ {t('landing.pollTimedOut', 'Taking longer than expected')}
+
+
+ {t(
+ 'landing.pollTimedOutDesc',
+ 'Payment processing is taking longer than usual. You can try checking again.',
+ )}
+
+
+
+
+ );
+}
+
+// ============================================================
+// Main Component
+// ============================================================
+
+export default function PurchaseSuccess() {
+ const { t } = useTranslation();
+ const { token } = useParams<{ token: string }>();
+ const [searchParams] = useSearchParams();
+ const isActivateHint = searchParams.get('activate') === '1';
+ const pollStart = useRef(Date.now());
+ const [pollTimedOut, setPollTimedOut] = useState(false);
+ const [isActivating, setIsActivating] = useState(false);
+ const [activationError, setActivationError] = useState(false);
+ const activatingRef = useRef(false);
+
+ // 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 queryClient = useQueryClient();
+
+ const {
+ data: purchaseStatus,
+ isError,
+ refetch,
+ } = useQuery({
+ queryKey: ['purchase-status', token],
+ queryFn: () => landingApi.getPurchaseStatus(token!),
+ enabled: !!token && !pollTimedOut,
+ refetchInterval: (query) => {
+ const currentStatus = query.state.data?.status;
+ if (currentStatus === 'pending' || currentStatus === 'paid') {
+ if (Date.now() - pollStart.current > MAX_POLL_MS) {
+ setPollTimedOut(true);
+ return false;
+ }
+ return 3_000;
+ }
+ return false;
+ },
+ retry: 2,
+ });
+
+ const handleRetryPoll = useCallback(() => {
+ pollStart.current = Date.now();
+ setPollTimedOut(false);
+ refetch();
+ }, [refetch]);
+
+ const handleActivate = useCallback(async () => {
+ if (!token || activatingRef.current) return;
+ activatingRef.current = true;
+ setIsActivating(true);
+ setActivationError(false);
+ try {
+ const result = await landingApi.activatePurchase(token);
+ queryClient.setQueryData(['purchase-status', token], result);
+ } catch {
+ setActivationError(true);
+ } finally {
+ activatingRef.current = false;
+ setIsActivating(false);
+ }
+ }, [token, queryClient]);
+
+ const isSuccess = purchaseStatus?.status === 'delivered';
+ const isPendingActivation = purchaseStatus?.status === 'pending_activation';
+ const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired';
+
+ // Gift pending activation → buyer sees "gift sent" message, not the activate button.
+ // Recipient arrives via email link with ?activate=1 and sees the activate button instead.
+ const isGiftPendingActivation = isPendingActivation && purchaseStatus?.is_gift && !isActivateHint;
+
+ // Email self-purchase delivered → show cabinet credentials
+ const isEmailSelfPurchase =
+ isSuccess &&
+ purchaseStatus.contact_type === 'email' &&
+ !purchaseStatus.is_gift &&
+ purchaseStatus.cabinet_email;
+
+ return (
+
+
+ {isError ? (
+
+ ) : isEmailSelfPurchase ? (
+
+ ) : isSuccess ? (
+
+ ) : isGiftPendingActivation ? (
+
+ ) : isPendingActivation ? (
+
+
+ {activationError && (
+
{t('landing.activationFailed')}
+ )}
+
+ ) : isFailed ? (
+
+ ) : pollTimedOut ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx
new file mode 100644
index 0000000..2e18611
--- /dev/null
+++ b/src/pages/QuickPurchase.tsx
@@ -0,0 +1,1126 @@
+import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
+import { useParams } from 'react-router';
+import { useQuery, useMutation, useQueryClient } 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 { StaticBackgroundRenderer } from '../components/backgrounds/BackgroundRenderer';
+import LanguageSwitcher from '../components/LanguageSwitcher';
+import { cn } from '../lib/utils';
+import { getApiErrorMessage } from '../utils/api-error';
+import { formatPrice } from '../utils/format';
+
+function detectContactType(value: string): 'email' | 'telegram' {
+ return value.startsWith('@') ? 'telegram' : 'email';
+}
+
+function isValidContact(value: string): boolean {
+ const trimmed = value.trim();
+ if (!trimmed) return false;
+
+ if (trimmed.startsWith('@')) {
+ return trimmed.length >= 4;
+ }
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed);
+}
+
+function formatPeriodLabel(
+ days: number,
+ t: (key: string, options?: Record) => string,
+): string {
+ const key = `landing.periodLabels.d${days}`;
+ const result = t(key);
+ if (result !== key) return result;
+
+ const months = Math.floor(days / 30);
+ const remainder = days % 30;
+ if (months > 0 && remainder === 0) {
+ return t('landing.periodLabels.nMonths', { count: months });
+ }
+ return t('landing.periodLabels.nDays', { count: days });
+}
+
+// ============================================================
+// Sub-components
+// ============================================================
+
+function LoadingSkeleton() {
+ return (
+
+ );
+}
+
+function ErrorState({ message }: { message: string }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
{t('landing.error', 'Error')}
+
{message}
+
+
+ );
+}
+
+function PeriodTabs({
+ periods,
+ selectedDays,
+ onSelect,
+}: {
+ periods: LandingTariffPeriod[];
+ selectedDays: number;
+ onSelect: (days: number) => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ {periods.map((period) => (
+
+ ))}
+
+ );
+}
+
+function GiftToggle({ isGift, onToggle }: { isGift: boolean; onToggle: (v: boolean) => void }) {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+ );
+}
+
+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 (
+
+ {/* Main contact */}
+
+
+
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"
+ />
+
{t('landing.contactHint')}
+
+
+ {/* Gift fields */}
+
+ {isGift && (
+
+
+
+ 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"
+ />
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+function TariffCard({
+ tariff,
+ isSelected,
+ selectedPeriod,
+ onSelect,
+}: {
+ tariff: LandingTariff;
+ isSelected: boolean;
+ selectedPeriod: LandingTariffPeriod | undefined;
+ onSelect: () => void;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
+
+function PaymentMethodCard({
+ method,
+ isSelected,
+ selectedSubOption,
+ onSelect,
+ onSelectSubOption,
+}: {
+ method: LandingPaymentMethod;
+ isSelected: boolean;
+ selectedSubOption: string | null;
+ onSelect: () => void;
+ onSelectSubOption: (subOptionId: string) => void;
+}) {
+ const hasSubOptions = method.sub_options && method.sub_options.length > 1;
+
+ return (
+
+
+
+ {/* Sub-options */}
+ {isSelected && hasSubOptions && (
+
+
+ {method.sub_options!.map((opt) => (
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+function SanitizedHtml({ html, className }: { html: string; className?: string }) {
+ const sanitized = useMemo(() => {
+ const clean = DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: ['p', 'a', 'strong', 'em', 'b', 'i', 'br', 'span', 'ul', 'ol', 'li'],
+ ALLOWED_ATTR: ['href', 'target', 'rel'],
+ });
+ // Enforce rel="noopener noreferrer" and target="_blank" on all links
+ const container = document.createElement('div');
+ container.innerHTML = clean;
+ container.querySelectorAll('a').forEach((a) => {
+ a.setAttribute('rel', 'noopener noreferrer');
+ a.setAttribute('target', '_blank');
+ });
+ return container.innerHTML;
+ }, [html]);
+
+ return (
+
+ );
+}
+
+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 (
+
+ {/* Summary */}
+
+ {selectedTariff && (
+
+
+ {t('landing.selectedTariff', 'Tariff')}
+
+
{selectedTariff.name}
+
+ )}
+ {selectedPeriod && (
+
+
+ {t('landing.period', 'Period')}
+
+
+ {formatPeriodLabel(selectedPeriod.days, t)}
+
+
+ )}
+
+
+ {t('landing.total', 'Total')}
+
+
+ {formatPrice(currentPrice)}
+ {selectedPeriod?.original_price_kopeks != null &&
+ selectedPeriod.original_price_kopeks > selectedPeriod.price_kopeks && (
+ <>
+
+ {formatPrice(selectedPeriod.original_price_kopeks)}
+
+ {selectedPeriod.discount_percent != null && (
+
+ -{selectedPeriod.discount_percent}%
+
+ )}
+ >
+ )}
+
+
+
+
+ {/* Features */}
+ {config.features.length > 0 && (
+
+ {config.features.map((feature, idx) => (
+
+
+
+
{feature.title}
+
{feature.description}
+
+
+ ))}
+
+ )}
+
+ {/* Error */}
+
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+ {/* Pay button */}
+
+
+ {/* Footer */}
+ {config.footer_text && (
+
+ )}
+
+ );
+}
+
+// ============================================================
+// Discount Countdown
+// ============================================================
+
+function TimeUnit({ value, label }: { value: number; label: string }) {
+ return (
+
+
+ {String(value).padStart(2, '0')}
+
+ {label}
+
+ );
+}
+
+function calcTimeLeft(endTime: number) {
+ const diff = Math.max(0, endTime - Date.now());
+ return {
+ diff,
+ days: Math.floor(diff / 86_400_000),
+ hours: Math.floor((diff % 86_400_000) / 3_600_000),
+ minutes: Math.floor((diff % 3_600_000) / 60_000),
+ seconds: Math.floor((diff % 60_000) / 1_000),
+ };
+}
+
+function DiscountBanner({
+ discount,
+ onExpired,
+}: {
+ discount: { percent: number; ends_at: string; badge_text: string | null };
+ onExpired: () => void;
+}) {
+ const { t } = useTranslation();
+ const endTime = useMemo(() => new Date(discount.ends_at).getTime(), [discount.ends_at]);
+ const initial = useMemo(() => calcTimeLeft(endTime), [endTime]);
+ const [timeLeft, setTimeLeft] = useState(initial);
+ const intervalRef = useRef>(undefined);
+ const expiredRef = useRef(false);
+
+ useEffect(() => {
+ expiredRef.current = false;
+ const fresh = calcTimeLeft(endTime);
+ setTimeLeft(fresh);
+ if (fresh.diff === 0) {
+ if (!expiredRef.current) {
+ expiredRef.current = true;
+ onExpired();
+ }
+ return;
+ }
+
+ intervalRef.current = setInterval(() => {
+ const tl = calcTimeLeft(endTime);
+ setTimeLeft(tl);
+ if (tl.diff === 0) {
+ clearInterval(intervalRef.current);
+ if (!expiredRef.current) {
+ expiredRef.current = true;
+ onExpired();
+ }
+ }
+ }, 1000);
+
+ return () => clearInterval(intervalRef.current);
+ }, [endTime, onExpired]);
+
+ return (
+
+
+ {/* Left: badge + text */}
+
+
+ -{discount.percent}%
+
+ {discount.badge_text && (
+ {discount.badge_text}
+ )}
+
+
+ {/* Right: countdown */}
+
+ {timeLeft.days > 0 && (
+ <>
+
+ :
+ >
+ )}
+
+ :
+
+ :
+
+
+
+
+ );
+}
+
+// ============================================================
+// Main Component
+// ============================================================
+
+export default function QuickPurchase() {
+ const { slug } = useParams<{ slug: string }>();
+ const { t, i18n } = useTranslation();
+ const queryClient = useQueryClient();
+
+ // Fetch config
+ const {
+ data: config,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['landing-config', slug, i18n.language],
+ queryFn: () => landingApi.getConfig(slug!, i18n.language),
+ enabled: !!slug,
+ staleTime: 60_000,
+ retry: 1,
+ });
+
+ const [discountExpired, setDiscountExpired] = useState(false);
+
+ const handleDiscountExpired = useCallback(() => {
+ setDiscountExpired(true);
+ queryClient.invalidateQueries({ queryKey: ['landing-config', slug] });
+ }, [queryClient, slug]);
+
+ // Reset expired flag when config changes (e.g. new discount scheduled)
+ useEffect(() => {
+ if (config?.discount) setDiscountExpired(false);
+ }, [config?.discount]);
+
+ // Selection state
+ const [selectedTariffId, setSelectedTariffId] = useState(null);
+ const [selectedPeriodDays, setSelectedPeriodDays] = useState(null);
+ const [contactValue, setContactValue] = useState('');
+ const [isGift, setIsGift] = useState(false);
+ const [giftRecipient, setGiftRecipient] = useState('');
+ const [giftMessage, setGiftMessage] = useState('');
+ const [selectedMethod, setSelectedMethod] = useState(null);
+ const [selectedSubOption, setSelectedSubOption] = useState(null);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
+ const redirectTimeoutRef = useRef>(undefined);
+
+ // Cleanup redirect timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (redirectTimeoutRef.current) clearTimeout(redirectTimeoutRef.current);
+ };
+ }, []);
+
+ // Collect ALL unique periods across ALL tariffs
+ const allPeriods = useMemo(() => {
+ if (!config) return [];
+ const periodMap = new Map();
+ for (const tariff of config.tariffs) {
+ for (const period of tariff.periods) {
+ if (!periodMap.has(period.days)) {
+ periodMap.set(period.days, period);
+ }
+ }
+ }
+ return Array.from(periodMap.values()).sort((a, b) => a.days - b.days);
+ }, [config]);
+
+ // Filter tariffs to only those that have the selected period
+ const visibleTariffs = useMemo(() => {
+ if (!config || !selectedPeriodDays) return config?.tariffs ?? [];
+ return config.tariffs.filter((tariff) =>
+ tariff.periods.some((p) => p.days === selectedPeriodDays),
+ );
+ }, [config, selectedPeriodDays]);
+
+ // Auto-select first tariff, period, method on config load
+ useEffect(() => {
+ if (!config) return;
+
+ // Auto-select first period from all available periods
+ if (allPeriods.length > 0 && selectedPeriodDays === null) {
+ setSelectedPeriodDays(allPeriods[0].days);
+ }
+
+ // Auto-select first visible tariff
+ if (visibleTariffs.length > 0 && selectedTariffId === null) {
+ setSelectedTariffId(visibleTariffs[0].id);
+ }
+
+ if (config.payment_methods.length > 0 && selectedMethod === null) {
+ const firstMethod = config.payment_methods[0];
+ setSelectedMethod(firstMethod.method_id);
+ if (firstMethod.sub_options && firstMethod.sub_options.length >= 1) {
+ setSelectedSubOption(firstMethod.sub_options[0].id);
+ } else {
+ setSelectedSubOption(null);
+ }
+ }
+ }, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
+
+ // When period changes, auto-select first visible tariff if current is hidden
+ useEffect(() => {
+ if (!visibleTariffs.length) return;
+ const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
+ if (!currentVisible) {
+ setSelectedTariffId(visibleTariffs[0].id);
+ }
+ }, [visibleTariffs, selectedTariffId]);
+
+ // SEO: set document title
+ useEffect(() => {
+ if (!config?.meta_title) return;
+ const prev = document.title;
+ document.title = config.meta_title;
+ return () => {
+ document.title = prev;
+ };
+ }, [config?.meta_title]);
+
+ // SEO: set meta description
+ useEffect(() => {
+ if (!config?.meta_description) return;
+ let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
+ if (!meta) {
+ meta = document.createElement('meta');
+ meta.name = 'description';
+ document.head.appendChild(meta);
+ }
+ const prev = meta.content;
+ meta.content = config.meta_description;
+ return () => {
+ meta!.content = prev;
+ };
+ }, [config?.meta_description]);
+
+ // Inject custom CSS (sanitized)
+ useEffect(() => {
+ if (!config?.custom_css) return;
+
+ let css = config.custom_css;
+ // Strip all at-rules (including @font-face, @import, @charset, @namespace, @keyframes, @media)
+ css = css.replace(/@[a-zA-Z-]+\s*[^{}]*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g, '');
+ css = css.replace(/@[a-zA-Z-]+\s*[^;{}]+;/g, '');
+ // Strip ALL url() including data: URIs
+ css = css.replace(/url\s*\([^)]*\)/gi, 'url(about:blank)');
+ // Strip expression(), behavior, -moz-binding
+ css = css.replace(/expression\s*\([^)]*\)/gi, '');
+ css = css.replace(/behavior\s*:[^;]+/gi, '');
+ css = css.replace(/-moz-binding\s*:[^;]+/gi, '');
+ // Strip content property (prevents text injection via ::before/::after)
+ css = css.replace(/content\s*:[^;]+;/gi, '');
+ // Strip CSS escape sequences that could bypass filters
+ css = css.replace(/\\[0-9a-fA-F]{1,6}\s?/g, '');
+
+ const style = document.createElement('style');
+ style.setAttribute('data-landing-css', 'true');
+ style.textContent = css;
+ document.head.appendChild(style);
+
+ return () => {
+ style.remove();
+ };
+ }, [config?.custom_css]);
+
+ // Derived data
+ const selectedTariff = useMemo(
+ () => config?.tariffs.find((tr) => tr.id === selectedTariffId),
+ [config?.tariffs, selectedTariffId],
+ );
+
+ const selectedPeriod = useMemo(
+ () => selectedTariff?.periods.find((p) => p.days === selectedPeriodDays),
+ [selectedTariff, selectedPeriodDays],
+ );
+
+ const currentPrice = selectedPeriod?.price_kopeks ?? 0;
+
+ // Validation
+ const canSubmit = useMemo(() => {
+ if (!selectedTariffId || !selectedPeriodDays || !selectedMethod) return false;
+ if (!isValidContact(contactValue)) return false;
+ if (isGift && !isValidContact(giftRecipient)) return false;
+ return true;
+ }, [selectedTariffId, selectedPeriodDays, selectedMethod, contactValue, isGift, giftRecipient]);
+
+ // Purchase mutation
+ const purchaseMutation = useMutation({
+ mutationFn: (data: PurchaseRequest) => landingApi.createPurchase(slug!, data),
+ onSuccess: (result) => {
+ window.location.href = result.payment_url;
+ // If redirect blocked (popup blocker etc.), reset after 5s
+ redirectTimeoutRef.current = setTimeout(() => setIsSubmitting(false), 5000);
+ },
+ onError: (err) => {
+ const msg = getApiErrorMessage(
+ err,
+ t('landing.purchaseError', 'Something went wrong. Please try again.'),
+ );
+ setSubmitError(msg);
+ setIsSubmitting(false);
+ },
+ });
+
+ // Submit handler
+ const handleSubmit = () => {
+ if (!canSubmit || !slug || isSubmitting) return;
+
+ setIsSubmitting(true);
+ setSubmitError(null);
+
+ // Build the payment_method string: append sub-option suffix if selected
+ // e.g. "platega" + "2" → "platega_2", "yookassa" + "sbp" → "yookassa_sbp"
+ let paymentMethod = selectedMethod!;
+ if (selectedSubOption) {
+ paymentMethod = `${paymentMethod}_${selectedSubOption}`;
+ }
+
+ const data: PurchaseRequest = {
+ tariff_id: selectedTariffId!,
+ period_days: selectedPeriodDays!,
+ contact_type: detectContactType(contactValue),
+ contact_value: contactValue.trim(),
+ payment_method: paymentMethod,
+ is_gift: isGift,
+ };
+
+ if (isGift && giftRecipient) {
+ data.gift_recipient_type = detectContactType(giftRecipient);
+ data.gift_recipient_value = giftRecipient.trim();
+ data.gift_message = giftMessage.trim() || undefined;
+ }
+
+ purchaseMutation.mutate(data);
+ };
+
+ // Loading state
+ if (isLoading) {
+ return ;
+ }
+
+ // Error state
+ if (error || !config) {
+ const errMsg = getApiErrorMessage(error, t('landing.notFound', 'Landing page not found'));
+ return ;
+ }
+
+ const showTariffCards = visibleTariffs.length > 1;
+
+ return (
+
+ {config.background_config &&
}
+
+ {/* Language switcher */}
+
+
+
+
+ {/* Header */}
+
+
+ {config.title}
+
+ {config.subtitle && (
+ {config.subtitle}
+ )}
+
+
+ {/* Discount banner */}
+
+ {config.discount && !discountExpired && (
+
+ )}
+
+
+ {/* Two-column layout */}
+
+ {/* Left column */}
+
+ {/* Period tabs */}
+ {allPeriods.length > 0 && (
+
+
+ {t('landing.choosePeriod', 'Choose period')}
+
+
+
+ )}
+
+ {/* Gift toggle */}
+ {config.gift_enabled && }
+
+ {/* Contact form */}
+ {
+ setContactValue(v);
+ setSubmitError(null);
+ }}
+ isGift={isGift}
+ giftRecipient={giftRecipient}
+ onGiftRecipientChange={(v) => {
+ setGiftRecipient(v);
+ setSubmitError(null);
+ }}
+ giftMessage={giftMessage}
+ onGiftMessageChange={setGiftMessage}
+ />
+
+ {/* Tariff cards */}
+ {showTariffCards && (
+
+
+ {t('landing.chooseTariff', 'Choose tariff')}
+
+
+ {visibleTariffs.map((tariff) => {
+ const period = tariff.periods.find((p) => p.days === selectedPeriodDays);
+ return (
+ setSelectedTariffId(tariff.id)}
+ />
+ );
+ })}
+
+
+ )}
+
+ {/* Payment methods */}
+ {config.payment_methods.length > 0 && (
+
+
+ {t('landing.paymentMethod', 'Payment method')}
+
+
+ {config.payment_methods.map((method) => (
+
{
+ setSelectedMethod(method.method_id);
+ // Auto-select first sub-option (even for single — backend needs suffix)
+ if (method.sub_options && method.sub_options.length >= 1) {
+ setSelectedSubOption(method.sub_options[0].id);
+ } else {
+ setSelectedSubOption(null);
+ }
+ }}
+ onSelectSubOption={setSelectedSubOption}
+ />
+ ))}
+
+
+ )}
+
+
+ {/* Right column (sticky sidebar / bottom on mobile) */}
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx
index 761e3e7..64ec03c 100644
--- a/src/pages/Subscription.tsx
+++ b/src/pages/Subscription.tsx
@@ -211,6 +211,8 @@ export default function Subscription() {
const { data: purchaseOptions } = useQuery({
queryKey: ['purchase-options'],
queryFn: subscriptionApi.getPurchaseOptions,
+ staleTime: 0,
+ refetchOnMount: 'always',
});
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
@@ -250,6 +252,7 @@ export default function Subscription() {
mutationFn: () => subscriptionApi.togglePause(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
+ queryClient.invalidateQueries({ queryKey: ['balance'] });
},
});
@@ -275,6 +278,7 @@ export default function Subscription() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['devices'] });
+ queryClient.invalidateQueries({ queryKey: ['device-price'] });
setShowDeviceTopup(false);
setDevicesToAdd(1);
},
@@ -525,7 +529,9 @@ export default function Subscription() {
? subscription.is_trial
? t('subscription.trialStatus')
: t('subscription.active')
- : t('subscription.expired')}
+ : subscription.status === 'disabled'
+ ? t('subscription.pause.suspended')
+ : t('subscription.expired')}
@@ -598,7 +604,7 @@ export default function Subscription() {
className="font-mono text-[12px] font-semibold"
style={{ color: 'rgb(var(--color-accent-400))' }}
>
- {subscription.device_limit}
+ {subscription.device_limit === 0 ? '∞' : subscription.device_limit}
{t('subscription.devices')}
@@ -696,13 +702,22 @@ export default function Subscription() {
{t('dashboard.connectDevice')}
- {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,
+ })}