mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
refactor(coupons): единый formatShortDate + честная статистика списка + retry
- formatShortDate вынесен в utils/format вместо трёх копий formatDate + инлайн localeMap (AdminCoupons/AdminCouponDetail/CouponStatus) - список партий: агрегатные карточки Активны/Погашено/Отозвано считались только по текущей странице и противоречили глобальному total «Партий» — показываем их лишь когда весь набор влезает на одну страницу - CouponStatus: retry:false на публичном статусе (404 — ожидаемый ответ на невалидный/погашенный токен, ретрай зря бьёт rate-limited эндпоинт)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user