diff --git a/src/api/landings.ts b/src/api/landings.ts index 2b41bb4..947dcad 100644 --- a/src/api/landings.ts +++ b/src/api/landings.ts @@ -33,8 +33,22 @@ export interface LandingPaymentMethod { description: string | null; icon_url: string | null; sort_order: number; + min_amount_kopeks: number | null; + max_amount_kopeks: number | null; + currency: string | null; + return_url: string | null; } +/** Editable fields on a payment method in the landing editor */ +export type EditableMethodField = + | 'display_name' + | 'description' + | 'icon_url' + | 'min_amount_kopeks' + | 'max_amount_kopeks' + | 'currency' + | 'return_url'; + export interface LandingConfig { slug: string; title: string; @@ -67,13 +81,15 @@ export interface PurchaseResponse { } export interface PurchaseStatus { - status: 'pending' | 'paid' | 'delivered' | 'failed' | 'expired'; + status: 'pending' | 'paid' | 'delivered' | 'pending_activation' | 'failed' | 'expired'; subscription_url: string | null; subscription_crypto_link: string | null; is_gift: boolean; contact_value: string | null; + recipient_contact_value: string | null; period_days: number | null; tariff_name: string | null; + gift_message: string | null; } // ============================================================ @@ -203,6 +219,11 @@ export const landingApi = { const response = await apiClient.get(`/cabinet/landing/purchase/${token}`); return response.data; }, + + activatePurchase: async (token: string): Promise => { + const response = await apiClient.post(`/cabinet/landing/activate/${token}`); + return response.data; + }, }; // ============================================================ diff --git a/src/locales/en.json b/src/locales/en.json index 16558f9..c186498 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3964,6 +3964,12 @@ "copyLink": "Copy link", "giftToggleLabel": "Purchase type", "pollTimedOut": "Taking longer than expected", - "pollTimedOutDesc": "Payment processing is taking longer than usual. You can try checking again." + "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" } } diff --git a/src/locales/fa.json b/src/locales/fa.json index 49b426f..e4188f0 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3515,6 +3515,12 @@ "copyLink": "کپی لینک", "giftToggleLabel": "\u0646\u0648\u0639 \u062e\u0631\u06cc\u062f", "pollTimedOut": "\u0632\u0645\u0627\u0646 \u0628\u06cc\u0634\u062a\u0631\u06cc \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f", - "pollTimedOutDesc": "\u067e\u0631\u062f\u0627\u0632\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0628\u06cc\u0634\u062a\u0631 \u0627\u0632 \u062d\u062f \u0645\u0639\u0645\u0648\u0644 \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f\u0647 \u0627\u0633\u062a. \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc\u062f \u062f\u0648\u0628\u0627\u0631\u0647 \u0628\u0631\u0631\u0633\u06cc \u06a9\u0646\u06cc\u062f." + "pollTimedOutDesc": "\u067e\u0631\u062f\u0627\u0632\u0634 \u067e\u0631\u062f\u0627\u062e\u062a \u0628\u06cc\u0634\u062a\u0631 \u0627\u0632 \u062d\u062f \u0645\u0639\u0645\u0648\u0644 \u0637\u0648\u0644 \u06a9\u0634\u06cc\u062f\u0647 \u0627\u0633\u062a. \u0645\u06cc\u200c\u062a\u0648\u0627\u0646\u06cc\u062f \u062f\u0648\u0628\u0627\u0631\u0647 \u0628\u0631\u0631\u0633\u06cc \u06a9\u0646\u06cc\u062f.", + "pendingActivation": "اشتراک آماده فعال‌سازی", + "pendingActivationDesc": "شما قبلاً اشتراک فعالی دارید. فعال‌سازی اشتراک جدید جایگزین اشتراک فعلی می‌شود.", + "activateNow": "فعال‌سازی", + "activating": "در حال فعال‌سازی...", + "activationFailed": "خطا در فعال‌سازی", + "giftMessage": "پیام" } } diff --git a/src/locales/ru.json b/src/locales/ru.json index 35b82d4..dcc866b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4527,6 +4527,12 @@ "copyLink": "Скопировать ссылку", "giftToggleLabel": "Тип покупки", "pollTimedOut": "Занимает больше времени, чем ожидалось", - "pollTimedOutDesc": "Обработка платежа занимает больше времени, чем обычно. Вы можете попробовать проверить ещё раз." + "pollTimedOutDesc": "Обработка платежа занимает больше времени, чем обычно. Вы можете попробовать проверить ещё раз.", + "pendingActivation": "Подписка готова к активации", + "pendingActivationDesc": "У вас уже есть активная подписка. Активация новой заменит текущую.", + "activateNow": "Активировать", + "activating": "Активация...", + "activationFailed": "Ошибка активации", + "giftMessage": "Сообщение" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 8e35549..19db57b 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3514,6 +3514,12 @@ "copyLink": "复制链接", "giftToggleLabel": "\u8d2d\u4e70\u7c7b\u578b", "pollTimedOut": "\u5904\u7406\u65f6\u95f4\u8f83\u957f", - "pollTimedOutDesc": "\u4ed8\u6b3e\u5904\u7406\u65f6\u95f4\u6bd4\u5e73\u65f6\u957f\u3002\u60a8\u53ef\u4ee5\u5c1d\u8bd5\u518d\u6b21\u68c0\u67e5\u3002" + "pollTimedOutDesc": "\u4ed8\u6b3e\u5904\u7406\u65f6\u95f4\u6bd4\u5e73\u65f6\u957f\u3002\u60a8\u53ef\u4ee5\u5c1d\u8bd5\u518d\u6b21\u68c0\u67e5\u3002", + "pendingActivation": "订阅准备激活", + "pendingActivationDesc": "您已有活跃订阅。激活新订阅将替换当前订阅。", + "activateNow": "立即激活", + "activating": "激活中...", + "activationFailed": "激活失败", + "giftMessage": "留言" } } diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index 6fb1729..32349da 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -7,6 +7,7 @@ import { type LandingCreateRequest, type AdminLandingFeature, type LandingPaymentMethod, + type EditableMethodField, type LocaleDict, type SupportedLocale, toLocaleDict, @@ -139,10 +140,13 @@ function SortableFeatureItem({ interface SortableSelectedMethodProps { method: MethodWithId; + onUpdate: (methodId: string, field: EditableMethodField, value: string | number | null) => void; onRemove: (methodId: string) => void; } -function SortableSelectedMethodCard({ method, onRemove }: SortableSelectedMethodProps) { +function SortableSelectedMethodCard({ method, onUpdate, onRemove }: SortableSelectedMethodProps) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: method._id, }); @@ -159,24 +163,142 @@ function SortableSelectedMethodCard({ method, onRemove }: SortableSelectedMethod ref={setNodeRef} style={style} className={cn( - 'flex items-center gap-2 rounded-lg border px-3 py-2', + 'rounded-lg border', isDragging ? 'border-accent-500/50 bg-dark-700' : 'border-dark-700 bg-dark-800/50', )} > - - {method.display_name} - +
+ + + + +
+ {expanded && ( +
+
+ + onUpdate(method.method_id, 'display_name', e.target.value)} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+
+ + onUpdate(method.method_id, 'description', e.target.value || null)} + placeholder={t('admin.landings.methodDescPlaceholder', 'Optional description')} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+
+ + onUpdate(method.method_id, 'icon_url', e.target.value || null)} + placeholder="https://..." + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+
+
+ + + onUpdate( + method.method_id, + 'min_amount_kopeks', + e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null, + ) + } + placeholder="—" + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+
+ + + onUpdate( + method.method_id, + 'max_amount_kopeks', + e.target.value ? Math.max(0, Math.floor(Number(e.target.value))) : null, + ) + } + placeholder="—" + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+
+
+ + onUpdate(method.method_id, 'currency', e.target.value || null)} + placeholder="RUB" + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+
+ + onUpdate(method.method_id, 'return_url', e.target.value || null)} + placeholder={t( + 'admin.landings.methodReturnUrlPlaceholder', + 'Default: cabinet success page. Use {token} for purchase token', + )} + className="w-full rounded-lg border border-dark-700 bg-dark-800 px-3 py-1.5 text-sm text-dark-100 outline-none focus:border-accent-500" + /> +
+
+ )} ); } @@ -336,7 +458,14 @@ export default function AdminLandingEditor() { setSelectedTariffIds(landingData.allowed_tariff_ids ?? []); setAllowedPeriods(landingData.allowed_periods ?? {}); setPaymentMethods( - (landingData.payment_methods ?? []).map((m) => ({ ...m, _id: crypto.randomUUID() })), + (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, + })), ); setGiftEnabled(landingData.gift_enabled); setFooterText(toLocaleDict(landingData.footer_text)); @@ -410,6 +539,10 @@ export default function AdminLandingEditor() { ...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, })); // Filter out empty period arrays and periods for non-selected tariffs @@ -480,31 +613,47 @@ export default function AdminLandingEditor() { }, []); // ---- Payment methods helpers ---- - const togglePaymentMethod = (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; - return [ - ...prev, - { - _id: crypto.randomUUID(), - method_id: systemMethod.method_id, - display_name: systemMethod.display_name ?? systemMethod.default_display_name, - description: '', - icon_url: '', - sort_order: prev.length, - }, - ]; - }); - }; + 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; + 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, + }, + ]; + }); + }, + [availablePaymentMethods], + ); - const removePaymentMethod = (methodId: string) => { + 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 removePaymentMethod = useCallback((methodId: string) => { setPaymentMethods((prev) => prev.filter((m) => m.method_id !== methodId)); - }; + }, []); const handleMethodDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event; @@ -857,6 +1006,7 @@ export default function AdminLandingEditor() { ))} diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx index bc4ddf1..c904ab4 100644 --- a/src/pages/PurchaseSuccess.tsx +++ b/src/pages/PurchaseSuccess.tsx @@ -49,16 +49,20 @@ function SuccessState({ subscriptionUrl, cryptoLink, contactValue, + recipientContactValue, tariffName, periodDays, isGift, + giftMessage, }: { subscriptionUrl: string | null; cryptoLink: string | null; contactValue: string | null; + recipientContactValue: string | null; tariffName: string | null; periodDays: number | null; isGift: boolean; + giftMessage: string | null; }) { const { t } = useTranslation(); const [copied, setCopied] = useState(false); @@ -85,6 +89,7 @@ function SuccessState({ }, [subscriptionUrl, cryptoLink]); const displayUrl = subscriptionUrl ?? cryptoLink; + const displayContact = isGift ? recipientContactValue : contactValue; return ( )} - {contactValue && ( + {displayContact && (

