refactor: extract purchase/renewal flow to separate page

Move ~1900 lines of purchase/renewal logic from Subscription.tsx into
a new SubscriptionPurchase.tsx page at /subscription/purchase.

- Create SubscriptionPurchase.tsx with tariffs grid, classic mode wizard,
  switch preview modal, and promo handling
- Create PurchaseCTAButton.tsx with animated gradient border, state-aware
  colors and context-aware text
- Create subscriptionHelpers.ts with shared utilities
- Add CopyIcon to shared icons module
- Register /subscription/purchase route with lazy loading
- Update SubscriptionCardExpired and PromoOffersSection navigators
- Add CTA translation keys to ru.json and en.json
- Remove all scrollToExtend references
This commit is contained in:
Fringg
2026-02-27 06:47:30 +03:00
parent f4d7a2cc8d
commit 126f9ab9b9
10 changed files with 2091 additions and 1942 deletions

View File

@@ -26,6 +26,7 @@ import Dashboard from './pages/Dashboard';
// User pages - lazy load
const Subscription = lazy(() => import('./pages/Subscription'));
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
const Balance = lazy(() => import('./pages/Balance'));
const Referral = lazy(() => import('./pages/Referral'));
const Support = lazy(() => import('./pages/Support'));
@@ -203,6 +204,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/subscription/purchase"
element={
<ProtectedRoute>
<LazyPage>
<SubscriptionPurchase />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/balance"
element={

View File

@@ -156,7 +156,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
};
const handleUseNow = () => {
navigate('/subscription', { state: { scrollToExtend: true } });
navigate('/subscription/purchase');
};
const handleDeactivateClick = () => {

View File

@@ -139,8 +139,7 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
{/* Action buttons */}
<div className="flex gap-2.5">
<Link
to="/subscription"
state={{ scrollToExtend: true }}
to="/subscription/purchase"
className="flex flex-1 items-center justify-center rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
style={{
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',

View File

@@ -242,6 +242,22 @@ export const CheckIcon = ({ className }: IconProps) => (
</svg>
);
export const CopyIcon = ({ className }: IconProps) => (
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
export const XIcon = ({ className }: IconProps) => (
<svg
className={cn('h-4 w-4', className)}

View File

@@ -0,0 +1,93 @@
import { Link } from 'react-router';
import { useTranslation } from 'react-i18next';
import { HoverBorderGradient } from '../ui/hover-border-gradient';
import type { Subscription } from '../../types';
interface PurchaseCTAButtonProps {
subscription: Subscription | null;
}
export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonProps) {
const { t } = useTranslation();
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
const isTrial = subscription?.is_trial;
const accentColor = isExpired ? '#FF3B5C' : '#3EDBB0';
const buttonText = isExpired
? t('subscription.getSubscription')
: isTrial
? t('subscription.trialUpgrade.title')
: t('subscription.extend');
const hintText = isExpired
? t('subscription.cta.expiredHint')
: isTrial
? t('subscription.cta.trialHint')
: t('subscription.cta.activeHint');
return (
<Link to="/subscription/purchase" className="block">
<HoverBorderGradient
accentColor={accentColor}
duration={4}
className="group relative w-full cursor-pointer overflow-hidden rounded-2xl p-[1.5px]"
>
<div
className="relative flex items-center justify-between rounded-[14px] px-5 py-4 transition-all duration-300"
style={{
background: isExpired
? 'linear-gradient(135deg, rgba(255,59,92,0.08), rgba(255,107,53,0.06))'
: 'linear-gradient(135deg, rgba(62,219,176,0.08), rgba(0,229,160,0.06))',
}}
>
{/* Left: icon + text */}
<div className="flex items-center gap-3">
{/* Sparkle icon */}
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
style={{
background: isExpired ? 'rgba(255,59,92,0.12)' : 'rgba(62,219,176,0.12)',
}}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke={accentColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
</div>
<div>
<div className="text-[15px] font-semibold text-dark-50">{buttonText}</div>
<div className="text-[12px] text-dark-50/40">{hintText}</div>
</div>
</div>
{/* Right: chevron */}
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="flex-shrink-0 text-dark-50/30 transition-transform duration-300 group-hover:translate-x-1"
>
<path d="M9 18l6-6-6-6" />
</svg>
</div>
</HoverBorderGradient>
</Link>
);
}

View File

@@ -318,6 +318,11 @@
"price": "Price",
"noSubscription": "You don't have an active subscription",
"getSubscription": "Get Subscription",
"cta": {
"expiredHint": "Choose a plan and pay",
"trialHint": "More traffic and devices",
"activeHint": "Renewal and plan change"
},
"connectionInfo": "Connection Info",
"copyLink": "Copy Link",
"copied": "Copied!",

View File

@@ -336,6 +336,11 @@
"price": "Цена",
"noSubscription": "У вас нет активной подписки",
"getSubscription": "Оформить подписку",
"cta": {
"expiredHint": "Выберите тариф и оплатите",
"trialHint": "Больше трафика и устройств",
"activeHint": "Продление и смена тарифа"
},
"connectionInfo": "Данные для подключения",
"copyLink": "Копировать ссылку",
"copied": "Скопировано!",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
import { AxiosError } from 'axios';
import i18n from '../i18n';
export type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm';
export const getErrorMessage = (error: unknown): string => {
if (error instanceof AxiosError) {
const detail = error.response?.data?.detail;
if (typeof detail === 'string') return detail;
if (typeof detail === 'object' && detail?.message) return detail.message;
}
if (error instanceof Error) return error.message;
return i18n.t('common.error');
};
export const getInsufficientBalanceError = (
error: unknown,
): { required: number; balance: number; missingAmount?: number } | null => {
if (error instanceof AxiosError) {
const detail = error.response?.data?.detail;
if (
typeof detail === 'object' &&
(detail?.code === 'insufficient_balance' || detail?.code === 'insufficient_funds')
) {
return {
required: detail.required || detail.total_price || 0,
balance: detail.balance || 0,
missingAmount: detail.missing_amount || detail.missingAmount || 0,
};
}
}
return null;
};
export const getFlagEmoji = (countryCode: string): string => {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};