fix subscription page
Build & Deploy / Build & Push Docker Image (push) Successful in 7m57s
Test / Type Check (all packages) (push) Successful in 4m27s
Build & Deploy / Deploy to VPS (push) Successful in 7s
Test / API Unit Tests (push) Failing after 3m2s
Test / Homepage Unit Tests (push) Successful in 4m3s
Test / Storefront Unit Tests (push) Successful in 3m32s
Test / Admin Unit Tests (push) Successful in 3m27s
Test / Dashboard Unit Tests (push) Successful in 3m3s
Test / API Integration Tests (push) Failing after 3m53s

This commit is contained in:
root
2026-06-29 23:15:55 -04:00
parent a752a399c2
commit f22e0d45e1
22 changed files with 842 additions and 72 deletions
@@ -51,7 +51,10 @@ const STATUS_BADGE: Record<string, string> = {
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-orange-100 text-orange-700',
CANCELLED: 'bg-slate-100 text-slate-600',
CANCELED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700',
EXPIRED: 'bg-red-100 text-red-700',
SUSPENDED: 'bg-red-100 text-red-700',
}
const INVOICE_STATUS: Record<string, string> = {
@@ -70,6 +73,8 @@ export default function SubscriptionPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [verificationError, setVerificationError] = useState<string | null>(null)
const [verificationAttempt, setVerificationAttempt] = useState(0)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
@@ -96,7 +101,7 @@ export default function SubscriptionPage() {
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
annual: 'Annual (save 20%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
@@ -113,10 +118,15 @@ export default function SubscriptionPage() {
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
verifying: 'Verifying access…',
accessDenied: 'Access denied',
accessDeniedBody: 'Only account owners can manage subscriptions.',
retry: 'Retry',
accessUnavailable: 'Unable to verify your access right now. Please try again.',
noInvoices: 'No invoices yet.',
noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.',
providerUnavailable: 'This payment provider is not configured.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', CANCELED: 'Canceled', UNPAID: 'Unpaid', EXPIRED: 'Expired', SUSPENDED: 'Suspended' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Storefront listing'],
@@ -139,7 +149,7 @@ export default function SubscriptionPage() {
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
annual: 'Annuel (économie 20%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
@@ -156,10 +166,15 @@ export default function SubscriptionPage() {
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
verifying: 'Vérification de laccès…',
accessDenied: 'Accès refusé',
accessDeniedBody: 'Seuls les propriétaires du compte peuvent gérer les abonnements.',
retry: 'Réessayer',
accessUnavailable: 'Impossible de vérifier votre accès pour le moment. Veuillez réessayer.',
noInvoices: 'Aucune facture pour le moment.',
noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.',
providerUnavailable: 'Ce prestataire de paiement nest pas configuré.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', CANCELED: 'Annulé', UNPAID: 'Impayé', EXPIRED: 'Expiré', SUSPENDED: 'Suspendu' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence storefront'],
@@ -182,7 +197,7 @@ export default function SubscriptionPage() {
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
annual: 'سنوي (توفير 20%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
@@ -199,10 +214,15 @@ export default function SubscriptionPage() {
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
verifying: 'جارٍ التحقق من الوصول…',
accessDenied: 'تم رفض الوصول',
accessDeniedBody: 'يمكن لمالكي الحساب فقط إدارة الاشتراكات.',
retry: 'إعادة المحاولة',
accessUnavailable: 'تعذر التحقق من وصولك الآن. يرجى المحاولة مرة أخرى.',
noInvoices: 'لا توجد فواتير حتى الآن.',
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', CANCELED: 'ملغى', UNPAID: 'غير مدفوع', EXPIRED: 'منتهي', SUSPENDED: 'معلّق' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
@@ -213,29 +233,34 @@ export default function SubscriptionPage() {
}[language]
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
return
} catch {}
}
let cancelled = false
setCanViewPage(null)
setVerificationError(null)
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
})
.catch(() => {
setCanViewPage(false)
router.replace('/')
.catch((err: any) => {
if (cancelled) return
if (err?.statusCode === 401) {
router.replace('/sign-in?redirect=%2Fsubscription')
return
}
if (err?.statusCode === 403) {
setCanViewPage(false)
return
}
setVerificationError(err?.message ?? copy.accessUnavailable)
})
}, [router])
return () => {
cancelled = true
}
}, [copy.accessUnavailable, router, verificationAttempt])
const fetchPlanData = useCallback(async () => {
const [prices, features] = await Promise.all([
@@ -285,8 +310,41 @@ export default function SubscriptionPage() {
}
}, [canViewPage, fetchPlanData])
if (verificationError) {
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessUnavailable}</h2>
<p className="mt-2 text-sm text-slate-500">{verificationError}</p>
<button
type="button"
onClick={() => setVerificationAttempt((value) => value + 1)}
className="btn-primary mt-5"
>
{copy.retry}
</button>
</div>
</div>
)
}
if (canViewPage === null) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" aria-label={copy.verifying} />
</div>
)
}
if (canViewPage !== true) {
return null
return (
<div className="flex min-h-[40vh] items-center justify-center px-6">
<div className="card max-w-md p-6 text-center">
<h2 className="text-base font-semibold text-slate-900">{copy.accessDenied}</h2>
<p className="mt-2 text-sm text-slate-500">{copy.accessDeniedBody}</p>
</div>
</div>
)
}
async function handleCheckout() {