mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Move the tariff selection surface (promo-group banner, all-purchased empty state, and the 1/2-col tariff cards grid with per-tariff CTAs) into src/components/subscription/purchase/TariffPickerGrid.tsx. The grid is pure presentation — it owns no queries or mutations. Two callbacks back into the parent: - onSelectTariff(tariff) → parent sets selectedTariff + showForm - onSwitchTariff(tariffId) → parent sets switchTariffId Internal helpers (formatPrice via useCurrency, applyPromoDiscount via usePromoDiscount, glass tokens via useTheme + getGlassColors, navigate for the 'all purchased' back-to-list button) all derive inside the component — nothing threaded as props beyond data. SubscriptionPurchase.tsx: 1202 → 908 lines (-294).
348 lines
15 KiB
TypeScript
348 lines
15 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
||
import { useNavigate } from 'react-router';
|
||
import { useTheme } from '../../../hooks/useTheme';
|
||
import { useCurrency } from '../../../hooks/useCurrency';
|
||
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
|
||
import { getGlassColors } from '../../../utils/glassTheme';
|
||
import type { Tariff, Subscription, PurchaseOptions } from '../../../types';
|
||
|
||
// ──────────────────────────────────────────────────────────────────
|
||
// TariffPickerGrid
|
||
//
|
||
// The tariff selection surface inside SubscriptionPurchase. Renders:
|
||
// - an optional promo-group banner when any tariff carries a
|
||
// promo_group_name
|
||
// - the "all tariffs purchased" empty state (multi-tariff mode)
|
||
// - the grid itself (1 col mobile, 2 cols sm+) with promo prices,
|
||
// per-tariff CTAs differentiated by user state (extend / switch /
|
||
// purchase / legacy renewal)
|
||
//
|
||
// Owns nothing — pure presentation that calls back into the parent
|
||
// for selection (`onSelectTariff`) and switch (`onSwitchTariff`).
|
||
// ──────────────────────────────────────────────────────────────────
|
||
|
||
export interface TariffPickerGridProps {
|
||
tariffs: Tariff[];
|
||
subscription: Subscription | null;
|
||
purchaseOptions: PurchaseOptions | undefined;
|
||
isTariffsMode: boolean;
|
||
isMultiTariff: boolean;
|
||
onSelectTariff: (tariff: Tariff) => void;
|
||
onSwitchTariff: (tariffId: number) => void;
|
||
}
|
||
|
||
export function TariffPickerGrid({
|
||
tariffs,
|
||
subscription,
|
||
purchaseOptions,
|
||
isTariffsMode,
|
||
isMultiTariff,
|
||
onSelectTariff,
|
||
onSwitchTariff,
|
||
}: TariffPickerGridProps) {
|
||
const { t } = useTranslation();
|
||
const navigate = useNavigate();
|
||
const { isDark } = useTheme();
|
||
const g = getGlassColors(isDark);
|
||
const { formatAmount, currencySymbol } = useCurrency();
|
||
const { applyPromoDiscount } = usePromoDiscount();
|
||
|
||
const formatPrice = (kopeks: number) =>
|
||
kopeks === 0
|
||
? t('subscription.free', 'Бесплатно')
|
||
: `${formatAmount(kopeks / 100)} ${currencySymbol}`;
|
||
|
||
return (
|
||
<>
|
||
{/* Promo group discount banner */}
|
||
{tariffs.some((tariff) => tariff.promo_group_name) && (
|
||
<div className="mb-4 flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-3">
|
||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-success-500/20 text-success-400">
|
||
<svg
|
||
className="h-5 w-5"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={2}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V5.5A2.5 2.5 0 109.5 8H12zm-7 4h14M5 12a2 2 0 110-4h14a2 2 0 110 4M5 12v7a2 2 0 002 2h10a2 2 0 002-2v-7"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<div className="text-sm font-medium text-success-400">
|
||
{t('subscription.promoGroup.title', {
|
||
name: tariffs.find((tariff) => tariff.promo_group_name)?.promo_group_name,
|
||
})}
|
||
</div>
|
||
<div className="text-xs text-dark-400">
|
||
{t('subscription.promoGroup.personalDiscountsApplied')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Tariff Grid */}
|
||
{isMultiTariff &&
|
||
purchaseOptions &&
|
||
'all_tariffs_purchased' in purchaseOptions &&
|
||
purchaseOptions.all_tariffs_purchased && (
|
||
<div
|
||
className="rounded-2xl border p-6 text-center"
|
||
style={{ background: g.cardBg, borderColor: g.cardBorder }}
|
||
>
|
||
<div className="mb-2 text-3xl">✅</div>
|
||
<h3 className="mb-1 text-lg font-semibold" style={{ color: g.text }}>
|
||
{t('subscription.allTariffsPurchased', 'Все тарифы подключены')}
|
||
</h3>
|
||
<p className="mb-4 text-sm" style={{ color: g.textSecondary }}>
|
||
{t(
|
||
'subscription.allTariffsPurchasedDesc',
|
||
'Вы уже приобрели все доступные тарифы. Продлить подписку можно на странице тарифа.',
|
||
)}
|
||
</p>
|
||
<button
|
||
onClick={() => navigate('/subscriptions')}
|
||
className="rounded-xl bg-accent-500 px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||
>
|
||
{t('subscription.backToList', 'Мои подписки')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
{[...tariffs]
|
||
.filter((tariff) => {
|
||
// In multi-tariff mode: hide already purchased tariffs
|
||
if (isMultiTariff && tariff.is_purchased) return false;
|
||
if (subscription?.is_trial && tariff.name.toLowerCase().includes('trial')) {
|
||
return false;
|
||
}
|
||
return true;
|
||
})
|
||
.sort((a, b) => {
|
||
const aIsCurrent = a.is_current || a.id === subscription?.tariff_id;
|
||
const bIsCurrent = b.is_current || b.id === subscription?.tariff_id;
|
||
if (aIsCurrent && !bIsCurrent) return -1;
|
||
if (!aIsCurrent && bIsCurrent) return 1;
|
||
return 0;
|
||
})
|
||
.map((tariff) => {
|
||
const isCurrentTariff = tariff.is_current || tariff.id === subscription?.tariff_id;
|
||
const isSubscriptionExpired =
|
||
isTariffsMode &&
|
||
purchaseOptions &&
|
||
'subscription_is_expired' in purchaseOptions &&
|
||
purchaseOptions.subscription_is_expired === true;
|
||
const canSwitch =
|
||
!isMultiTariff &&
|
||
subscription &&
|
||
subscription.tariff_id &&
|
||
!isCurrentTariff &&
|
||
!subscription.is_trial &&
|
||
!isSubscriptionExpired &&
|
||
(subscription.is_active || subscription.is_limited);
|
||
const isLegacySubscription =
|
||
subscription && !subscription.is_trial && !subscription.tariff_id;
|
||
|
||
return (
|
||
<div
|
||
key={tariff.id}
|
||
className={`bento-card-hover p-5 text-left transition-all ${
|
||
isCurrentTariff ? 'bento-card-glow border-accent-500' : ''
|
||
}`}
|
||
>
|
||
<div className="mb-3 flex items-start justify-between">
|
||
<div>
|
||
<div className="text-lg font-semibold text-dark-100">{tariff.name}</div>
|
||
{tariff.description && (
|
||
<div className="mt-1 whitespace-pre-line text-sm text-dark-400">
|
||
{tariff.description}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{isCurrentTariff && (
|
||
<span className="badge-success text-xs">{t('subscription.currentTariff')}</span>
|
||
)}
|
||
</div>
|
||
<div className="flex flex-wrap gap-4 text-sm">
|
||
<div className="flex items-center gap-1.5">
|
||
<svg
|
||
className="h-4 w-4 text-accent-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={1.5}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||
/>
|
||
</svg>
|
||
<span className="font-medium text-dark-200">{tariff.traffic_limit_label}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<svg
|
||
className="h-4 w-4 text-dark-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={1.5}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||
/>
|
||
</svg>
|
||
<span className="text-dark-300">
|
||
{tariff.device_limit === 0
|
||
? '∞'
|
||
: t('subscription.devices', { count: tariff.device_limit })}
|
||
</span>
|
||
</div>
|
||
{tariff.traffic_reset_mode && tariff.traffic_reset_mode !== 'NO_RESET' && (
|
||
<div className="flex items-center gap-1.5">
|
||
<svg
|
||
className="h-4 w-4 text-dark-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
strokeWidth={1.5}
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M2.985 19.644l3.181-3.182"
|
||
/>
|
||
</svg>
|
||
<span className="text-dark-300">
|
||
{t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Price info */}
|
||
<div className="mt-3 border-t border-dark-700/50 pt-3 text-sm text-dark-400">
|
||
{(() => {
|
||
const dailyPrice =
|
||
tariff.daily_price_kopeks ?? tariff.price_per_day_kopeks ?? 0;
|
||
const originalDailyPrice = tariff.original_daily_price_kopeks || 0;
|
||
if (dailyPrice > 0 || originalDailyPrice > 0) {
|
||
const promoDaily = applyPromoDiscount(
|
||
dailyPrice,
|
||
originalDailyPrice > dailyPrice ? originalDailyPrice : undefined,
|
||
);
|
||
return (
|
||
<span className="flex items-center gap-2">
|
||
<span className="font-medium text-accent-400">
|
||
{formatPrice(promoDaily.price)}
|
||
</span>
|
||
{promoDaily.original && promoDaily.original > promoDaily.price && (
|
||
<span className="text-xs text-dark-500 line-through">
|
||
{formatPrice(promoDaily.original)}
|
||
</span>
|
||
)}
|
||
<span>{t('subscription.tariff.perDay')}</span>
|
||
{promoDaily.percent && promoDaily.percent > 0 && (
|
||
<span
|
||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||
promoDaily.isPromoGroup
|
||
? 'bg-success-500/20 text-success-400'
|
||
: 'bg-warning-500/20 text-warning-400'
|
||
}`}
|
||
>
|
||
-{promoDaily.percent}%
|
||
</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
if (tariff.periods.length > 0) {
|
||
const firstPeriod = tariff.periods[0];
|
||
const promoPeriod = applyPromoDiscount(
|
||
firstPeriod?.price_kopeks || 0,
|
||
firstPeriod?.original_price_kopeks,
|
||
);
|
||
return (
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<span>{t('subscription.from')}</span>
|
||
<span className="font-medium text-accent-400">
|
||
{formatPrice(promoPeriod.price)}
|
||
</span>
|
||
{promoPeriod.original && promoPeriod.original > promoPeriod.price && (
|
||
<span className="text-xs text-dark-500 line-through">
|
||
{formatPrice(promoPeriod.original)}
|
||
</span>
|
||
)}
|
||
{promoPeriod.percent && promoPeriod.percent > 0 && (
|
||
<span
|
||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||
promoPeriod.isPromoGroup
|
||
? 'bg-success-500/20 text-success-400'
|
||
: 'bg-warning-500/20 text-warning-400'
|
||
}`}
|
||
>
|
||
-{promoPeriod.percent}%
|
||
</span>
|
||
)}
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="font-medium text-accent-400">
|
||
{t('subscription.tariff.flexiblePayment')}
|
||
</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="mt-4 flex gap-2">
|
||
{isCurrentTariff ? (
|
||
subscription?.is_daily ? (
|
||
<div className="flex-1 py-2 text-center text-sm text-dark-500">
|
||
{t('subscription.currentTariff')}
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => onSelectTariff(tariff)}
|
||
className="btn-primary flex-1 py-2 text-sm"
|
||
>
|
||
{t('subscription.extend')}
|
||
</button>
|
||
)
|
||
) : isLegacySubscription ? (
|
||
<button
|
||
onClick={() => onSelectTariff(tariff)}
|
||
className="btn-primary flex-1 py-2 text-sm"
|
||
>
|
||
{t('subscription.tariff.selectForRenewal')}
|
||
</button>
|
||
) : canSwitch ? (
|
||
<button
|
||
onClick={() => onSwitchTariff(tariff.id)}
|
||
className="btn-secondary flex-1 py-2 text-sm"
|
||
>
|
||
{t('subscription.switchTariff.switch')}
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => onSelectTariff(tariff)}
|
||
className="btn-primary flex-1 py-2 text-sm"
|
||
>
|
||
{t('subscription.purchase')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</>
|
||
);
|
||
}
|