mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-29 18:13:47 +00:00
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.
This commit is contained in:
@@ -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({
|
const deactivateMutation = useMutation({
|
||||||
mutationFn: promoApi.clearActiveDiscount,
|
mutationFn: async () => {
|
||||||
|
const source = activeDiscount?.source ?? '';
|
||||||
|
if (source.startsWith('promocode:')) {
|
||||||
|
await promoApi.deactivateDiscount();
|
||||||
|
} else {
|
||||||
|
await promoApi.clearActiveDiscount();
|
||||||
|
}
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||||
@@ -131,8 +143,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
setTimeout(() => setSuccessMessage(null), 5000);
|
setTimeout(() => setSuccessMessage(null), 5000);
|
||||||
},
|
},
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
// detail may be a plain string (clearActiveDiscount route) or a structured
|
||||||
setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error'));
|
// { 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);
|
setTimeout(() => setErrorMessage(null), 5000);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -787,6 +787,13 @@
|
|||||||
"not_yet_valid": "Promo code is not yet active",
|
"not_yet_valid": "Promo code is not yet active",
|
||||||
"used": "Promo code has already been used",
|
"used": "Promo code has already been used",
|
||||||
"already_used_by_user": "You have already used this promo code",
|
"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",
|
"user_not_found": "User not found",
|
||||||
"server_error": "Server error"
|
"server_error": "Server error"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -674,6 +674,13 @@
|
|||||||
"not_yet_valid": "کد تخفیف هنوز فعال نشده",
|
"not_yet_valid": "کد تخفیف هنوز فعال نشده",
|
||||||
"used": "کد تخفیف قبلاً استفاده شده",
|
"used": "کد تخفیف قبلاً استفاده شده",
|
||||||
"already_used_by_user": "شما قبلاً از این کد استفاده کردهاید",
|
"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": "کاربر یافت نشد",
|
"user_not_found": "کاربر یافت نشد",
|
||||||
"server_error": "خطای سرور"
|
"server_error": "خطای سرور"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -816,6 +816,13 @@
|
|||||||
"not_yet_valid": "Промокод ещё не начал действовать",
|
"not_yet_valid": "Промокод ещё не начал действовать",
|
||||||
"used": "Промокод уже использован",
|
"used": "Промокод уже использован",
|
||||||
"already_used_by_user": "Вы уже использовали этот промокод",
|
"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": "Пользователь не найден",
|
"user_not_found": "Пользователь не найден",
|
||||||
"server_error": "Ошибка сервера"
|
"server_error": "Ошибка сервера"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -674,6 +674,13 @@
|
|||||||
"not_yet_valid": "优惠码尚未生效",
|
"not_yet_valid": "优惠码尚未生效",
|
||||||
"used": "优惠码已被使用",
|
"used": "优惠码已被使用",
|
||||||
"already_used_by_user": "您已使用过此优惠码",
|
"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": "用户不存在",
|
"user_not_found": "用户不存在",
|
||||||
"server_error": "服务器错误"
|
"server_error": "服务器错误"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ import { tariffsApi } from '../api/tariffs';
|
|||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { BackIcon, RefreshIcon } from '@/components/icons';
|
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() {
|
export default function AdminPromocodeCreate() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -76,7 +86,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
setMaxUses(data.max_uses || 1);
|
setMaxUses(data.max_uses || 1);
|
||||||
setIsActive(data.is_active ?? true);
|
setIsActive(data.is_active ?? true);
|
||||||
setFirstPurchaseOnly(data.first_purchase_only || false);
|
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);
|
setPromoGroupId(data.promo_group_id || null);
|
||||||
setTariffId(data.tariff_id || null);
|
setTariffId(data.tariff_id || null);
|
||||||
return data;
|
return data;
|
||||||
@@ -120,7 +130,12 @@ export default function AdminPromocodeCreate() {
|
|||||||
max_uses: maxUsesValue,
|
max_uses: maxUsesValue,
|
||||||
is_active: isActive,
|
is_active: isActive,
|
||||||
first_purchase_only: firstPurchaseOnly,
|
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,
|
promo_group_id: type === 'promo_group' ? promoGroupId : null,
|
||||||
...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
|
...(type === 'trial_subscription' && tariffId ? { tariff_id: tariffId } : {}),
|
||||||
};
|
};
|
||||||
@@ -145,7 +160,8 @@ export default function AdminPromocodeCreate() {
|
|||||||
if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0)
|
if ((type === 'subscription_days' || type === 'trial_subscription') && daysValue <= 0)
|
||||||
return false;
|
return false;
|
||||||
if (type === 'promo_group' && !promoGroupId) 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 false;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -410,7 +426,7 @@ export default function AdminPromocodeCreate() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="input w-32"
|
className="input w-32"
|
||||||
min={1}
|
min={0}
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
<span className="text-dark-400">{t('admin.promocodes.form.hours')}</span>
|
<span className="text-dark-400">{t('admin.promocodes.form.hours')}</span>
|
||||||
|
|||||||
@@ -159,22 +159,33 @@ export default function Balance() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const axiosError = error as { response?: { data?: { detail?: string } } };
|
// Backend returns a structured error: detail = { code, message }. We map
|
||||||
const errorDetail = axiosError.response?.data?.detail || 'server_error';
|
// the stable machine code to a localized string. (The old contract
|
||||||
const detail = errorDetail.toLowerCase();
|
// substring-matched English prose and silently degraded every unmapped
|
||||||
const errorKey = detail.includes('not found')
|
// code — active_discount_exists, daily_limit, … — to "server error".)
|
||||||
? 'not_found'
|
const axiosError = error as {
|
||||||
: detail.includes('deactivated')
|
response?: { data?: { detail?: { code?: string } | string } };
|
||||||
? 'inactive'
|
};
|
||||||
: detail.includes('not yet active')
|
const detail = axiosError.response?.data?.detail;
|
||||||
? 'not_yet_valid'
|
const code = typeof detail === 'object' && detail ? detail.code : undefined;
|
||||||
: detail.includes('expired')
|
const knownErrorKeys = [
|
||||||
? 'expired'
|
'not_found',
|
||||||
: detail.includes('fully used')
|
'expired',
|
||||||
? 'used'
|
'inactive',
|
||||||
: detail.includes('already used')
|
'not_yet_valid',
|
||||||
? 'already_used_by_user'
|
'used',
|
||||||
: 'server_error';
|
'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}`));
|
setPromocodeError(t(`balance.promocode.errors.${errorKey}`));
|
||||||
setPromoSelectSubs(null);
|
setPromoSelectSubs(null);
|
||||||
setPromoSelectCode(null);
|
setPromoSelectCode(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user