refactor(coupons): единый formatShortDate + честная статистика списка + retry

- formatShortDate вынесен в utils/format вместо трёх копий formatDate +
  инлайн localeMap (AdminCoupons/AdminCouponDetail/CouponStatus)
- список партий: агрегатные карточки Активны/Погашено/Отозвано считались
  только по текущей странице и противоречили глобальному total «Партий» —
  показываем их лишь когда весь набор влезает на одну страницу
- CouponStatus: retry:false на публичном статусе (404 — ожидаемый ответ на
  невалидный/погашенный токен, ретрай зря бьёт rate-limited эндпоинт)
This commit is contained in:
Fringg
2026-07-12 00:34:41 +03:00
parent 799e986d84
commit b011b55f04
4 changed files with 36 additions and 44 deletions

View File

@@ -6,7 +6,7 @@ import i18n from '../i18n';
import { couponsApi } from '../api/coupons';
import { usePlatform } from '../platform/hooks/usePlatform';
import { copyToClipboard } from '../utils/clipboard';
import { formatPrice } from '../utils/format';
import { formatPrice, formatShortDate } from '../utils/format';
import { getApiErrorMessage } from '../utils/api-error';
import { useToast } from '../components/Toast';
import { PermissionGate } from '../components/auth/PermissionGate';
@@ -21,17 +21,6 @@ import {
} from '@/components/icons';
import { StatCard } from '../components/stats';
const formatDate = (date: string | null): string => {
if (!date) return '-';
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
const locale = localeMap[i18n.language] || 'ru-RU';
return new Date(date).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
export default function AdminCouponDetail() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -184,13 +173,13 @@ export default function AdminCouponDetail() {
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
<span className="text-dark-100">
{batch.valid_until
? formatDate(batch.valid_until)
? formatShortDate(batch.valid_until)
: t('admin.coupons.detail.perpetual')}
</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('admin.coupons.detail.createdAt')}</span>
<span className="text-dark-100">{formatDate(batch.created_at)}</span>
<span className="text-dark-100">{formatShortDate(batch.created_at)}</span>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { couponsApi, CouponBatch } from '../api/coupons';
import { usePlatform } from '../platform/hooks/usePlatform';
import { formatPrice } from '../utils/format';
import { formatPrice, formatShortDate } from '../utils/format';
import {
BackIcon,
PlusIcon,
@@ -18,17 +18,6 @@ import { StatCard } from '../components/stats';
const PAGE_SIZE = 50;
const formatDate = (date: string | null): string => {
if (!date) return '-';
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
const locale = localeMap[i18n.language] || 'ru-RU';
return new Date(date).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
export default function AdminCoupons() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -74,8 +63,10 @@ export default function AdminCoupons() {
</button>
</div>
{/* Stats Overview */}
{batches.length > 0 && (
{/* Stats Overview — the Active/Redeemed/Revoked cards sum only the loaded
page, so show them only when the whole set fits one page; otherwise
they would contradict the global "Batches" total. */}
{batches.length > 0 && total <= PAGE_SIZE && (
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard
label={t('admin.coupons.stats.batches')}
@@ -159,7 +150,7 @@ export default function AdminCoupons() {
)}
{batch.valid_until && (
<span>
{t('admin.coupons.list.until')}: {formatDate(batch.valid_until)}
{t('admin.coupons.list.until')}: {formatShortDate(batch.valid_until)}
</span>
)}
</div>

View File

@@ -5,22 +5,11 @@ import { useTranslation } from 'react-i18next';
import axios from 'axios';
import { couponsApi, CouponRedeemResponse } from '../api/coupons';
import { useAuthStore } from '../store/auth';
import i18n from '../i18n';
import { formatShortDate } from '../utils/format';
import { Spinner } from '@/components/ui/Spinner';
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
import { TicketIcon } from '@/components/icons';
const formatDate = (date: string | null): string => {
if (!date) return '-';
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
const locale = localeMap[i18n.language] || 'ru-RU';
return new Date(date).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4 py-10">
@@ -60,7 +49,10 @@ export default function CouponStatus() {
queryKey: ['coupon-status', token],
queryFn: () => couponsApi.getCouponStatus(token!),
enabled: !!token,
retry: 1,
// 404 is the expected answer for an invalid/consumed/expired token — the
// common case for a shared link — so retrying only wastes a request against
// the rate-limited public endpoint.
retry: false,
});
const redeemMutation = useMutation({
@@ -110,7 +102,7 @@ export default function CouponStatus() {
{redeemed.end_date && (
<>
<br />
{t('coupon.success.until')} {formatDate(redeemed.end_date)}
{t('coupon.success.until')} {formatShortDate(redeemed.end_date)}
</>
)}
</p>
@@ -142,7 +134,9 @@ export default function CouponStatus() {
{coupon.valid_until && (
<div className="flex justify-between gap-4">
<span className="text-dark-400">{t('coupon.validUntil')}</span>
<span className="font-medium text-dark-100">{formatDate(coupon.valid_until)}</span>
<span className="font-medium text-dark-100">
{formatShortDate(coupon.valid_until)}
</span>
</div>
)}
</div>

View File

@@ -59,3 +59,21 @@ export function formatPrice(kopeks: number, lang?: string): string {
return `${rounded} ${config.symbol}`;
}
}
const SHORT_DATE_LOCALE_MAP: Record<string, string> = {
ru: 'ru-RU',
en: 'en-US',
zh: 'zh-CN',
fa: 'fa-IR',
};
/** Date-only (dd.mm.yyyy) in the active UI locale; '-' for a null date. */
export function formatShortDate(date: string | null): string {
if (!date) return '-';
const locale = SHORT_DATE_LOCALE_MAP[i18next.language] || 'ru-RU';
return new Date(date).toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}