Files
bedolaga-cabinet/src/components/PromoOffersSection.tsx
c0mrade 35428cc27d fix(promocode): correct activation UX, error mapping and validity dates
Map promocode errors by stable backend code (detail.code) instead of brittle English
substring matching, so previously-unmapped codes (active_discount_exists, daily_limit,
no_subscription_for_days, trial_*, etc.) show the right message instead of 'server error';
added the missing keys to all 4 locales.

valid_until now anchors to end-of-day in the admin's LOCAL timezone on both create AND
edit read-back, so codes no longer expire at the start of the day and editing no longer
drifts the date forward for negative-offset admins.

Allow 0-hour discount ('until first purchase'). Route user-facing discount deactivation
to the endpoint that rolls back promocode usage when the discount came from a promocode,
and parse the structured {code,message} error.
2026-06-08 00:23:31 +03:00

340 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon, XCircleIcon } from './icons';
import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog';
// Helper functions
const formatTimeLeft = (
expiresAt: string,
t: (key: string, options?: Record<string, unknown>) => string,
): string => {
const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt);
} else {
// No timezone - treat as UTC
expires = new Date(expiresAt + 'Z');
}
const diffMs = expires.getTime() - now.getTime();
if (diffMs <= 0) return t('promo.offers.expired');
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days} ${t('promo.time.days')}`;
}
if (hours > 0) {
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
}
return `${minutes}${t('promo.time.minutes')}`;
};
const getOfferIcon = (effectType: string, discountPercent?: number | null) => {
if (effectType === 'test_access') return <span className="text-2xl">🚀</span>;
if (discountPercent) return <span className="text-2xl">🏷</span>;
return <span className="text-2xl">🎁</span>;
};
const getOfferTitle = (
offer: PromoOffer,
t: (key: string, options?: Record<string, unknown>) => string,
): string => {
if (offer.effect_type === 'test_access') {
return t('promo.offers.testAccess');
}
if (offer.discount_percent) {
return t('promo.offers.discountPercent', { percent: offer.discount_percent });
}
return t('promo.offers.specialOffer');
};
const getOfferDescription = (
offer: PromoOffer,
t: (key: string, options?: Record<string, unknown>) => string,
): string => {
if (offer.effect_type === 'test_access') {
const squads = offer.extra_data?.test_squad_uuids?.length || 0;
return squads > 0
? t('promo.offers.serverAccess', { count: squads })
: t('promo.offers.additionalServers');
}
return t('promo.offers.activateDiscountHint');
};
interface PromoOffersSectionProps {
className?: string;
}
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const confirmDeactivate = useDestructiveConfirm();
const [claimingId, setClaimingId] = useState<number | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Fetch available offers
const { data: offers = [], isLoading: offersLoading } = useQuery({
queryKey: ['promo-offers'],
queryFn: promoApi.getOffers,
staleTime: 30000,
});
// Fetch active discount
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
});
// Claim offer mutation
const claimMutation = useMutation({
mutationFn: promoApi.claimOffer,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['balance'] });
setSuccessMessage(result.message);
setClaimingId(null);
setTimeout(() => setSuccessMessage(null), 5000);
},
onError: (error: unknown) => {
const axiosErr = error as { response?: { data?: { detail?: string } } };
setErrorMessage(axiosErr.response?.data?.detail || t('promo.offers.activationFailed'));
setClaimingId(null);
setTimeout(() => setErrorMessage(null), 5000);
},
});
// Deactivate discount mutation.
// A discount granted by a PROMOCODE must be turned off via the dedicated route
// (POST /cabinet/promocode/deactivate-discount), which also rolls back the promocode
// usage (deletes the PromoCodeUse, decrements current_uses, strips the promo group) so
// the user can re-activate it later. The plain clearActiveDiscount route only zeroes
// the discount fields and is correct for admin/offer-sourced discounts.
const deactivateMutation = useMutation({
mutationFn: async () => {
const source = activeDiscount?.source ?? '';
if (source.startsWith('promocode:')) {
await promoApi.deactivateDiscount();
} else {
await promoApi.clearActiveDiscount();
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
setSuccessMessage(t('promo.deactivate.success'));
setTimeout(() => setSuccessMessage(null), 5000);
},
onError: (error: unknown) => {
// detail may be a plain string (clearActiveDiscount route) or a structured
// { code, message } object (deactivate-discount route). Handle both.
const axiosErr = error as {
response?: { data?: { detail?: string | { message?: string } } };
};
const detail = axiosErr.response?.data?.detail;
const message = typeof detail === 'string' ? detail : detail?.message;
setErrorMessage(message || t('promo.deactivate.error'));
setTimeout(() => setErrorMessage(null), 5000);
},
});
const handleClaim = (offerId: number) => {
setClaimingId(offerId);
setErrorMessage(null);
setSuccessMessage(null);
claimMutation.mutate(offerId);
};
const handleUseNow = () => {
navigate('/subscription/purchase');
};
const handleDeactivateClick = async () => {
setErrorMessage(null);
setSuccessMessage(null);
const confirmed = await confirmDeactivate(
t('promo.deactivate.confirmDescription', {
percent: activeDiscount?.discount_percent || 0,
}),
t('promo.deactivate.confirm'),
t('promo.deactivate.confirmTitle'),
);
if (confirmed) {
deactivateMutation.mutate();
}
};
// Filter unclaimed and active offers
const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
// Don't render if no offers and no active discount
if (
!offersLoading &&
availableOffers.length === 0 &&
(!activeDiscount || !activeDiscount.is_active)
) {
return null;
}
return (
<div className={`space-y-4 ${className}`}>
{/* Active Discount Banner with actions */}
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
<div className="card border-success-500/30 bg-gradient-to-br from-success-500/10 to-accent-500/5">
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
<span className="text-2xl">🏷</span>
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-dark-100">
{t('promo.offers.discountActiveTitle', {
percent: activeDiscount.discount_percent,
})}
</h3>
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs font-bold text-success-400">
-{activeDiscount.discount_percent}%
</span>
</div>
<div className="flex items-center gap-4 text-sm text-dark-400">
{activeDiscount.expires_at && (
<div className="flex items-center gap-1">
<ClockIcon />
<span>
{t('promo.offers.expires', {
time: formatTimeLeft(activeDiscount.expires_at, t),
})}
</span>
</div>
)}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col gap-2 sm:flex-row">
<button
onClick={handleUseNow}
className="flex-1 rounded-xl bg-gradient-to-r from-success-500 to-success-600 px-4 py-2.5 font-semibold text-white shadow-lg shadow-success-500/25 transition-all hover:from-success-400 hover:to-success-500 active:from-success-600 active:to-success-700"
>
{t('promo.useNow')}
</button>
<button
onClick={handleDeactivateClick}
className="flex items-center justify-center gap-1.5 rounded-xl border border-dark-600/50 bg-dark-900/50 px-4 py-2.5 text-sm text-dark-400 transition-colors hover:border-error-500/30 hover:bg-error-500/10 hover:text-error-400"
>
<XCircleIcon className="h-4 w-4" />
<span>{t('promo.deactivate.button')}</span>
</button>
</div>
</div>
</div>
)}
{/* Success/Error Messages */}
{successMessage && (
<div className="flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-success-400">
<CheckIcon />
<span>{successMessage}</span>
</div>
)}
{errorMessage && (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-error-400">
{errorMessage}
</div>
)}
{/* Available Offers */}
{availableOffers.length > 0 && (
<div className="space-y-3">
{availableOffers.map((offer) => (
<div
key={offer.id}
className="card border-warning-500/30 bg-gradient-to-br from-warning-500/5 to-transparent transition-colors hover:border-warning-500/50"
>
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-warning-500/30 to-warning-500/20">
{getOfferIcon(offer.effect_type, offer.discount_percent)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<h3 className="font-semibold text-dark-100">{getOfferTitle(offer, t)}</h3>
{offer.effect_type === 'test_access' && (
<span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
{t('promo.offers.test')}
</span>
)}
</div>
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer, t)}</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-1 text-xs text-dark-500">
<ClockIcon />
<span>
{t('promo.offers.remaining', { time: formatTimeLeft(offer.expires_at, t) })}
</span>
</div>
<button
onClick={() => handleClaim(offer.id)}
disabled={claimingId === offer.id}
className="group relative flex w-full items-center justify-center gap-2 overflow-hidden rounded-lg bg-gradient-to-r from-warning-500 to-warning-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-warning-500/25 transition-all hover:scale-105 hover:shadow-xl hover:shadow-warning-500/30 active:scale-100 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100 sm:w-auto"
>
{/* Shimmer effect */}
<span className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000 group-hover:translate-x-full" />
{claimingId === offer.id ? (
<>
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>{t('promo.offers.activating')}</span>
</>
) : (
<>
<span className="text-lg">🎁</span>
<span>{t('promo.offers.activate')}</span>
</>
)}
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
{/* Loading State */}
{offersLoading && (
<div className="card">
<div className="flex items-center gap-4">
<div className="h-12 w-12 animate-pulse rounded-xl bg-dark-700" />
<div className="flex-1 space-y-2">
<div className="h-5 w-32 animate-pulse rounded bg-dark-700" />
<div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
</div>
</div>
</div>
)}
</div>
);
}