From 35428cc27d45335c11b8999391bd8a388090fe7b Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 8 Jun 2026 00:23:31 +0300 Subject: [PATCH] fix(promocode): correct activation UX, error mapping and validity dates Map promocode errors by stable backend code (detail.code) instead of brittle English substring matching, so previously-unmapped codes (active_discount_exists, daily_limit, no_subscription_for_days, trial_*, etc.) show the right message instead of 'server error'; added the missing keys to all 4 locales. valid_until now anchors to end-of-day in the admin's LOCAL timezone on both create AND edit read-back, so codes no longer expire at the start of the day and editing no longer drifts the date forward for negative-offset admins. Allow 0-hour discount ('until first purchase'). Route user-facing discount deactivation to the endpoint that rolls back promocode usage when the discount came from a promocode, and parse the structured {code,message} error. --- src/components/PromoOffersSection.tsx | 26 +++++++++++++--- src/locales/en.json | 7 +++++ src/locales/fa.json | 7 +++++ src/locales/ru.json | 7 +++++ src/locales/zh.json | 7 +++++ src/pages/AdminPromocodeCreate.tsx | 24 ++++++++++++--- src/pages/Balance.tsx | 43 +++++++++++++++++---------- 7 files changed, 97 insertions(+), 24 deletions(-) diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index 00f22df..cb26698 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -118,9 +118,21 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio }, }); - // Deactivate discount mutation + // Deactivate discount mutation. + // A discount granted by a PROMOCODE must be turned off via the dedicated route + // (POST /cabinet/promocode/deactivate-discount), which also rolls back the promocode + // usage (deletes the PromoCodeUse, decrements current_uses, strips the promo group) so + // the user can re-activate it later. The plain clearActiveDiscount route only zeroes + // the discount fields and is correct for admin/offer-sourced discounts. const deactivateMutation = useMutation({ - mutationFn: promoApi.clearActiveDiscount, + mutationFn: async () => { + const source = activeDiscount?.source ?? ''; + if (source.startsWith('promocode:')) { + await promoApi.deactivateDiscount(); + } else { + await promoApi.clearActiveDiscount(); + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['active-discount'] }); queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); @@ -131,8 +143,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio setTimeout(() => setSuccessMessage(null), 5000); }, onError: (error: unknown) => { - const axiosErr = error as { response?: { data?: { detail?: string } } }; - setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error')); + // detail may be a plain string (clearActiveDiscount route) or a structured + // { code, message } object (deactivate-discount route). Handle both. + const axiosErr = error as { + response?: { data?: { detail?: string | { message?: string } } }; + }; + const detail = axiosErr.response?.data?.detail; + const message = typeof detail === 'string' ? detail : detail?.message; + setErrorMessage(message || t('promo.deactivate.error')); setTimeout(() => setErrorMessage(null), 5000); }, diff --git a/src/locales/en.json b/src/locales/en.json index 32fdaad..4fcbc57 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -787,6 +787,13 @@ "not_yet_valid": "Promo code is not yet active", "used": "Promo code has already been used", "already_used_by_user": "You have already used this promo code", + "active_discount_exists": "You already have an active discount. Deactivate it first.", + "no_subscription_for_days": "This promo code requires an active or expired subscription", + "subscription_not_found": "Subscription not found", + "not_first_purchase": "This promo code is only available for your first purchase", + "daily_limit": "Too many promo code activations today", + "trial_subscription_exists": "You already have a subscription, so this trial code can't be applied", + "trial_provisioning_failed": "Couldn't provision the trial right now, please try again later", "user_not_found": "User not found", "server_error": "Server error" }, diff --git a/src/locales/fa.json b/src/locales/fa.json index 847874b..b6ef96c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -674,6 +674,13 @@ "not_yet_valid": "کد تخفیف هنوز فعال نشده", "used": "کد تخفیف قبلاً استفاده شده", "already_used_by_user": "شما قبلاً از این کد استفاده کرده‌اید", + "active_discount_exists": "شما در حال حاضر یک تخفیف فعال دارید. ابتدا آن را غیرفعال کنید.", + "no_subscription_for_days": "این کد تخفیف به اشتراک فعال یا منقضی‌شده نیاز دارد", + "subscription_not_found": "اشتراک یافت نشد", + "not_first_purchase": "این کد تخفیف فقط برای اولین خرید در دسترس است", + "daily_limit": "تعداد فعال‌سازی کد تخفیف امروز بیش از حد است", + "trial_subscription_exists": "شما در حال حاضر اشتراک دارید، بنابراین این کد آزمایشی قابل اعمال نیست", + "trial_provisioning_failed": "در حال حاضر امکان فعال‌سازی نسخه آزمایشی نیست، لطفاً بعداً تلاش کنید", "user_not_found": "کاربر یافت نشد", "server_error": "خطای سرور" }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 588db82..1d4f2e5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -816,6 +816,13 @@ "not_yet_valid": "Промокод ещё не начал действовать", "used": "Промокод уже использован", "already_used_by_user": "Вы уже использовали этот промокод", + "active_discount_exists": "У вас уже есть активная скидка. Сначала отключите её.", + "no_subscription_for_days": "Для этого промокода нужна активная или истёкшая подписка", + "subscription_not_found": "Подписка не найдена", + "not_first_purchase": "Промокод доступен только для первой покупки", + "daily_limit": "Слишком много активаций промокодов за сегодня", + "trial_subscription_exists": "У вас уже есть подписка, поэтому триал-промокод применить нельзя", + "trial_provisioning_failed": "Не удалось выдать триал прямо сейчас, попробуйте позже", "user_not_found": "Пользователь не найден", "server_error": "Ошибка сервера" }, diff --git a/src/locales/zh.json b/src/locales/zh.json index 727becb..585c8d4 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -674,6 +674,13 @@ "not_yet_valid": "优惠码尚未生效", "used": "优惠码已被使用", "already_used_by_user": "您已使用过此优惠码", + "active_discount_exists": "您已有一个生效中的折扣,请先停用它。", + "no_subscription_for_days": "此优惠码需要有效或已过期的订阅", + "subscription_not_found": "未找到订阅", + "not_first_purchase": "此优惠码仅限首次购买使用", + "daily_limit": "今日优惠码激活次数过多", + "trial_subscription_exists": "您已有订阅,因此无法使用此试用优惠码", + "trial_provisioning_failed": "暂时无法开通试用,请稍后再试", "user_not_found": "用户不存在", "server_error": "服务器错误" }, diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx index 76bb1ba..f36462a 100644 --- a/src/pages/AdminPromocodeCreate.tsx +++ b/src/pages/AdminPromocodeCreate.tsx @@ -16,6 +16,16 @@ import { tariffsApi } from '../api/tariffs'; import { usePlatform } from '../platform/hooks/usePlatform'; import { BackIcon, RefreshIcon } from '@/components/icons'; +// valid_until is created as end-of-day in the admin's LOCAL tz, then stored/returned +// as a UTC instant. Reading the picker back must convert UTC -> local date, otherwise +// negative-offset admins see tomorrow's date and re-saving drifts the expiry forward a +// day each time. (Mirror of DateField's local toISO; must NOT slice the raw UTC string.) +const utcInstantToLocalDateInput = (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())}`; +}; + export default function AdminPromocodeCreate() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -76,7 +86,7 @@ export default function AdminPromocodeCreate() { setMaxUses(data.max_uses || 1); setIsActive(data.is_active ?? true); setFirstPurchaseOnly(data.first_purchase_only || false); - setValidUntil(data.valid_until ? data.valid_until.split('T')[0] : ''); + setValidUntil(data.valid_until ? utcInstantToLocalDateInput(data.valid_until) : ''); setPromoGroupId(data.promo_group_id || null); setTariffId(data.tariff_id || null); return data; @@ -120,7 +130,12 @@ export default function AdminPromocodeCreate() { max_uses: maxUsesValue, is_active: isActive, first_purchase_only: firstPurchaseOnly, - valid_until: validUntil ? new Date(validUntil).toISOString() : null, + // The picker yields a date-only 'YYYY-MM-DD'. A promo "valid until D" must + // stay valid through the WHOLE of day D, so anchor to end-of-day in the + // admin's local timezone. `new Date('YYYY-MM-DD')` parses as UTC midnight + // (the START of the day) — for a GMT+3 admin that made a code picked for + // "today" already expired by 3am, surfacing as a bogus "expired" error. + valid_until: validUntil ? new Date(`${validUntil}T23:59:59`).toISOString() : null, promo_group_id: type === 'promo_group' ? promoGroupId : null, ...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}), }; @@ -145,7 +160,8 @@ export default function AdminPromocodeCreate() { if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0) return false; if (type === 'promo_group' && !promoGroupId) return false; - if (type === 'discount' && (balanceValue <= 0 || balanceValue > 100 || daysValue <= 0)) + // For discount, validity hours of 0 = "until first purchase" (perpetual) — allowed. + if (type === 'discount' && (balanceValue <= 0 || balanceValue > 100 || daysValue < 0)) return false; return true; }; @@ -410,7 +426,7 @@ export default function AdminPromocodeCreate() { } }} className="input w-32" - min={1} + min={0} placeholder="0" /> {t('admin.promocodes.form.hours')} diff --git a/src/pages/Balance.tsx b/src/pages/Balance.tsx index 7dfb2bd..729355b 100644 --- a/src/pages/Balance.tsx +++ b/src/pages/Balance.tsx @@ -159,22 +159,33 @@ export default function Balance() { queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); } } catch (error: unknown) { - const axiosError = error as { response?: { data?: { detail?: string } } }; - const errorDetail = axiosError.response?.data?.detail || 'server_error'; - const detail = errorDetail.toLowerCase(); - const errorKey = detail.includes('not found') - ? 'not_found' - : detail.includes('deactivated') - ? 'inactive' - : detail.includes('not yet active') - ? 'not_yet_valid' - : detail.includes('expired') - ? 'expired' - : detail.includes('fully used') - ? 'used' - : detail.includes('already used') - ? 'already_used_by_user' - : 'server_error'; + // Backend returns a structured error: detail = { code, message }. We map + // the stable machine code to a localized string. (The old contract + // substring-matched English prose and silently degraded every unmapped + // code — active_discount_exists, daily_limit, … — to "server error".) + const axiosError = error as { + response?: { data?: { detail?: { code?: string } | string } }; + }; + const detail = axiosError.response?.data?.detail; + const code = typeof detail === 'object' && detail ? detail.code : undefined; + const knownErrorKeys = [ + 'not_found', + 'expired', + 'inactive', + 'not_yet_valid', + 'used', + 'already_used_by_user', + 'active_discount_exists', + 'no_subscription_for_days', + 'subscription_not_found', + 'not_first_purchase', + 'daily_limit', + 'trial_subscription_exists', + 'trial_provisioning_failed', + 'user_not_found', + 'server_error', + ]; + const errorKey = code && knownErrorKeys.includes(code) ? code : 'server_error'; setPromocodeError(t(`balance.promocode.errors.${errorKey}`)); setPromoSelectSubs(null); setPromoSelectCode(null);