mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge PR #473: раздел оптовых купонов + публичная страница активации
UI для фичи «Купоны» (backend — remnawave-bot #3044): - админ-раздел /admin/coupons: список партий со статистикой, форма создания с готовыми ссылками (копировать/скачать .txt), карточка партии с экспортом и отзывом непогашенных за PermissionGate coupons:edit - публичная /coupon/:token: партнёр видит тариф/срок, кнопки активации в боте и (для залогиненных) в кабинете с маппингом машинных кодов ошибок - локали ru/en; zh/fa через ru-фолбэк Ревью-фиксы поверх PR: - единый formatShortDate вместо 3 копий formatDate + инлайн localeMap - статистика списка показывается только для одной страницы (суммы по странице иначе противоречат глобальному total) - retry:false на публичном статусе (404 — штатный ответ)
This commit is contained in:
42
src/App.tsx
42
src/App.tsx
@@ -94,6 +94,10 @@ const AdminBroadcasts = lazyWithRetry(() => import('./pages/AdminBroadcasts'));
|
|||||||
const AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate'));
|
const AdminBroadcastCreate = lazyWithRetry(() => import('./pages/AdminBroadcastCreate'));
|
||||||
const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes'));
|
const AdminPromocodes = lazyWithRetry(() => import('./pages/AdminPromocodes'));
|
||||||
const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate'));
|
const AdminPromocodeCreate = lazyWithRetry(() => import('./pages/AdminPromocodeCreate'));
|
||||||
|
const AdminCoupons = lazyWithRetry(() => import('./pages/AdminCoupons'));
|
||||||
|
const AdminCouponCreate = lazyWithRetry(() => import('./pages/AdminCouponCreate'));
|
||||||
|
const AdminCouponDetail = lazyWithRetry(() => import('./pages/AdminCouponDetail'));
|
||||||
|
const CouponStatus = lazyWithRetry(() => import('./pages/CouponStatus'));
|
||||||
const AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats'));
|
const AdminPromocodeStats = lazyWithRetry(() => import('./pages/AdminPromocodeStats'));
|
||||||
const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups'));
|
const AdminPromoGroups = lazyWithRetry(() => import('./pages/AdminPromoGroups'));
|
||||||
const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate'));
|
const AdminPromoGroupCreate = lazyWithRetry(() => import('./pages/AdminPromoGroupCreate'));
|
||||||
@@ -294,6 +298,14 @@ function App() {
|
|||||||
</LazyPage>
|
</LazyPage>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/coupon/:token"
|
||||||
|
element={
|
||||||
|
<LazyPage>
|
||||||
|
<CouponStatus />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/buy/:slug"
|
path="/buy/:slug"
|
||||||
element={
|
element={
|
||||||
@@ -839,6 +851,36 @@ function App() {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/coupons"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="coupons:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminCoupons />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/coupons/create"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="coupons:create">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminCouponCreate />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/coupons/:id"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="coupons:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminCouponDetail />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/promocodes/:id/stats"
|
path="/admin/promocodes/:id/stats"
|
||||||
element={
|
element={
|
||||||
|
|||||||
112
src/api/coupons.ts
Normal file
112
src/api/coupons.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// ============== Types ==============
|
||||||
|
|
||||||
|
export interface CouponBatch {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tariff_id: number | null;
|
||||||
|
tariff_name: string | null;
|
||||||
|
period_days: number;
|
||||||
|
coupons_total: number;
|
||||||
|
wholesale_price_kopeks: number;
|
||||||
|
valid_until: string | null;
|
||||||
|
is_revoked: boolean;
|
||||||
|
created_at: string;
|
||||||
|
active_count: number;
|
||||||
|
redeemed_count: number;
|
||||||
|
revoked_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponBatchListResponse {
|
||||||
|
items: CouponBatch[];
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponBatchCreateRequest {
|
||||||
|
name: string;
|
||||||
|
tariff_id: number;
|
||||||
|
period_days: number;
|
||||||
|
coupons_count: number;
|
||||||
|
wholesale_price_kopeks?: number;
|
||||||
|
valid_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponBatchCreated extends CouponBatch {
|
||||||
|
links: string[];
|
||||||
|
tokens: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponBatchLinks {
|
||||||
|
batch_id: number;
|
||||||
|
count: number;
|
||||||
|
links: string[];
|
||||||
|
tokens: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponBatchRevokeResponse {
|
||||||
|
revoked_count: number;
|
||||||
|
batch: CouponBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponRedeemResponse {
|
||||||
|
success: boolean;
|
||||||
|
tariff_name: string;
|
||||||
|
period_days: number;
|
||||||
|
renewed: boolean;
|
||||||
|
end_date: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CouponStatus {
|
||||||
|
tariff_name: string;
|
||||||
|
period_days: number;
|
||||||
|
valid_until: string | null;
|
||||||
|
bot_link: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============== API ==============
|
||||||
|
|
||||||
|
export const couponsApi = {
|
||||||
|
// Admin: batches
|
||||||
|
getBatches: async (params?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<CouponBatchListResponse> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/coupons', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBatch: async (id: number): Promise<CouponBatch> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/coupons/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createBatch: async (data: CouponBatchCreateRequest): Promise<CouponBatchCreated> => {
|
||||||
|
const response = await apiClient.post('/cabinet/admin/coupons', data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getBatchLinks: async (id: number): Promise<CouponBatchLinks> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/coupons/${id}/links`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
revokeBatch: async (id: number): Promise<CouponBatchRevokeResponse> => {
|
||||||
|
const response = await apiClient.post(`/cabinet/admin/coupons/${id}/revoke`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// User: redeem a coupon for the current cabinet user
|
||||||
|
redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
|
||||||
|
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Public: coupon info by token (no auth)
|
||||||
|
getCouponStatus: async (token: string): Promise<CouponStatus> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/coupon/${token}/status`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1241,6 +1241,7 @@
|
|||||||
"campaigns": "Campaigns",
|
"campaigns": "Campaigns",
|
||||||
"promoOffers": "Promo Offers",
|
"promoOffers": "Promo Offers",
|
||||||
"promocodes": "Promo Codes",
|
"promocodes": "Promo Codes",
|
||||||
|
"coupons": "Coupons",
|
||||||
"promoGroups": "Discount Groups",
|
"promoGroups": "Discount Groups",
|
||||||
"trafficUsage": "Traffic Usage",
|
"trafficUsage": "Traffic Usage",
|
||||||
"updates": "Updates",
|
"updates": "Updates",
|
||||||
@@ -3031,6 +3032,84 @@
|
|||||||
"description": "This will remove partner privileges. The partner will no longer earn commissions from referrals."
|
"description": "This will remove partner privileges. The partner will no longer earn commissions from referrals."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"coupons": {
|
||||||
|
"title": "Coupons",
|
||||||
|
"subtitle": "Wholesale batches of one-time subscription links",
|
||||||
|
"addBatch": "Create batch",
|
||||||
|
"noBatches": "No batches yet",
|
||||||
|
"days": "d.",
|
||||||
|
"stats": {
|
||||||
|
"batches": "Batches",
|
||||||
|
"active": "Active",
|
||||||
|
"redeemed": "Redeemed",
|
||||||
|
"revoked": "Revoked"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"revoked": "Revoked",
|
||||||
|
"until": "Until",
|
||||||
|
"price": "Wholesale"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"prev": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"page": "Page {{current}} of {{total}}"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "New coupon batch",
|
||||||
|
"subtitle": "Every coupon is a one-time subscription link",
|
||||||
|
"name": "Batch name",
|
||||||
|
"namePlaceholder": "E.g. the partner name",
|
||||||
|
"tariff": "Tariff",
|
||||||
|
"selectTariff": "Select a tariff",
|
||||||
|
"periodDays": "Subscription days per coupon",
|
||||||
|
"couponsCount": "Number of coupons (1–500)",
|
||||||
|
"price": "Wholesale price per coupon, ₽",
|
||||||
|
"priceHint": "Bookkeeping only, 0 — not set",
|
||||||
|
"validDays": "Coupon lifetime, days",
|
||||||
|
"validDaysHint": "0 or empty — perpetual",
|
||||||
|
"create": "Create batch",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"cancel": "Cancel"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Enter the batch name",
|
||||||
|
"tariffRequired": "Select a tariff",
|
||||||
|
"periodInvalid": "Subscription days: 1 to 3650",
|
||||||
|
"countInvalid": "Number of coupons: 1 to 500"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"title": "Batch created",
|
||||||
|
"linksLabel": "Links ({{count}})",
|
||||||
|
"copyAll": "Copy all",
|
||||||
|
"copied": "Copied",
|
||||||
|
"download": "Download .txt",
|
||||||
|
"toList": "Back to batches",
|
||||||
|
"toBatch": "Open batch"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Batch #{{id}}",
|
||||||
|
"total": "Total coupons",
|
||||||
|
"price": "Wholesale price",
|
||||||
|
"priceTotal": "total",
|
||||||
|
"validUntil": "Valid until",
|
||||||
|
"perpetual": "Perpetual",
|
||||||
|
"createdAt": "Created",
|
||||||
|
"linksTitle": "Active links ({{count}})"
|
||||||
|
},
|
||||||
|
"revoke": {
|
||||||
|
"button": "Revoke unredeemed ({{count}})",
|
||||||
|
"confirmTitle": "Revoke the batch?",
|
||||||
|
"confirmText": "{{count}} unredeemed coupons will be revoked. Their links will stop working. This cannot be undone.",
|
||||||
|
"confirm": "Revoke",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"success": "Coupons revoked: {{count}}"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Batch not found",
|
||||||
|
"createFailed": "Failed to create the batch",
|
||||||
|
"revokeFailed": "Failed to revoke the batch"
|
||||||
|
}
|
||||||
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
"title": "Promo Codes",
|
"title": "Promo Codes",
|
||||||
"subtitle": "Manage promo codes and discount groups",
|
"subtitle": "Manage promo codes and discount groups",
|
||||||
@@ -5086,6 +5165,34 @@
|
|||||||
"nMonths": "{{count}} months"
|
"nMonths": "{{count}} months"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"coupon": {
|
||||||
|
"title": "Subscription coupon",
|
||||||
|
"subtitle": "A one-time coupon — redeem it to get or extend a subscription",
|
||||||
|
"tariff": "Tariff",
|
||||||
|
"period": "Period",
|
||||||
|
"daysSuffix": "d.",
|
||||||
|
"validUntil": "Redeem before",
|
||||||
|
"openBot": "Redeem in the bot",
|
||||||
|
"redeemCabinet": "Redeem in the cabinet",
|
||||||
|
"redeeming": "Redeeming…",
|
||||||
|
"needAuth": "Sign in to redeem the coupon in the cabinet — or open the link in the bot",
|
||||||
|
"notFound": {
|
||||||
|
"title": "Coupon not found",
|
||||||
|
"text": "The coupon does not exist, was already used or has expired"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"activated": "Subscription activated!",
|
||||||
|
"renewed": "Subscription extended!",
|
||||||
|
"until": "Valid until"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalid": "Coupon not found or already used",
|
||||||
|
"expired": "The coupon has expired",
|
||||||
|
"already_redeemed_by_you": "You have already redeemed this coupon",
|
||||||
|
"internal": "Server error, try again later",
|
||||||
|
"generic": "Failed to redeem the coupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"gift": {
|
"gift": {
|
||||||
"title": "Gift Subscription",
|
"title": "Gift Subscription",
|
||||||
"subtitle": "Send a VPN subscription as a gift",
|
"subtitle": "Send a VPN subscription as a gift",
|
||||||
|
|||||||
@@ -1263,6 +1263,7 @@
|
|||||||
"campaigns": "Кампании",
|
"campaigns": "Кампании",
|
||||||
"promoOffers": "Промопредложения",
|
"promoOffers": "Промопредложения",
|
||||||
"promocodes": "Промокоды",
|
"promocodes": "Промокоды",
|
||||||
|
"coupons": "Купоны",
|
||||||
"promoGroups": "Группы скидок",
|
"promoGroups": "Группы скидок",
|
||||||
"trafficUsage": "Расход трафика",
|
"trafficUsage": "Расход трафика",
|
||||||
"updates": "Обновления",
|
"updates": "Обновления",
|
||||||
@@ -3433,6 +3434,84 @@
|
|||||||
"description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов."
|
"description": "Это лишит партнёра привилегий. Партнёр больше не будет получать комиссию от рефералов."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"coupons": {
|
||||||
|
"title": "Купоны",
|
||||||
|
"subtitle": "Оптовые партии одноразовых ссылок на подписку",
|
||||||
|
"addBatch": "Создать партию",
|
||||||
|
"noBatches": "Партий пока нет",
|
||||||
|
"days": "дн.",
|
||||||
|
"stats": {
|
||||||
|
"batches": "Партий",
|
||||||
|
"active": "Активных",
|
||||||
|
"redeemed": "Погашено",
|
||||||
|
"revoked": "Отозвано"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"revoked": "Отозвана",
|
||||||
|
"until": "До",
|
||||||
|
"price": "Опт"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"prev": "Назад",
|
||||||
|
"next": "Вперёд",
|
||||||
|
"page": "Стр. {{current}} из {{total}}"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"title": "Новая партия купонов",
|
||||||
|
"subtitle": "Каждый купон — одноразовая ссылка на подписку",
|
||||||
|
"name": "Название партии",
|
||||||
|
"namePlaceholder": "Например, имя партнёра",
|
||||||
|
"tariff": "Тариф",
|
||||||
|
"selectTariff": "Выберите тариф",
|
||||||
|
"periodDays": "Дней подписки на купон",
|
||||||
|
"couponsCount": "Количество купонов (1–500)",
|
||||||
|
"price": "Оптовая цена за купон, ₽",
|
||||||
|
"priceHint": "Только для учёта, 0 — не указывать",
|
||||||
|
"validDays": "Срок действия купонов, дней",
|
||||||
|
"validDaysHint": "0 или пусто — бессрочно",
|
||||||
|
"create": "Создать партию",
|
||||||
|
"creating": "Создание…",
|
||||||
|
"cancel": "Отмена"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Укажите название партии",
|
||||||
|
"tariffRequired": "Выберите тариф",
|
||||||
|
"periodInvalid": "Дней подписки: от 1 до 3650",
|
||||||
|
"countInvalid": "Количество купонов: от 1 до 500"
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"title": "Партия создана",
|
||||||
|
"linksLabel": "Ссылки ({{count}} шт.)",
|
||||||
|
"copyAll": "Скопировать все",
|
||||||
|
"copied": "Скопировано",
|
||||||
|
"download": "Скачать .txt",
|
||||||
|
"toList": "К списку партий",
|
||||||
|
"toBatch": "К партии"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"title": "Партия #{{id}}",
|
||||||
|
"total": "Всего купонов",
|
||||||
|
"price": "Оптовая цена",
|
||||||
|
"priceTotal": "итого",
|
||||||
|
"validUntil": "Действует до",
|
||||||
|
"perpetual": "Бессрочно",
|
||||||
|
"createdAt": "Создана",
|
||||||
|
"linksTitle": "Активные ссылки ({{count}} шт.)"
|
||||||
|
},
|
||||||
|
"revoke": {
|
||||||
|
"button": "Отозвать непогашенные ({{count}})",
|
||||||
|
"confirmTitle": "Отозвать партию?",
|
||||||
|
"confirmText": "Будет отозвано {{count}} непогашенных купонов. Их ссылки перестанут работать. Действие необратимо.",
|
||||||
|
"confirm": "Отозвать",
|
||||||
|
"cancel": "Отмена",
|
||||||
|
"success": "Отозвано купонов: {{count}}"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Партия не найдена",
|
||||||
|
"createFailed": "Не удалось создать партию",
|
||||||
|
"revokeFailed": "Не удалось отозвать партию"
|
||||||
|
}
|
||||||
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
"title": "Промокоды",
|
"title": "Промокоды",
|
||||||
"subtitle": "Управление промокодами и группами скидок",
|
"subtitle": "Управление промокодами и группами скидок",
|
||||||
@@ -5643,6 +5722,34 @@
|
|||||||
"nMonths": "{{count}} мес"
|
"nMonths": "{{count}} мес"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"coupon": {
|
||||||
|
"title": "Купон на подписку",
|
||||||
|
"subtitle": "Одноразовый купон — активируйте, чтобы получить или продлить подписку",
|
||||||
|
"tariff": "Тариф",
|
||||||
|
"period": "Срок",
|
||||||
|
"daysSuffix": "дн.",
|
||||||
|
"validUntil": "Активировать до",
|
||||||
|
"openBot": "Активировать в боте",
|
||||||
|
"redeemCabinet": "Активировать в кабинете",
|
||||||
|
"redeeming": "Активация…",
|
||||||
|
"needAuth": "Чтобы активировать купон в кабинете, войдите в аккаунт — или откройте ссылку в боте",
|
||||||
|
"notFound": {
|
||||||
|
"title": "Купон не найден",
|
||||||
|
"text": "Купон не существует, уже использован или истёк"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"activated": "Подписка активирована!",
|
||||||
|
"renewed": "Подписка продлена!",
|
||||||
|
"until": "Действует до"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"invalid": "Купон не найден или уже использован",
|
||||||
|
"expired": "Срок действия купона истёк",
|
||||||
|
"already_redeemed_by_you": "Вы уже активировали этот купон",
|
||||||
|
"internal": "Ошибка сервера, попробуйте позже",
|
||||||
|
"generic": "Не удалось активировать купон"
|
||||||
|
}
|
||||||
|
},
|
||||||
"gift": {
|
"gift": {
|
||||||
"title": "Подарить подписку",
|
"title": "Подарить подписку",
|
||||||
"subtitle": "Отправьте VPN-подписку в подарок",
|
"subtitle": "Отправьте VPN-подписку в подарок",
|
||||||
|
|||||||
298
src/pages/AdminCouponCreate.tsx
Normal file
298
src/pages/AdminCouponCreate.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { createNumberInputHandler } from '../utils/inputHelpers';
|
||||||
|
import { couponsApi, CouponBatchCreated } from '../api/coupons';
|
||||||
|
import { tariffsApi } from '../api/tariffs';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
|
import { BackIcon, CheckIcon, CopyIcon, DownloadIcon } from '@/components/icons';
|
||||||
|
|
||||||
|
const downloadLinksFile = (batch: CouponBatchCreated | { id: number; links: string[] }) => {
|
||||||
|
const blob = new Blob([batch.links.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `coupons_batch_${batch.id}.txt`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminCouponCreate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [tariffId, setTariffId] = useState<number | null>(null);
|
||||||
|
const [periodDays, setPeriodDays] = useState<number | ''>(30);
|
||||||
|
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
|
||||||
|
const [priceRubles, setPriceRubles] = useState<number | ''>('');
|
||||||
|
const [validDays, setValidDays] = useState<number | ''>('');
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Result state (batch created — show the generated links)
|
||||||
|
const [created, setCreated] = useState<CouponBatchCreated | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const { data: tariffsData } = useQuery({
|
||||||
|
queryKey: ['admin-tariffs-for-coupon'],
|
||||||
|
queryFn: () => tariffsApi.getTariffs(true),
|
||||||
|
});
|
||||||
|
const tariffs = (tariffsData?.tariffs || []).filter((tariff) => tariff.is_active);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: couponsApi.createBatch,
|
||||||
|
onSuccess: (batch) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
|
||||||
|
setCreated(batch);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setServerError(getApiErrorMessage(err, t('admin.coupons.errors.createFailed')));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (!name.trim()) errors.push('nameRequired');
|
||||||
|
if (!tariffId) errors.push('tariffRequired');
|
||||||
|
if (!periodDays || periodDays < 1 || periodDays > 3650) errors.push('periodInvalid');
|
||||||
|
if (!couponsCount || couponsCount < 1 || couponsCount > 500) errors.push('countInvalid');
|
||||||
|
setValidationErrors(errors);
|
||||||
|
return errors.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
setServerError(null);
|
||||||
|
if (!validate()) return;
|
||||||
|
createMutation.mutate({
|
||||||
|
name: name.trim(),
|
||||||
|
tariff_id: tariffId!,
|
||||||
|
period_days: Number(periodDays),
|
||||||
|
coupons_count: Number(couponsCount),
|
||||||
|
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
|
||||||
|
valid_days: validDays === '' ? 0 : Number(validDays),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyAll = () => {
|
||||||
|
if (!created) return;
|
||||||
|
void copyToClipboard(created.links.join('\n'));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: the batch is created — show the one-time links
|
||||||
|
if (created) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl animate-fade-in">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.created.title')}</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
#{created.id} {created.name} · {created.tariff_name} · {created.period_days}{' '}
|
||||||
|
{t('admin.coupons.days')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.coupons.created.linksLabel', { count: created.links.length })}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyAll}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
{copied ? t('admin.coupons.created.copied') : t('admin.coupons.created.copyAll')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => downloadLinksFile(created)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
{t('admin.coupons.created.download')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={created.links.join('\n')}
|
||||||
|
rows={Math.min(12, created.links.length)}
|
||||||
|
className="input w-full resize-none font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => navigate('/admin/coupons')} className="btn-secondary flex-1">
|
||||||
|
{t('admin.coupons.created.toList')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/coupons/${created.id}`)}
|
||||||
|
className="btn-primary flex-1"
|
||||||
|
>
|
||||||
|
{t('admin.coupons.created.toBatch')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/coupons')}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.form.title')}</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.coupons.form.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Validation errors */}
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4">
|
||||||
|
<ul className="list-inside list-disc space-y-1 text-sm text-error-400">
|
||||||
|
{validationErrors.map((error) => (
|
||||||
|
<li key={error}>{t(`admin.coupons.validation.${error}`)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{serverError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||||
|
{serverError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-xl border border-dark-700 bg-dark-800 p-4 sm:p-6">
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.coupons.form.name')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
maxLength={255}
|
||||||
|
placeholder={t('admin.coupons.form.namePlaceholder')}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tariff */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.coupons.form.tariff')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={tariffId ?? ''}
|
||||||
|
onChange={(e) => setTariffId(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="input w-full"
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.coupons.form.selectTariff')}</option>
|
||||||
|
{tariffs.map((tariff) => (
|
||||||
|
<option key={tariff.id} value={tariff.id}>
|
||||||
|
{tariff.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period + count */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.coupons.form.periodDays')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={periodDays}
|
||||||
|
onChange={createNumberInputHandler(setPeriodDays, 1, 3650)}
|
||||||
|
min={1}
|
||||||
|
max={3650}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.coupons.form.couponsCount')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={couponsCount}
|
||||||
|
onChange={createNumberInputHandler(setCouponsCount, 1, 500)}
|
||||||
|
min={1}
|
||||||
|
max={500}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Wholesale price + validity */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.coupons.form.price')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={priceRubles}
|
||||||
|
onChange={createNumberInputHandler(setPriceRubles, 0)}
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
placeholder="0"
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.priceHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.coupons.form.validDays')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={validDays}
|
||||||
|
onChange={createNumberInputHandler(setValidDays, 0, 3650)}
|
||||||
|
min={0}
|
||||||
|
max={3650}
|
||||||
|
placeholder="0"
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 border-t border-dark-700 pt-4">
|
||||||
|
<button onClick={() => navigate('/admin/coupons')} className="btn-secondary flex-1">
|
||||||
|
{t('admin.coupons.form.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={createMutation.isPending}
|
||||||
|
className={`btn-primary flex-1 ${createMutation.isPending ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{createMutation.isPending
|
||||||
|
? t('admin.coupons.form.creating')
|
||||||
|
: t('admin.coupons.form.create')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
src/pages/AdminCouponDetail.tsx
Normal file
260
src/pages/AdminCouponDetail.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
import { couponsApi } from '../api/coupons';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
|
import { formatPrice, formatShortDate } from '../utils/format';
|
||||||
|
import { getApiErrorMessage } from '../utils/api-error';
|
||||||
|
import { useToast } from '../components/Toast';
|
||||||
|
import { PermissionGate } from '../components/auth/PermissionGate';
|
||||||
|
import {
|
||||||
|
BackIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
TicketIcon,
|
||||||
|
} from '@/components/icons';
|
||||||
|
import { StatCard } from '../components/stats';
|
||||||
|
|
||||||
|
export default function AdminCouponDetail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
const batchId = Number(id);
|
||||||
|
const [revokeConfirm, setRevokeConfirm] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const { data: batch, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-coupon-batch', batchId],
|
||||||
|
queryFn: () => couponsApi.getBatch(batchId),
|
||||||
|
enabled: Number.isFinite(batchId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasActive = (batch?.active_count ?? 0) > 0;
|
||||||
|
|
||||||
|
const { data: links } = useQuery({
|
||||||
|
queryKey: ['admin-coupon-batch-links', batchId],
|
||||||
|
queryFn: () => couponsApi.getBatchLinks(batchId),
|
||||||
|
enabled: Number.isFinite(batchId) && hasActive,
|
||||||
|
});
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: () => couponsApi.revokeBatch(batchId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batch', batchId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batch-links', batchId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
|
||||||
|
setRevokeConfirm(false);
|
||||||
|
showToast({
|
||||||
|
type: 'success',
|
||||||
|
title: t('admin.coupons.revoke.success', { count: result.revoked_count }),
|
||||||
|
message: result.batch.name,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setRevokeConfirm(false);
|
||||||
|
showToast({
|
||||||
|
type: 'error',
|
||||||
|
title: t('admin.coupons.errors.revokeFailed'),
|
||||||
|
message: getApiErrorMessage(err, ''),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleCopyAll = () => {
|
||||||
|
if (!links) return;
|
||||||
|
void copyToClipboard(links.links.join('\n'));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (!links) return;
|
||||||
|
const blob = new Blob([links.links.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `coupons_batch_${batchId}.txt`;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!batch) {
|
||||||
|
return (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-dark-400">{t('admin.coupons.errors.loadFailed')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex items-center gap-3">
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/coupons')}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-xl font-bold text-dark-100">
|
||||||
|
{t('admin.coupons.detail.title', { id: batch.id })} · {batch.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
{batch.tariff_name || '—'} · {batch.period_days} {t('admin.coupons.days')}
|
||||||
|
{batch.is_revoked && (
|
||||||
|
<span className="ml-2 rounded bg-error-500/20 px-2 py-0.5 text-xs text-error-400">
|
||||||
|
{t('admin.coupons.list.revoked')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mb-6 grid grid-cols-3 gap-3">
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.coupons.stats.active')}
|
||||||
|
value={batch.active_count}
|
||||||
|
icon={<TicketIcon className="h-5 w-5" />}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.coupons.stats.redeemed')}
|
||||||
|
value={batch.redeemed_count}
|
||||||
|
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||||
|
tone="accent"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.coupons.stats.revoked')}
|
||||||
|
value={batch.revoked_count}
|
||||||
|
icon={<ChartBarIcon className="h-5 w-5" />}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info card */}
|
||||||
|
<div className="mb-6 space-y-2 rounded-xl border border-dark-700 bg-dark-800 p-4 text-sm">
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-dark-400">{t('admin.coupons.detail.total')}</span>
|
||||||
|
<span className="text-dark-100">{batch.coupons_total}</span>
|
||||||
|
</div>
|
||||||
|
{batch.wholesale_price_kopeks > 0 && (
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-dark-400">{t('admin.coupons.detail.price')}</span>
|
||||||
|
<span className="text-dark-100">
|
||||||
|
{formatPrice(batch.wholesale_price_kopeks, i18n.language)} ·{' '}
|
||||||
|
{t('admin.coupons.detail.priceTotal')}{' '}
|
||||||
|
{formatPrice(batch.wholesale_price_kopeks * batch.coupons_total, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
|
||||||
|
<span className="text-dark-100">
|
||||||
|
{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">{formatShortDate(batch.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
{hasActive && links && (
|
||||||
|
<div className="mb-6 rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium text-dark-200">
|
||||||
|
{t('admin.coupons.detail.linksTitle', { count: links.count })}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyAll}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
|
||||||
|
>
|
||||||
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
{copied ? t('admin.coupons.created.copied') : t('admin.coupons.created.copyAll')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-dark-700 px-3 py-1.5 text-sm text-dark-200 transition-colors hover:bg-dark-600"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
{t('admin.coupons.created.download')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={links.links.join('\n')}
|
||||||
|
rows={Math.min(10, links.count)}
|
||||||
|
className="input w-full resize-none font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Revoke */}
|
||||||
|
{hasActive && (
|
||||||
|
<PermissionGate permission="coupons:edit" fallback={null}>
|
||||||
|
<button
|
||||||
|
onClick={() => setRevokeConfirm(true)}
|
||||||
|
className="w-full rounded-lg border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-error-400 transition-colors hover:bg-error-500/20"
|
||||||
|
>
|
||||||
|
{t('admin.coupons.revoke.button', { count: batch.active_count })}
|
||||||
|
</button>
|
||||||
|
</PermissionGate>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Revoke confirmation */}
|
||||||
|
{revokeConfirm && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||||
|
{t('admin.coupons.revoke.confirmTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-6 text-dark-400">
|
||||||
|
{t('admin.coupons.revoke.confirmText', { count: batch.active_count })}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setRevokeConfirm(false)} className="btn-secondary flex-1">
|
||||||
|
{t('admin.coupons.revoke.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => revokeMutation.mutate()}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
className={`flex-1 rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 ${
|
||||||
|
revokeMutation.isPending ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('admin.coupons.revoke.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/pages/AdminCoupons.tsx
Normal file
190
src/pages/AdminCoupons.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
import { couponsApi, CouponBatch } from '../api/coupons';
|
||||||
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
import { formatPrice, formatShortDate } from '../utils/format';
|
||||||
|
import {
|
||||||
|
BackIcon,
|
||||||
|
PlusIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
TagIcon,
|
||||||
|
TicketIcon,
|
||||||
|
} from '@/components/icons';
|
||||||
|
import { StatCard } from '../components/stats';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
export default function AdminCoupons() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { capabilities } = usePlatform();
|
||||||
|
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
const { data: batchesData, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-coupon-batches', offset],
|
||||||
|
queryFn: () => couponsApi.getBatches({ limit: PAGE_SIZE, offset }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const batches = batchesData?.items || [];
|
||||||
|
const total = batchesData?.total || 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||||
|
const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Show back button only on web, not in Telegram Mini App */}
|
||||||
|
{!capabilities.hasBackButton && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin')}
|
||||||
|
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100">{t('admin.coupons.title')}</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.coupons.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/coupons/create')}
|
||||||
|
className="flex items-center justify-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-on-accent transition-colors hover:bg-accent-600"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
{t('admin.coupons.addBatch')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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')}
|
||||||
|
value={total}
|
||||||
|
icon={<TagIcon className="h-5 w-5" />}
|
||||||
|
tone="neutral"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.coupons.stats.active')}
|
||||||
|
value={batches.reduce((sum, b) => sum + b.active_count, 0)}
|
||||||
|
icon={<TicketIcon className="h-5 w-5" />}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.coupons.stats.redeemed')}
|
||||||
|
value={batches.reduce((sum, b) => sum + b.redeemed_count, 0)}
|
||||||
|
icon={<CheckCircleIcon className="h-5 w-5" />}
|
||||||
|
tone="accent"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.coupons.stats.revoked')}
|
||||||
|
value={batches.reduce((sum, b) => sum + b.revoked_count, 0)}
|
||||||
|
icon={<ChartBarIcon className="h-5 w-5" />}
|
||||||
|
tone="warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Batches List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : batches.length === 0 ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<p className="text-dark-400">{t('admin.coupons.noBatches')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{batches.map((batch: CouponBatch) => (
|
||||||
|
<button
|
||||||
|
key={batch.id}
|
||||||
|
onClick={() => navigate(`/admin/coupons/${batch.id}`)}
|
||||||
|
className={`w-full rounded-xl border bg-dark-800 p-4 text-left transition-colors hover:border-dark-600 ${
|
||||||
|
batch.is_revoked ? 'border-dark-700/50 opacity-60' : 'border-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-2 flex items-center gap-2 font-medium text-dark-100">
|
||||||
|
<span className="text-dark-500">#{batch.id}</span>
|
||||||
|
<span className="truncate">{batch.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||||
|
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||||
|
{batch.tariff_name || '—'} · {batch.period_days} {t('admin.coupons.days')}
|
||||||
|
</span>
|
||||||
|
{batch.is_revoked && (
|
||||||
|
<span className="rounded bg-error-500/20 px-2 py-0.5 text-xs text-error-400">
|
||||||
|
{t('admin.coupons.list.revoked')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||||
|
<span className="text-success-400">
|
||||||
|
{t('admin.coupons.stats.active')}: {batch.active_count}
|
||||||
|
</span>
|
||||||
|
<span className="text-accent-400">
|
||||||
|
{t('admin.coupons.stats.redeemed')}: {batch.redeemed_count}
|
||||||
|
</span>
|
||||||
|
{batch.revoked_count > 0 && (
|
||||||
|
<span className="text-error-400">
|
||||||
|
{t('admin.coupons.stats.revoked')}: {batch.revoked_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{batch.wholesale_price_kopeks > 0 && (
|
||||||
|
<span>
|
||||||
|
{t('admin.coupons.list.price')}:{' '}
|
||||||
|
{formatPrice(batch.wholesale_price_kopeks, i18n.language)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{batch.valid_until && (
|
||||||
|
<span>
|
||||||
|
{t('admin.coupons.list.until')}: {formatShortDate(batch.valid_until)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||||||
|
<button
|
||||||
|
onClick={() => setOffset((o) => Math.max(0, o - PAGE_SIZE))}
|
||||||
|
disabled={offset === 0}
|
||||||
|
className={`btn-secondary min-w-[100px] flex-1 ${offset === 0 ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{t('admin.coupons.pagination.prev')}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 text-center">
|
||||||
|
{t('admin.coupons.pagination.page', { current: currentPage, total: totalPages })}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOffset((o) => o + PAGE_SIZE)}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
className={`btn-secondary min-w-[100px] flex-1 ${
|
||||||
|
currentPage >= totalPages ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('admin.coupons.pagination.next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -187,6 +187,12 @@ const sections: AdminSection[] = [
|
|||||||
to: '/admin/promocodes',
|
to: '/admin/promocodes',
|
||||||
permission: 'promocodes:read',
|
permission: 'promocodes:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'admin.nav.coupons',
|
||||||
|
icon: 'ticket',
|
||||||
|
to: '/admin/coupons',
|
||||||
|
permission: 'coupons:read',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'admin.nav.promoGroups',
|
name: 'admin.nav.promoGroups',
|
||||||
icon: 'percent',
|
icon: 'percent',
|
||||||
|
|||||||
176
src/pages/CouponStatus.tsx
Normal file
176
src/pages/CouponStatus.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { couponsApi, CouponRedeemResponse } from '../api/coupons';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { formatShortDate } from '../utils/format';
|
||||||
|
import { Spinner } from '@/components/ui/Spinner';
|
||||||
|
import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark';
|
||||||
|
import { TicketIcon } from '@/components/icons';
|
||||||
|
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh items-center justify-center bg-dark-950 px-4 py-10">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-dark-800/50 bg-dark-900/50 p-6 sm:p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The redeem endpoint answers 400 with a structured {detail: {code, message}} —
|
||||||
|
// map the stable machine code to a localized message.
|
||||||
|
const redeemErrorText = (err: unknown, t: (key: string) => string): string => {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const detail = err.response?.data?.detail as { code?: string } | string | undefined;
|
||||||
|
if (detail && typeof detail === 'object' && detail.code) {
|
||||||
|
const known = ['invalid', 'expired', 'already_redeemed_by_you', 'internal'];
|
||||||
|
if (known.includes(detail.code)) return t(`coupon.errors.${detail.code}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t('coupon.errors.generic');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CouponStatus() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
|
const [redeemed, setRedeemed] = useState<CouponRedeemResponse | null>(null);
|
||||||
|
const [redeemError, setRedeemError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: coupon,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['coupon-status', token],
|
||||||
|
queryFn: () => couponsApi.getCouponStatus(token!),
|
||||||
|
enabled: !!token,
|
||||||
|
// 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({
|
||||||
|
mutationFn: () => couponsApi.redeemCoupon(token!),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setRedeemError(null);
|
||||||
|
setRedeemed(result);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setRedeemError(redeemErrorText(err, t));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !coupon) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="mb-2 text-lg font-semibold text-dark-100">{t('coupon.notFound.title')}</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('coupon.notFound.text')}</p>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redeemed) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4 flex justify-center">
|
||||||
|
<AnimatedCheckmark />
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-2 text-lg font-semibold text-dark-100">
|
||||||
|
{redeemed.renewed ? t('coupon.success.renewed') : t('coupon.success.activated')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">
|
||||||
|
{redeemed.tariff_name} · {redeemed.period_days} {t('coupon.daysSuffix')}
|
||||||
|
{redeemed.end_date && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
{t('coupon.success.until')} {formatShortDate(redeemed.end_date)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4 flex justify-center text-accent-400">
|
||||||
|
<TicketIcon className="h-10 w-10" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-1 text-lg font-semibold text-dark-100">{t('coupon.title')}</h1>
|
||||||
|
<p className="mb-6 text-sm text-dark-400">{t('coupon.subtitle')}</p>
|
||||||
|
|
||||||
|
<div className="mb-6 space-y-2 rounded-xl border border-dark-800 bg-dark-900 p-4 text-left text-sm">
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-dark-400">{t('coupon.tariff')}</span>
|
||||||
|
<span className="font-medium text-dark-100">{coupon.tariff_name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-dark-400">{t('coupon.period')}</span>
|
||||||
|
<span className="font-medium text-dark-100">
|
||||||
|
{coupon.period_days} {t('coupon.daysSuffix')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{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">
|
||||||
|
{formatShortDate(coupon.valid_until)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{redeemError && (
|
||||||
|
<div className="mb-4 rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||||
|
{redeemError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{coupon.bot_link && (
|
||||||
|
<a
|
||||||
|
href={coupon.bot_link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-primary block w-full text-center"
|
||||||
|
>
|
||||||
|
{t('coupon.openBot')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{isAuthenticated ? (
|
||||||
|
<button
|
||||||
|
onClick={() => redeemMutation.mutate()}
|
||||||
|
disabled={redeemMutation.isPending}
|
||||||
|
className={`btn-secondary w-full ${redeemMutation.isPending ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{redeemMutation.isPending ? t('coupon.redeeming') : t('coupon.redeemCabinet')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-dark-500">{t('coupon.needAuth')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -59,3 +59,21 @@ export function formatPrice(kopeks: number, lang?: string): string {
|
|||||||
return `${rounded} ${config.symbol}`;
|
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