mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
feat(coupons): лимит на пользователя и удаление партии в кабинете
Парная фронтовая часть к бэкенду (бот: 82609441). - Поле «Лимит на пользователя» в форме создания партии: 0 — без ограничения, для раздач и конкурсов ставится 1. Значение показывается в карточке партии, если задано. - Кнопка «Удалить партию» с подтверждением: отдельно от отзыва, который лишь гасит ссылки. Если в партии есть погашенные купоны, подтверждение предупреждает о потере истории активаций (выданные подписки не отзываются). После удаления — возврат к списку. - API: max_per_user в типах партии и запросе создания, deleteBatch. Локали ru/en — раздел admin.coupons в zh/fa отсутствует целиком (купоны там никогда не переводились), поэтому новые ключи добавлены в том же объёме.
This commit is contained in:
@@ -10,6 +10,8 @@ export interface CouponBatch {
|
|||||||
period_days: number;
|
period_days: number;
|
||||||
coupons_total: number;
|
coupons_total: number;
|
||||||
wholesale_price_kopeks: number;
|
wholesale_price_kopeks: number;
|
||||||
|
/** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */
|
||||||
|
max_per_user: number;
|
||||||
valid_until: string | null;
|
valid_until: string | null;
|
||||||
is_revoked: boolean;
|
is_revoked: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest {
|
|||||||
period_days: number;
|
period_days: number;
|
||||||
coupons_count: number;
|
coupons_count: number;
|
||||||
wholesale_price_kopeks?: number;
|
wholesale_price_kopeks?: number;
|
||||||
|
max_per_user?: number;
|
||||||
valid_days?: number;
|
valid_days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +102,12 @@ export const couponsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// User: redeem a coupon for the current cabinet user
|
// User: redeem a coupon for the current cabinet user
|
||||||
|
/** Полное удаление партии (в отличие от revoke, который гасит ссылки) */
|
||||||
|
deleteBatch: async (id: number): Promise<{ batch_id: number; deleted_coupons: number }> => {
|
||||||
|
const response = await apiClient.delete(`/cabinet/admin/coupons/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
|
redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
|
||||||
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
|
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -3168,7 +3168,9 @@
|
|||||||
"validDaysHint": "0 or empty — perpetual",
|
"validDaysHint": "0 or empty — perpetual",
|
||||||
"create": "Create batch",
|
"create": "Create batch",
|
||||||
"creating": "Creating…",
|
"creating": "Creating…",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"maxPerUser": "Per-user limit",
|
||||||
|
"maxPerUserHint": "How many coupons from this batch one person may redeem. 0 — unlimited; use 1 for giveaways and contests."
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "Enter the batch name",
|
"nameRequired": "Enter the batch name",
|
||||||
@@ -3193,7 +3195,8 @@
|
|||||||
"validUntil": "Valid until",
|
"validUntil": "Valid until",
|
||||||
"perpetual": "Perpetual",
|
"perpetual": "Perpetual",
|
||||||
"createdAt": "Created",
|
"createdAt": "Created",
|
||||||
"linksTitle": "Active links ({{count}})"
|
"linksTitle": "Active links ({{count}})",
|
||||||
|
"maxPerUser": "Per user"
|
||||||
},
|
},
|
||||||
"revoke": {
|
"revoke": {
|
||||||
"button": "Revoke unredeemed ({{count}})",
|
"button": "Revoke unredeemed ({{count}})",
|
||||||
@@ -3206,7 +3209,16 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Batch not found",
|
"loadFailed": "Batch not found",
|
||||||
"createFailed": "Failed to create the batch",
|
"createFailed": "Failed to create the batch",
|
||||||
"revokeFailed": "Failed to revoke the batch"
|
"revokeFailed": "Failed to revoke the batch",
|
||||||
|
"deleteFailed": "Could not delete the batch"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"button": "🗑 Delete batch",
|
||||||
|
"confirmTitle": "Delete the batch?",
|
||||||
|
"confirmText": "{{count}} coupons will be deleted along with the batch itself.",
|
||||||
|
"redeemedWarning": "{{count}} of them are already redeemed. The redemption history will be lost; granted subscriptions are not revoked.",
|
||||||
|
"confirm": "Delete",
|
||||||
|
"success": "Batch deleted ({{count}} coupons)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
|
|||||||
@@ -3560,7 +3560,9 @@
|
|||||||
"validDaysHint": "0 или пусто — бессрочно",
|
"validDaysHint": "0 или пусто — бессрочно",
|
||||||
"create": "Создать партию",
|
"create": "Создать партию",
|
||||||
"creating": "Создание…",
|
"creating": "Создание…",
|
||||||
"cancel": "Отмена"
|
"cancel": "Отмена",
|
||||||
|
"maxPerUser": "Лимит на пользователя",
|
||||||
|
"maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1."
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "Укажите название партии",
|
"nameRequired": "Укажите название партии",
|
||||||
@@ -3585,7 +3587,8 @@
|
|||||||
"validUntil": "Действует до",
|
"validUntil": "Действует до",
|
||||||
"perpetual": "Бессрочно",
|
"perpetual": "Бессрочно",
|
||||||
"createdAt": "Создана",
|
"createdAt": "Создана",
|
||||||
"linksTitle": "Активные ссылки ({{count}} шт.)"
|
"linksTitle": "Активные ссылки ({{count}} шт.)",
|
||||||
|
"maxPerUser": "На пользователя"
|
||||||
},
|
},
|
||||||
"revoke": {
|
"revoke": {
|
||||||
"button": "Отозвать непогашенные ({{count}})",
|
"button": "Отозвать непогашенные ({{count}})",
|
||||||
@@ -3598,7 +3601,16 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"loadFailed": "Партия не найдена",
|
"loadFailed": "Партия не найдена",
|
||||||
"createFailed": "Не удалось создать партию",
|
"createFailed": "Не удалось создать партию",
|
||||||
"revokeFailed": "Не удалось отозвать партию"
|
"revokeFailed": "Не удалось отозвать партию",
|
||||||
|
"deleteFailed": "Не удалось удалить партию"
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"button": "🗑 Удалить партию",
|
||||||
|
"confirmTitle": "Удалить партию?",
|
||||||
|
"confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.",
|
||||||
|
"redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.",
|
||||||
|
"confirm": "Удалить",
|
||||||
|
"success": "Партия удалена (купонов: {{count}})"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"promocodes": {
|
"promocodes": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { createNumberInputHandler } from '../utils/inputHelpers';
|
import { createNumberInputHandler } from '../utils/inputHelpers';
|
||||||
import { couponsApi, CouponBatchCreated } from '../api/coupons';
|
import { couponsApi, type CouponBatchCreated } from '../api/coupons';
|
||||||
import { tariffsApi } from '../api/tariffs';
|
import { tariffsApi } from '../api/tariffs';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
import { copyToClipboard } from '../utils/clipboard';
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
@@ -33,6 +33,7 @@ export default function AdminCouponCreate() {
|
|||||||
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
|
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
|
||||||
const [priceRubles, setPriceRubles] = useState<number | ''>('');
|
const [priceRubles, setPriceRubles] = useState<number | ''>('');
|
||||||
const [validDays, setValidDays] = useState<number | ''>('');
|
const [validDays, setValidDays] = useState<number | ''>('');
|
||||||
|
const [maxPerUser, setMaxPerUser] = useState<number | ''>('');
|
||||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
const [serverError, setServerError] = useState<string | null>(null);
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export default function AdminCouponCreate() {
|
|||||||
coupons_count: Number(couponsCount),
|
coupons_count: Number(couponsCount),
|
||||||
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
|
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
|
||||||
valid_days: validDays === '' ? 0 : Number(validDays),
|
valid_days: validDays === '' ? 0 : Number(validDays),
|
||||||
|
max_per_user: maxPerUser === '' ? 0 : Number(maxPerUser),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,6 +277,21 @@ export default function AdminCouponCreate() {
|
|||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
|
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||||
|
{t('admin.coupons.form.maxPerUser')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={maxPerUser}
|
||||||
|
onChange={createNumberInputHandler(setMaxPerUser, 0, 500)}
|
||||||
|
min={0}
|
||||||
|
max={500}
|
||||||
|
placeholder="0"
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.maxPerUserHint')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export default function AdminCouponDetail() {
|
|||||||
|
|
||||||
const batchId = Number(id);
|
const batchId = Number(id);
|
||||||
const [revokeConfirm, setRevokeConfirm] = useState(false);
|
const [revokeConfirm, setRevokeConfirm] = useState(false);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const { data: batch, isLoading } = useQuery({
|
const { data: batch, isLoading } = useQuery({
|
||||||
@@ -70,6 +71,28 @@ export default function AdminCouponDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => couponsApi.deleteBatch(batchId),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
|
||||||
|
setDeleteConfirm(false);
|
||||||
|
showToast({
|
||||||
|
type: 'success',
|
||||||
|
title: t('admin.coupons.delete.success', { count: result.deleted_coupons }),
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
navigate('/admin/coupons', { replace: true });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setDeleteConfirm(false);
|
||||||
|
showToast({
|
||||||
|
type: 'error',
|
||||||
|
title: t('admin.coupons.errors.deleteFailed'),
|
||||||
|
message: getApiErrorMessage(err, ''),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleCopyAll = () => {
|
const handleCopyAll = () => {
|
||||||
if (!links) return;
|
if (!links) return;
|
||||||
void copyToClipboard(links.links.join('\n'));
|
void copyToClipboard(links.links.join('\n'));
|
||||||
@@ -169,6 +192,12 @@ export default function AdminCouponDetail() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{batch.max_per_user > 0 && (
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
<span className="text-dark-400">{t('admin.coupons.detail.maxPerUser')}</span>
|
||||||
|
<span className="text-dark-100">{batch.max_per_user}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between gap-4">
|
<div className="flex justify-between gap-4">
|
||||||
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
|
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
|
||||||
<span className="text-dark-100">
|
<span className="text-dark-100">
|
||||||
@@ -228,6 +257,49 @@ export default function AdminCouponDetail() {
|
|||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete — полностью убирает партию, в отличие от отзыва */}
|
||||||
|
<PermissionGate permission="coupons:edit" fallback={null}>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(true)}
|
||||||
|
className="w-full rounded-lg border border-error-500/30 px-4 py-2.5 text-error-400 transition-colors hover:bg-error-500/20"
|
||||||
|
>
|
||||||
|
{t('admin.coupons.delete.button')}
|
||||||
|
</button>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<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.delete.confirmTitle')}
|
||||||
|
</h3>
|
||||||
|
<p className="mb-2 text-dark-400">
|
||||||
|
{t('admin.coupons.delete.confirmText', { count: batch.coupons_total })}
|
||||||
|
</p>
|
||||||
|
{batch.redeemed_count > 0 && (
|
||||||
|
<p className="mb-4 text-sm text-warning-400">
|
||||||
|
{t('admin.coupons.delete.redeemedWarning', { count: batch.redeemed_count })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => setDeleteConfirm(false)} className="btn-secondary flex-1">
|
||||||
|
{t('admin.coupons.revoke.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteMutation.mutate()}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className={`flex-1 rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 ${
|
||||||
|
deleteMutation.isPending ? 'cursor-not-allowed opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('admin.coupons.delete.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Revoke confirmation */}
|
{/* Revoke confirmation */}
|
||||||
{revokeConfirm && (
|
{revokeConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user