fix: complete is_limited status handling across all views

- SubscriptionListCard: show traffic progress bar for limited subs
  in multi-tariff list (was hidden because isActive excluded limited)
- SubscriptionListCard: amber border/background for limited status
  cards (was using default neutral style, inconsistent with rest of UI)
- Subscription page: hide delete button for limited subs in
  multi-tariff mode (limited subs still have valid days remaining)
- SubscriptionPurchase: allow tariff switch for limited subs
  (was blocked by is_active-only gate in canSwitch formula)
- AdminUserDetail: include limited status in "already purchased"
  tariff filter to prevent duplicate subscription creation
This commit is contained in:
Fringg
2026-04-23 04:04:11 +03:00
parent f68b46699c
commit 1d5ce2d4ea
4 changed files with 111 additions and 97 deletions

View File

@@ -82,7 +82,10 @@ export default function SubscriptionListCard({
}; };
const isTrial = subscription.is_trial; const isTrial = subscription.is_trial;
const isActive = subscription.status === 'active' || subscription.status === 'trial'; const isActive =
subscription.status === 'active' ||
subscription.status === 'trial' ||
subscription.status === 'limited';
const isExpired = subscription.status === 'expired' || subscription.status === 'disabled'; const isExpired = subscription.status === 'expired' || subscription.status === 'disabled';
const trafficLimit = subscription.traffic_limit_gb; const trafficLimit = subscription.traffic_limit_gb;
const trafficUsed = subscription.traffic_used_gb; const trafficUsed = subscription.traffic_used_gb;
@@ -95,21 +98,25 @@ export default function SubscriptionListCard({
const trafficColor = const trafficColor =
trafficPercent >= 90 ? 'bg-red-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-emerald-400'; trafficPercent >= 90 ? 'bg-red-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
const borderColor = isTrial const isLimitedStatus = subscription.status === 'limited';
? 'rgba(251,191,36,0.2)'
: isExpired
? 'rgba(255,59,92,0.15)'
: g.cardBorder;
const bgColor = isTrial const borderColor =
? isDark isTrial || isLimitedStatus
? 'rgba(251,191,36,0.04)' ? 'rgba(251,191,36,0.2)'
: 'rgba(251,191,36,0.03)' : isExpired
: isExpired ? 'rgba(255,59,92,0.15)'
: g.cardBorder;
const bgColor =
isTrial || isLimitedStatus
? isDark ? isDark
? 'rgba(255,59,92,0.04)' ? 'rgba(251,191,36,0.04)'
: 'rgba(255,59,92,0.03)' : 'rgba(251,191,36,0.03)'
: g.cardBg; : isExpired
? isDark
? 'rgba(255,59,92,0.04)'
: 'rgba(255,59,92,0.03)'
: g.cardBg;
return ( return (
<button <button

View File

@@ -1658,7 +1658,10 @@ export default function AdminUserDetail() {
.filter((tariffItem) => { .filter((tariffItem) => {
const purchasedIds = new Set( const purchasedIds = new Set(
userSubscriptions userSubscriptions
.filter((s) => s.is_active || s.status === 'trial') .filter(
(s) =>
s.is_active || s.status === 'trial' || s.status === 'limited',
)
.map((s) => s.tariff_id), .map((s) => s.tariff_id),
); );
return !purchasedIds.has(tariffItem.id); return !purchasedIds.has(tariffItem.id);

View File

@@ -1316,68 +1316,25 @@ export default function Subscription() {
<PurchaseCTAButton subscription={subscription} isMultiTariff={isMultiTariff} /> <PurchaseCTAButton subscription={subscription} isMultiTariff={isMultiTariff} />
{/* Delete expired subscription */} {/* Delete expired subscription */}
{isMultiTariff && subscription && !subscription.is_active && !subscription.is_trial && ( {isMultiTariff &&
<div className="space-y-3"> subscription &&
{!showDeleteSheet ? ( !subscription.is_active &&
<button !subscription.is_trial &&
onClick={async () => { !subscription.is_limited && (
if (platform === 'telegram') { <div className="space-y-3">
const confirmed = await destructiveConfirm( {!showDeleteSheet ? (
t( <button
'subscription.deleteWarning', onClick={async () => {
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны.', if (platform === 'telegram') {
), const confirmed = await destructiveConfirm(
t('subscription.confirmDelete', 'Да, удалить'), t(
t('subscription.deleteTitle', 'Удалить подписку?'), 'subscription.deleteWarning',
); 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны.',
if (!confirmed) return; ),
setDeleteLoading(true); t('subscription.confirmDelete', 'Да, удалить'),
try { t('subscription.deleteTitle', 'Удалить подписку?'),
await subscriptionApi.deleteSubscription(subscription.id); );
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); if (!confirmed) return;
navigate('/subscriptions', { replace: true });
} catch {
setDeleteLoading(false);
}
} else {
setShowDeleteSheet(true);
}
}}
disabled={deleteLoading}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-400/20 bg-red-400/5 p-3.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-400/10 disabled:opacity-50"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{t('subscription.delete', 'Удалить подписку')}
</button>
) : (
<div
className="rounded-2xl border border-red-400/20 p-4"
style={{ background: 'rgba(255,59,92,0.04)' }}
>
<div className="mb-3 text-sm font-semibold text-red-400">
{t('subscription.deleteTitle', 'Удалить подписку?')}
</div>
<div className="mb-4 text-xs" style={{ color: g.textSecondary }}>
{t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
)}
</div>
<div className="flex gap-2">
<button
onClick={async () => {
setDeleteLoading(true); setDeleteLoading(true);
try { try {
await subscriptionApi.deleteSubscription(subscription.id); await subscriptionApi.deleteSubscription(subscription.id);
@@ -1385,28 +1342,75 @@ export default function Subscription() {
navigate('/subscriptions', { replace: true }); navigate('/subscriptions', { replace: true });
} catch { } catch {
setDeleteLoading(false); setDeleteLoading(false);
setShowDeleteSheet(false);
} }
}} } else {
disabled={deleteLoading} setShowDeleteSheet(true);
className="flex-1 rounded-xl bg-red-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:opacity-50" }
}}
disabled={deleteLoading}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-400/20 bg-red-400/5 p-3.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-400/10 disabled:opacity-50"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
> >
{deleteLoading <path
? t('common.processing', 'Удаление...') strokeLinecap="round"
: t('subscription.confirmDelete', 'Да, удалить')} strokeLinejoin="round"
</button> d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
<button />
onClick={() => setShowDeleteSheet(false)} </svg>
className="flex-1 rounded-xl border border-dark-700 py-2.5 text-sm font-medium transition-colors hover:bg-dark-700" {t('subscription.delete', 'Удалить подписку')}
style={{ color: g.textSecondary }} </button>
> ) : (
{t('common.cancel', 'Отмена')} <div
</button> className="rounded-2xl border border-red-400/20 p-4"
style={{ background: 'rgba(255,59,92,0.04)' }}
>
<div className="mb-3 text-sm font-semibold text-red-400">
{t('subscription.deleteTitle', 'Удалить подписку?')}
</div>
<div className="mb-4 text-xs" style={{ color: g.textSecondary }}>
{t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
)}
</div>
<div className="flex gap-2">
<button
onClick={async () => {
setDeleteLoading(true);
try {
await subscriptionApi.deleteSubscription(subscription.id);
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
navigate('/subscriptions', { replace: true });
} catch {
setDeleteLoading(false);
setShowDeleteSheet(false);
}
}}
disabled={deleteLoading}
className="flex-1 rounded-xl bg-red-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:opacity-50"
>
{deleteLoading
? t('common.processing', 'Удаление...')
: t('subscription.confirmDelete', 'Да, удалить')}
</button>
<button
onClick={() => setShowDeleteSheet(false)}
className="flex-1 rounded-xl border border-dark-700 py-2.5 text-sm font-medium transition-colors hover:bg-dark-700"
style={{ color: g.textSecondary }}
>
{t('common.cancel', 'Отмена')}
</button>
</div>
</div> </div>
</div> )}
)} </div>
</div> )}
)}
{/* Additional Options (Buy Devices) */} {/* Additional Options (Buy Devices) */}
{subscription && {subscription &&

View File

@@ -781,7 +781,7 @@ export default function SubscriptionPurchase() {
!isCurrentTariff && !isCurrentTariff &&
!subscription.is_trial && !subscription.is_trial &&
!isSubscriptionExpired && !isSubscriptionExpired &&
subscription.is_active; (subscription.is_active || subscription.is_limited);
const isLegacySubscription = const isLegacySubscription =
subscription && !subscription.is_trial && !subscription.tariff_id; subscription && !subscription.is_trial && !subscription.tariff_id;