{isGift - ? t('landing.giftSentTo', { contact: contactValue }) - : t('landing.keySentTo', { contact: contactValue })} + ? t('landing.giftSentTo', { contact: displayContact }) + : t('landing.keySentTo', { contact: displayContact })} +

+ )} + {isGift && giftMessage && ( +

+ {t('landing.giftMessage')}: {giftMessage}

)} @@ -199,6 +209,85 @@ function SuccessState({ ); } +function PendingActivationState({ + tariffName, + periodDays, + giftMessage, + isGift, + isActivating, + onActivate, +}: { + tariffName: string | null; + periodDays: number | null; + giftMessage: string | null; + isGift: boolean; + isActivating: boolean; + onActivate: () => void; +}) { + const { t } = useTranslation(); + + return ( + + {/* Warning icon */} +
+ + + +
+ +
+

{t('landing.pendingActivation')}

+ {tariffName && periodDays && ( +

+ {tariffName} — {periodDays} {t('landing.daysAccess')} +

+ )} +

{t('landing.pendingActivationDesc')}

+ {isGift && giftMessage && ( +

+ {t('landing.giftMessage')}: {giftMessage} +

+ )} +
+ + +
+ ); +} + function FailedState() { const { t } = useTranslation(); @@ -278,9 +367,13 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) { // ============================================================ export default function PurchaseSuccess() { + const { t } = useTranslation(); const { token } = useParams<{ token: string }>(); 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(() => { @@ -294,7 +387,7 @@ export default function PurchaseSuccess() { }, []); const { - data: status, + data: purchaseStatus, isError, refetch, } = useQuery({ @@ -321,8 +414,25 @@ export default function PurchaseSuccess() { refetch(); }, [refetch]); - const isSuccess = status?.status === 'delivered'; - const isFailed = status?.status === 'failed' || status?.status === 'expired'; + const handleActivate = useCallback(async () => { + if (!token || activatingRef.current) return; + activatingRef.current = true; + setIsActivating(true); + setActivationError(false); + try { + await landingApi.activatePurchase(token); + await refetch(); + } catch { + setActivationError(true); + } finally { + activatingRef.current = false; + setIsActivating(false); + } + }, [token, refetch]); + + const isSuccess = purchaseStatus?.status === 'delivered'; + const isPendingActivation = purchaseStatus?.status === 'pending_activation'; + const isFailed = purchaseStatus?.status === 'failed' || purchaseStatus?.status === 'expired'; return (
@@ -331,13 +441,29 @@ export default function PurchaseSuccess() { ) : isSuccess ? ( + ) : isPendingActivation ? ( +
+ + {activationError && ( +

{t('landing.activationFailed')}

+ )} +
) : isFailed ? ( ) : pollTimedOut ? (