626 lines
26 KiB
TypeScript
626 lines
26 KiB
TypeScript
'use client'
|
||
|
||
import { useRouter } from 'next/navigation'
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||
|
||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
|
||
|
||
interface Subscription {
|
||
id: string
|
||
plan: Plan
|
||
billingPeriod: BillingPeriod
|
||
status: string
|
||
currency: string
|
||
trialEndAt: string | null
|
||
currentPeriodEnd: string | null
|
||
cancelAtPeriodEnd: boolean
|
||
}
|
||
|
||
interface Invoice {
|
||
id: string
|
||
amount: number
|
||
currency: string
|
||
status: string
|
||
paymentProvider: string
|
||
paidAt: string | null
|
||
createdAt: string
|
||
}
|
||
|
||
interface ProviderAvailability {
|
||
amanpay: boolean
|
||
paypal: boolean
|
||
}
|
||
|
||
interface PlanFeature {
|
||
id: string
|
||
plan: Plan
|
||
label: string
|
||
sortOrder: number
|
||
}
|
||
|
||
interface EmployeeProfile {
|
||
role?: string
|
||
}
|
||
|
||
const STATUS_BADGE: Record<string, string> = {
|
||
TRIALING: 'bg-sky-100 text-sky-700',
|
||
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> = {
|
||
PAID: 'bg-green-100 text-green-700',
|
||
PENDING: 'bg-orange-100 text-orange-700',
|
||
FAILED: 'bg-red-100 text-red-700',
|
||
REFUNDED: 'bg-slate-100 text-slate-600',
|
||
}
|
||
|
||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||
export default function SubscriptionPage() {
|
||
const router = useRouter()
|
||
const { language } = useDashboardI18n()
|
||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||
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')
|
||
const currency = 'MAD'
|
||
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
|
||
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
|
||
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
|
||
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
|
||
const [paying, setPaying] = useState(false)
|
||
const [cancelling, setCancelling] = useState(false)
|
||
const copy = {
|
||
en: {
|
||
title: 'Subscription',
|
||
subtitle: 'Manage your plan, payment provider, and subscription invoices.',
|
||
trial: 'Free trial',
|
||
remaining: 'remaining. Subscribe before it ends to keep access.',
|
||
currentPlan: 'Current plan',
|
||
renews: 'renews',
|
||
cancelScheduled: 'Cancellation scheduled at end of billing period.',
|
||
undo: 'Undo',
|
||
cancelling: 'Cancelling…',
|
||
cancelPlan: 'Cancel plan',
|
||
changePlan: 'Change plan',
|
||
subscribe: 'Subscribe',
|
||
selectPlan: 'Select a plan and payment provider to proceed.',
|
||
monthly: 'Monthly',
|
||
annual: 'Annual (save 20%)',
|
||
active: 'Active',
|
||
perMonthShort: 'mo',
|
||
perYearShort: 'yr',
|
||
paymentProvider: 'Payment provider',
|
||
total: 'Total',
|
||
perMonth: 'month',
|
||
perYear: 'year',
|
||
redirecting: 'Redirecting…',
|
||
subscribeNow: 'Subscribe now',
|
||
invoiceHistory: 'Invoice history',
|
||
date: 'Date',
|
||
provider: 'Provider',
|
||
status: 'Status',
|
||
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', 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', 'Carplace listing'],
|
||
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
|
||
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
|
||
} as Record<Plan, string[]>,
|
||
},
|
||
fr: {
|
||
title: 'Abonnement',
|
||
subtitle: 'Gérez votre plan, le prestataire de paiement et les factures d’abonnement.',
|
||
trial: 'Essai gratuit',
|
||
remaining: 'restants. Abonnez-vous avant la fin pour garder l’accès.',
|
||
currentPlan: 'Plan actuel',
|
||
renews: 'renouvelle le',
|
||
cancelScheduled: 'Annulation programmée à la fin de la période.',
|
||
undo: 'Annuler',
|
||
cancelling: 'Annulation…',
|
||
cancelPlan: 'Annuler le plan',
|
||
changePlan: 'Changer de plan',
|
||
subscribe: 'S’abonner',
|
||
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
|
||
monthly: 'Mensuel',
|
||
annual: 'Annuel (économie 20%)',
|
||
active: 'Actif',
|
||
perMonthShort: 'mois',
|
||
perYearShort: 'an',
|
||
paymentProvider: 'Prestataire de paiement',
|
||
total: 'Total',
|
||
perMonth: 'mois',
|
||
perYear: 'an',
|
||
redirecting: 'Redirection…',
|
||
subscribeNow: 'S’abonner maintenant',
|
||
invoiceHistory: 'Historique des factures',
|
||
date: 'Date',
|
||
provider: 'Prestataire',
|
||
status: 'Statut',
|
||
paid: 'Payé',
|
||
amount: 'Montant',
|
||
loading: 'Chargement…',
|
||
verifying: 'Vérification de l’accè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 n’est configuré. Contactez le support pour activer AmanPay ou PayPal.',
|
||
providerUnavailable: 'Ce prestataire de paiement n’est pas configuré.',
|
||
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 sur Carplace'],
|
||
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
|
||
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
|
||
} as Record<Plan, string[]>,
|
||
},
|
||
ar: {
|
||
title: 'الاشتراك',
|
||
subtitle: 'إدارة الخطة ومزوّد الدفع وفواتير الاشتراك.',
|
||
trial: 'تجربة مجانية',
|
||
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
|
||
currentPlan: 'الخطة الحالية',
|
||
renews: 'يتجدد في',
|
||
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
|
||
undo: 'تراجع',
|
||
cancelling: 'جارٍ الإلغاء…',
|
||
cancelPlan: 'إلغاء الخطة',
|
||
changePlan: 'تغيير الخطة',
|
||
subscribe: 'اشتراك',
|
||
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
|
||
monthly: 'شهري',
|
||
annual: 'سنوي (توفير 20%)',
|
||
active: 'نشط',
|
||
perMonthShort: 'شهر',
|
||
perYearShort: 'سنة',
|
||
paymentProvider: 'مزوّد الدفع',
|
||
total: 'الإجمالي',
|
||
perMonth: 'شهر',
|
||
perYear: 'سنة',
|
||
redirecting: 'جارٍ التحويل…',
|
||
subscribeNow: 'اشترك الآن',
|
||
invoiceHistory: 'سجل الفواتير',
|
||
date: 'التاريخ',
|
||
provider: 'المزوّد',
|
||
status: 'الحالة',
|
||
paid: 'مدفوع',
|
||
amount: 'المبلغ',
|
||
loading: 'جارٍ التحميل…',
|
||
verifying: 'جارٍ التحقق من الوصول…',
|
||
accessDenied: 'تم رفض الوصول',
|
||
accessDeniedBody: 'يمكن لمالكي الحساب فقط إدارة الاشتراكات.',
|
||
retry: 'إعادة المحاولة',
|
||
accessUnavailable: 'تعذر التحقق من وصولك الآن. يرجى المحاولة مرة أخرى.',
|
||
noInvoices: 'لا توجد فواتير حتى الآن.',
|
||
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
|
||
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
|
||
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 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
|
||
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
|
||
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
|
||
} as Record<Plan, string[]>,
|
||
},
|
||
}[language]
|
||
|
||
useEffect(() => {
|
||
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((err: any) => {
|
||
if (cancelled) return
|
||
if (err?.statusCode === 401) {
|
||
window.location.replace('/dashboard/sign-in?redirect=%2Fdashboard%2Fsubscription')
|
||
return
|
||
}
|
||
if (err?.statusCode === 403) {
|
||
setCanViewPage(false)
|
||
return
|
||
}
|
||
setVerificationError(err?.message ?? copy.accessUnavailable)
|
||
})
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [copy.accessUnavailable, router, verificationAttempt])
|
||
|
||
const fetchPlanData = useCallback(async () => {
|
||
const [prices, features] = await Promise.all([
|
||
apiFetch<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
|
||
apiFetch<PlanFeature[]>('/subscriptions/features'),
|
||
])
|
||
if (prices && Object.keys(prices).length > 0) setPlanPrices(prices)
|
||
if (features) setPlanFeaturesList(features)
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (canViewPage !== true) return
|
||
|
||
Promise.all([
|
||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||
apiFetch<ProviderAvailability>('/subscriptions/providers'),
|
||
fetchPlanData(),
|
||
])
|
||
.then(([sub, inv, availability]) => {
|
||
setProviderAvailability(availability)
|
||
if (availability.amanpay) setProvider('AMANPAY')
|
||
else if (availability.paypal) setProvider('PAYPAL')
|
||
if (sub) {
|
||
setSubscription(sub)
|
||
setSelectedPlan(sub.plan)
|
||
setBillingPeriod(sub.billingPeriod)
|
||
// currency is always MAD
|
||
}
|
||
setInvoices(inv ?? [])
|
||
})
|
||
.catch((err) => setError(err.message))
|
||
.finally(() => setLoading(false))
|
||
}, [canViewPage, fetchPlanData])
|
||
|
||
useEffect(() => {
|
||
if (canViewPage !== true) return
|
||
const handleFocus = () => { fetchPlanData().catch(() => {}) }
|
||
const handleVisibility = () => {
|
||
if (document.visibilityState === 'visible') fetchPlanData().catch(() => {})
|
||
}
|
||
window.addEventListener('focus', handleFocus)
|
||
document.addEventListener('visibilitychange', handleVisibility)
|
||
return () => {
|
||
window.removeEventListener('focus', handleFocus)
|
||
document.removeEventListener('visibilitychange', handleVisibility)
|
||
}
|
||
}, [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 (
|
||
<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() {
|
||
setPaying(true)
|
||
setError(null)
|
||
try {
|
||
if (provider === 'AMANPAY' && !providerAvailability.amanpay) throw new Error(copy.providerUnavailable)
|
||
if (provider === 'PAYPAL' && !providerAvailability.paypal) throw new Error(copy.providerUnavailable)
|
||
const currentUrl = new URL(window.location.href)
|
||
currentUrl.search = ''
|
||
currentUrl.hash = ''
|
||
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
plan: selectedPlan,
|
||
billingPeriod,
|
||
currency,
|
||
provider,
|
||
successUrl: `${currentUrl.toString()}?payment=success`,
|
||
failureUrl: `${currentUrl.toString()}?payment=failed`,
|
||
}),
|
||
})
|
||
window.location.href = result.checkoutUrl
|
||
} catch (err: any) {
|
||
setError(err.message)
|
||
setPaying(false)
|
||
}
|
||
}
|
||
|
||
async function handleCancel() {
|
||
setCancelling(true)
|
||
setError(null)
|
||
try {
|
||
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
|
||
setSubscription(sub)
|
||
} catch (err: any) {
|
||
setError(err.message)
|
||
} finally {
|
||
setCancelling(false)
|
||
}
|
||
}
|
||
|
||
async function handleResume() {
|
||
setCancelling(true)
|
||
setError(null)
|
||
try {
|
||
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
|
||
setSubscription(sub)
|
||
} catch (err: any) {
|
||
setError(err.message)
|
||
} finally {
|
||
setCancelling(false)
|
||
}
|
||
}
|
||
|
||
const planPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
|
||
const daysLeft = subscription?.trialEndAt
|
||
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
|
||
: null
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<div>
|
||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
|
||
)}
|
||
|
||
{/* Trial banner */}
|
||
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
|
||
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
|
||
<p className="text-sm font-medium text-sky-800">
|
||
{copy.trial} — <strong>{daysLeft} days</strong> {copy.remaining}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Current plan */}
|
||
{subscription && (
|
||
<div className="card p-6">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
|
||
<div className="mt-1 flex items-center gap-3">
|
||
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||
{copy.statusLabels[subscription.status] ?? subscription.status}
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 text-sm text-slate-500">
|
||
{subscription.billingPeriod} · {subscription.currency}
|
||
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
|
||
</p>
|
||
{subscription.cancelAtPeriodEnd && (
|
||
<p className="mt-2 text-sm font-medium text-orange-700">
|
||
{copy.cancelScheduled}{' '}
|
||
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
|
||
</p>
|
||
)}
|
||
</div>
|
||
{!subscription.cancelAtPeriodEnd && (
|
||
<button
|
||
onClick={handleCancel}
|
||
disabled={cancelling}
|
||
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
|
||
>
|
||
{cancelling ? copy.cancelling : copy.cancelPlan}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Plan selector + checkout */}
|
||
<div className="card p-6 space-y-6">
|
||
{!providerAvailability.amanpay && !providerAvailability.paypal ? (
|
||
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
|
||
{copy.noProviderConfigured}
|
||
</div>
|
||
) : null}
|
||
<div>
|
||
<h3 className="text-base font-semibold text-slate-900">
|
||
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
|
||
</h3>
|
||
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
|
||
</div>
|
||
|
||
{/* Billing period toggle */}
|
||
<div className="flex items-center gap-2">
|
||
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
|
||
<button
|
||
key={p}
|
||
onClick={() => setBillingPeriod(p)}
|
||
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||
billingPeriod === p ? 'bg-blue-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||
}`}
|
||
>
|
||
{p === 'MONTHLY' ? copy.monthly : copy.annual}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Plan cards */}
|
||
<div className="grid gap-4 md:grid-cols-3">
|
||
{PLANS.map((plan) => {
|
||
const price = planPrices[plan]?.[billingPeriod]?.[currency]
|
||
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
|
||
const features = planFeaturesList.filter((f) => f.plan === plan)
|
||
return (
|
||
<button
|
||
key={plan}
|
||
onClick={() => setSelectedPlan(plan)}
|
||
className={`text-left p-5 rounded-xl border-2 transition-all ${
|
||
selectedPlan === plan
|
||
? 'border-blue-500 bg-blue-50/50'
|
||
: 'border-slate-200 hover:border-slate-300 bg-white'
|
||
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<p className="font-semibold text-slate-900">{plan}</p>
|
||
{isActive && <span className="badge-green">{copy.active}</span>}
|
||
</div>
|
||
<p className="mt-2 text-2xl font-black text-slate-900">
|
||
{price ? formatCurrency(price, 'MAD') : '—'}
|
||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
|
||
</p>
|
||
<ul className="mt-3 space-y-1">
|
||
{features.length > 0
|
||
? features.map((f) => (
|
||
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5">
|
||
<span className="text-green-500">✓</span> {f.label}
|
||
</li>
|
||
))
|
||
: copy.planFeatures[plan].map((f) => (
|
||
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
|
||
<span className="text-green-500">✓</span> {f}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{/* Provider selector */}
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
|
||
<div className="flex gap-3">
|
||
{providerAvailability.amanpay ? (
|
||
<button
|
||
onClick={() => setProvider('AMANPAY')}
|
||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
|
||
provider === 'AMANPAY' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||
}`}
|
||
>
|
||
🏦 AmanPay
|
||
</button>
|
||
) : null}
|
||
{providerAvailability.paypal ? (
|
||
<button
|
||
onClick={() => setProvider('PAYPAL')}
|
||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
|
||
provider === 'PAYPAL' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
|
||
}`}
|
||
>
|
||
🔵 PayPal
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Checkout CTA */}
|
||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||
<div>
|
||
<p className="text-sm text-slate-500">{copy.total}</p>
|
||
<p className="text-xl font-black text-slate-900">
|
||
{planPrice ? formatCurrency(planPrice, 'MAD') : '—'}
|
||
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={handleCheckout}
|
||
disabled={paying || loading || (!providerAvailability.amanpay && !providerAvailability.paypal)}
|
||
className="btn-primary px-8 py-3"
|
||
>
|
||
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Invoice history */}
|
||
<div className="card overflow-hidden">
|
||
<div className="px-6 py-4 border-b border-slate-200">
|
||
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="bg-slate-50 border-b border-slate-200">
|
||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
|
||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
|
||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
|
||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
|
||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-slate-100">
|
||
{loading ? (
|
||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
|
||
) : invoices.length === 0 ? (
|
||
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
|
||
) : invoices.map((inv) => (
|
||
<tr key={inv.id}>
|
||
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
|
||
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
|
||
<td className="px-6 py-4">
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
|
||
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-slate-500">
|
||
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
|
||
</td>
|
||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
|
||
{formatCurrency(inv.amount, 'MAD')}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|