add subscription upgrade plan
Build & Push / Pipeline Tests (push) Failing after 2m0s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Failing after 1m7s
Build & Push / Pipeline Tests (push) Failing after 2m0s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Successful in 1m8s
Test / Homepage Unit Tests (push) Successful in 48s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 41s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Failing after 1m7s
This commit is contained in:
@@ -18,6 +18,7 @@ interface Subscription {
|
||||
status: string
|
||||
currency: string
|
||||
trialEndAt: string | null
|
||||
currentPeriodStart: string | null
|
||||
currentPeriodEnd: string | null
|
||||
cancelAtPeriodEnd: boolean
|
||||
}
|
||||
@@ -67,6 +68,31 @@ interface ManualCheckoutResult {
|
||||
instructions: Record<string, string>
|
||||
}
|
||||
|
||||
interface UpgradeQuoteResult {
|
||||
request: {
|
||||
id: string
|
||||
status: string
|
||||
}
|
||||
quote: {
|
||||
id: string
|
||||
subtotalAmount: number
|
||||
taxAmount: number
|
||||
totalAmount: number
|
||||
currency: string
|
||||
expiresAt: string
|
||||
renewalDate: string
|
||||
}
|
||||
}
|
||||
|
||||
interface UpgradeAcceptResult {
|
||||
request: {
|
||||
id: string
|
||||
status: string
|
||||
}
|
||||
invoice: Invoice | null
|
||||
instructions: Record<string, string> | null
|
||||
}
|
||||
|
||||
type CommunicationLocale = 'ar' | 'en' | 'fr'
|
||||
interface CommunicationSettings {
|
||||
timezone: string
|
||||
@@ -124,6 +150,12 @@ const INVOICE_STATUS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
|
||||
const PLAN_RANK: Record<Plan, number> = {
|
||||
STARTER: 1,
|
||||
GROWTH: 2,
|
||||
PRO: 3,
|
||||
ENTERPRISE: 4,
|
||||
}
|
||||
const PLAN_LABELS: Record<Plan, string> = {
|
||||
STARTER: 'Launch',
|
||||
GROWTH: 'Growth',
|
||||
@@ -154,6 +186,53 @@ function validatePaymentEvidenceFiles(files: File[]) {
|
||||
return null
|
||||
}
|
||||
|
||||
function utcDateOnly(date: Date) {
|
||||
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
||||
}
|
||||
|
||||
function calendarDaysBetween(start: Date, end: Date) {
|
||||
return Math.ceil((utcDateOnly(end) - utcDateOnly(start)) / 86400000)
|
||||
}
|
||||
|
||||
function buildUpgradeProrationPreview(input: {
|
||||
subscription: Subscription | null
|
||||
selectedPlan: Plan
|
||||
billingPeriod: BillingPeriod
|
||||
planPrices: Record<string, Record<string, Record<string, number>>>
|
||||
currency: string
|
||||
}) {
|
||||
const { subscription, selectedPlan, billingPeriod, planPrices, currency } = input
|
||||
if (!subscription || subscription.status !== 'ACTIVE') return null
|
||||
if (PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan]) return null
|
||||
if (billingPeriod !== subscription.billingPeriod) return null
|
||||
if (!subscription.currentPeriodStart || !subscription.currentPeriodEnd) return null
|
||||
|
||||
const currentPrice = planPrices[subscription.plan]?.[billingPeriod]?.[currency]
|
||||
const targetPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
|
||||
if (!currentPrice || !targetPrice) return null
|
||||
|
||||
const termStart = new Date(subscription.currentPeriodStart)
|
||||
const renewalDate = new Date(subscription.currentPeriodEnd)
|
||||
const now = new Date()
|
||||
const termDays = calendarDaysBetween(termStart, renewalDate)
|
||||
const remainingDays = Math.max(0, calendarDaysBetween(now, renewalDate))
|
||||
if (termDays <= 0 || remainingDays <= 0) return null
|
||||
|
||||
const currentCredit = Math.round((currentPrice * remainingDays) / termDays)
|
||||
const targetRemainingValue = Math.round((targetPrice * remainingDays) / termDays)
|
||||
const subtotal = Math.max(0, targetRemainingValue - currentCredit)
|
||||
return {
|
||||
currentPrice,
|
||||
targetPrice,
|
||||
termDays,
|
||||
remainingDays,
|
||||
currentCredit,
|
||||
targetRemainingValue,
|
||||
subtotal,
|
||||
renewalDate,
|
||||
}
|
||||
}
|
||||
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useDashboardI18n()
|
||||
@@ -198,6 +277,16 @@ export default function SubscriptionPage() {
|
||||
cancelling: 'Cancelling…',
|
||||
cancelPlan: 'Cancel plan',
|
||||
changePlan: 'Change plan',
|
||||
planUpgrade: 'Plan upgrade',
|
||||
selectHigherPlan: 'Choose a higher plan to request an upgrade.',
|
||||
upgradeManualOnly: 'Plan upgrades require bank transfer or check payment evidence before finance approval.',
|
||||
upgradeEstimateTitle: 'Remaining-term upgrade calculation',
|
||||
upgradeEstimateHelp: 'Estimated amount due for the unused part of your current billing term. The final quote is frozen when you submit payment evidence.',
|
||||
targetRemainingValue: 'Target plan value for remaining days',
|
||||
currentPlanCredit: 'Current plan credit for remaining days',
|
||||
upgradeSubtotal: 'Estimated upgrade due before tax',
|
||||
remainingDays: 'Remaining days',
|
||||
finalQuoteNote: 'Taxes and rounding are confirmed by the server quote.',
|
||||
subscribe: 'Subscribe',
|
||||
selectPlan: 'Select a plan to continue to Stripe checkout.',
|
||||
selectPayment: 'Choose Stripe, bank transfer, or check. The server calculates the final amount.',
|
||||
@@ -281,6 +370,16 @@ export default function SubscriptionPage() {
|
||||
cancelling: 'Annulation…',
|
||||
cancelPlan: 'Annuler le plan',
|
||||
changePlan: 'Changer de plan',
|
||||
planUpgrade: 'Mise à niveau du plan',
|
||||
selectHigherPlan: 'Choisissez un plan supérieur pour demander une mise à niveau.',
|
||||
upgradeManualOnly: 'Les mises à niveau de plan nécessitent un virement bancaire ou un chèque avec justificatif avant validation financière.',
|
||||
upgradeEstimateTitle: 'Calcul de mise à niveau pour la période restante',
|
||||
upgradeEstimateHelp: 'Montant estimé dû pour la partie non utilisée de la période de facturation actuelle. Le devis final est figé lors de l’envoi du justificatif.',
|
||||
targetRemainingValue: 'Valeur du nouveau plan pour les jours restants',
|
||||
currentPlanCredit: 'Crédit du plan actuel pour les jours restants',
|
||||
upgradeSubtotal: 'Mise à niveau estimée hors taxe',
|
||||
remainingDays: 'Jours restants',
|
||||
finalQuoteNote: 'Les taxes et l’arrondi sont confirmés par le devis serveur.',
|
||||
subscribe: 'S’abonner',
|
||||
selectPlan: 'Sélectionnez un plan pour continuer vers Stripe Checkout.',
|
||||
selectPayment: 'Choisissez Stripe, virement bancaire ou chèque. Le serveur calcule le montant final.',
|
||||
@@ -364,6 +463,16 @@ export default function SubscriptionPage() {
|
||||
cancelling: 'جارٍ الإلغاء…',
|
||||
cancelPlan: 'إلغاء الخطة',
|
||||
changePlan: 'تغيير الخطة',
|
||||
planUpgrade: 'ترقية الخطة',
|
||||
selectHigherPlan: 'اختر خطة أعلى لطلب الترقية.',
|
||||
upgradeManualOnly: 'تتطلب ترقيات الخطة تحويلاً بنكياً أو شيكاً مع إثبات دفع قبل موافقة المالية.',
|
||||
upgradeEstimateTitle: 'حساب الترقية للفترة المتبقية',
|
||||
upgradeEstimateHelp: 'مبلغ تقديري مستحق عن الجزء غير المستخدم من فترة الفوترة الحالية. يتم تثبيت العرض النهائي عند إرسال إثبات الدفع.',
|
||||
targetRemainingValue: 'قيمة الخطة الجديدة للأيام المتبقية',
|
||||
currentPlanCredit: 'رصيد الخطة الحالية للأيام المتبقية',
|
||||
upgradeSubtotal: 'مبلغ الترقية التقديري قبل الضريبة',
|
||||
remainingDays: 'الأيام المتبقية',
|
||||
finalQuoteNote: 'يؤكد عرض الخادم الضرائب والتقريب.',
|
||||
subscribe: 'اشتراك',
|
||||
selectPlan: 'اختر خطة للمتابعة إلى Stripe Checkout.',
|
||||
selectPayment: 'اختر Stripe أو التحويل البنكي أو الشيك. يحسب الخادم المبلغ النهائي.',
|
||||
@@ -526,12 +635,20 @@ export default function SubscriptionPage() {
|
||||
}
|
||||
}, [canViewPage, fetchPlanData])
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription?.status !== 'ACTIVE') return
|
||||
if (PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan]) return
|
||||
if (selectedMethod !== 'STRIPE') return
|
||||
const manualOption = paymentOptions.find((option) => option.enabled && option.method !== 'STRIPE')
|
||||
if (manualOption) setSelectedMethod(manualOption.method)
|
||||
}, [paymentOptions, selectedMethod, selectedPlan, subscription])
|
||||
|
||||
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>
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.accessUnavailable}</h2>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-zinc-400">{verificationError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVerificationAttempt((value) => value + 1)}
|
||||
@@ -556,8 +673,8 @@ export default function SubscriptionPage() {
|
||||
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>
|
||||
<h2 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.accessDenied}</h2>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-zinc-400">{copy.accessDeniedBody}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -569,6 +686,16 @@ export default function SubscriptionPage() {
|
||||
try {
|
||||
const selectedOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
|
||||
if (!selectedOption) throw new Error(copy.providerUnavailable)
|
||||
const isPlanUpgrade = Boolean(subscription?.status === 'ACTIVE' && PLAN_RANK[selectedPlan] > PLAN_RANK[subscription.plan])
|
||||
if (subscription?.status === 'ACTIVE' && PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan]) {
|
||||
throw new Error(copy.selectHigherPlan)
|
||||
}
|
||||
if (isPlanUpgrade && billingPeriod !== subscription?.billingPeriod) {
|
||||
throw new Error(copy.selectHigherPlan)
|
||||
}
|
||||
if (isPlanUpgrade && selectedMethod === 'STRIPE') {
|
||||
throw new Error(copy.upgradeManualOnly)
|
||||
}
|
||||
if (selectedMethod !== 'STRIPE') {
|
||||
if (paymentReference.trim().length < 3) throw new Error(`${manualReferenceLabel} is required`)
|
||||
if (evidenceFiles.length === 0) throw new Error(manualEvidenceLabel)
|
||||
@@ -576,10 +703,23 @@ export default function SubscriptionPage() {
|
||||
if (fileError) throw new Error(fileError)
|
||||
const idempotencyKey = checkoutIdempotencyKey ?? crypto.randomUUID()
|
||||
setCheckoutIdempotencyKey(idempotencyKey)
|
||||
const result = await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }),
|
||||
})
|
||||
const result = isPlanUpgrade
|
||||
? await (async () => {
|
||||
const quoted = await apiFetch<UpgradeQuoteResult>('/subscriptions/upgrade-quotes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ targetPlan: selectedPlan, requestType: 'IMMEDIATE_PRORATED', idempotencyKey }),
|
||||
})
|
||||
const accepted = await apiFetch<UpgradeAcceptResult>(`/subscriptions/upgrade-requests/${quoted.request.id}/accept`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ method: selectedMethod, acceptedTermsVersion: 'subscription-upgrade-terms-v1', idempotencyKey }),
|
||||
})
|
||||
if (!accepted.invoice || !accepted.instructions) throw new Error('Upgrade payment invoice was not created.')
|
||||
return { invoice: accepted.invoice, instructions: accepted.instructions }
|
||||
})()
|
||||
: await apiFetch<ManualCheckoutResult>('/subscriptions/manual-checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plan: selectedPlan, billingPeriod, currency, method: selectedMethod, idempotencyKey }),
|
||||
})
|
||||
setManualPaymentRequestNumber(result.invoice.invoiceNumber ?? result.invoice.id)
|
||||
|
||||
const key = submissionIdempotencyKey ?? crypto.randomUUID()
|
||||
@@ -753,68 +893,74 @@ export default function SubscriptionPage() {
|
||||
: null
|
||||
const selectedPaymentOption = paymentOptions.find((option) => option.method === selectedMethod && option.enabled)
|
||||
const isManualMethod = selectedMethod === 'BANK_TRANSFER' || selectedMethod === 'CHECK'
|
||||
const isActiveSubscription = subscription?.status === 'ACTIVE'
|
||||
const isPlanUpgradeSelection = Boolean(isActiveSubscription && subscription && PLAN_RANK[selectedPlan] > PLAN_RANK[subscription.plan])
|
||||
const isInvalidActiveUpgradeSelection = Boolean(isActiveSubscription && subscription && PLAN_RANK[selectedPlan] <= PLAN_RANK[subscription.plan])
|
||||
const activeUpgradeManualRequired = Boolean(isPlanUpgradeSelection && selectedMethod === 'STRIPE')
|
||||
const upgradeProration = buildUpgradeProrationPreview({ subscription, selectedPlan, billingPeriod, planPrices, currency })
|
||||
const payableAmount = upgradeProration?.subtotal ?? planPrice
|
||||
const manualReferenceLabel = selectedMethod === 'CHECK' ? copy.checkNumber : copy.bankTransferReference
|
||||
const manualEvidenceLabel = selectedMethod === 'CHECK' ? copy.checkEvidence : copy.bankTransferEvidence
|
||||
const manualEvidenceKindLabel = selectedMethod === 'CHECK' ? copy.check : copy.bankTransfer
|
||||
const manualDetailsForm = isManualMethod ? (
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900">{copy.manualDetailsTitle}</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.manualDetailsHelp}</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-zinc-100">{copy.manualDetailsTitle}</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{copy.manualDetailsHelp}</p>
|
||||
</div>
|
||||
{selectedPaymentOption?.instructions && !manualCheckout ? (
|
||||
<div className="mt-4 rounded-xl bg-white p-4">
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
|
||||
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
|
||||
<div className="mt-4 rounded-xl bg-white p-4 dark:bg-zinc-950">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-zinc-100">{copy.paymentInstructions}</p>
|
||||
<dl className="mt-2 grid gap-2 text-sm text-slate-700 dark:text-zinc-300 sm:grid-cols-2">
|
||||
{Object.entries(selectedPaymentOption.instructions).map(([key, value]) => (
|
||||
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
|
||||
<div key={key}><dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{key}</dt><dd className="font-medium">{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<label className="block text-sm font-medium text-slate-800">
|
||||
<label className="block text-sm font-medium text-slate-800 dark:text-zinc-200">
|
||||
{manualReferenceLabel}
|
||||
<input
|
||||
value={paymentReference}
|
||||
onChange={(event) => setPaymentReference(event.target.value)}
|
||||
maxLength={120}
|
||||
inputMode="text"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2"
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-800">
|
||||
<label className="block text-sm font-medium text-slate-800 dark:text-zinc-200">
|
||||
{manualEvidenceLabel}
|
||||
<input
|
||||
type="file"
|
||||
accept={PAYMENT_EVIDENCE_ACCEPT}
|
||||
multiple
|
||||
onChange={handleEvidenceFileChange}
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm"
|
||||
className="mt-1 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{evidenceFiles.length > 0 ? (
|
||||
<div className="mt-3 space-y-1">
|
||||
{evidenceFiles.map((file) => (
|
||||
<p key={`${file.name}-${file.size}-${file.lastModified}`} className="text-xs text-slate-600">
|
||||
<p key={`${file.name}-${file.size}-${file.lastModified}`} className="text-xs text-slate-600 dark:text-zinc-300">
|
||||
{manualEvidenceKindLabel}: {file.name}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{paymentSubmission?.documents.map((document) => (
|
||||
<p key={document.id} className="mt-2 text-xs text-slate-600">{document.originalFilename} · {document.scanStatus}</p>
|
||||
<p key={document.id} className="mt-2 text-xs text-slate-600 dark:text-zinc-300">{document.originalFilename} · {document.scanStatus}</p>
|
||||
))}
|
||||
<p className="mt-3 text-xs text-amber-900">{copy.evidenceWarning}</p>
|
||||
<p className="mt-3 text-xs text-amber-900 dark:text-amber-200">{copy.evidenceWarning}</p>
|
||||
</div>
|
||||
) : 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>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-zinc-100">{copy.title}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1 dark:text-zinc-400">{copy.subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -835,19 +981,19 @@ export default function SubscriptionPage() {
|
||||
<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>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-500">{copy.currentPlan}</p>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<h3 className="text-2xl font-bold text-slate-900">{PLAN_LABELS[subscription.plan]}</h3>
|
||||
<h3 className="text-2xl font-bold text-slate-900 dark:text-zinc-100">{PLAN_LABELS[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">
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">
|
||||
{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">
|
||||
<p className="mt-2 text-sm font-medium text-orange-700 dark:text-orange-300">
|
||||
{copy.cancelScheduled}{' '}
|
||||
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
|
||||
</p>
|
||||
@@ -881,10 +1027,16 @@ export default function SubscriptionPage() {
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-slate-900">
|
||||
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">
|
||||
{isActiveSubscription ? copy.planUpgrade : copy.subscribe}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.selectPayment}</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{copy.selectPayment}</p>
|
||||
{isInvalidActiveUpgradeSelection ? (
|
||||
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">{copy.selectHigherPlan}</p>
|
||||
) : null}
|
||||
{activeUpgradeManualRequired ? (
|
||||
<p className="mt-2 text-sm text-amber-700 dark:text-amber-300">{copy.upgradeManualOnly}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Billing period toggle */}
|
||||
@@ -893,9 +1045,10 @@ export default function SubscriptionPage() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setBillingPeriod(p)}
|
||||
disabled={isActiveSubscription && subscription?.billingPeriod !== 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'
|
||||
}`}
|
||||
billingPeriod === p ? 'bg-blue-900 text-white dark:bg-blue-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
} ${isActiveSubscription && subscription?.billingPeriod !== p ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
{p === 'MONTHLY' ? copy.monthly : copy.annual}
|
||||
</button>
|
||||
@@ -914,27 +1067,27 @@ export default function SubscriptionPage() {
|
||||
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'
|
||||
? 'border-blue-500 bg-blue-50/50 dark:bg-blue-950/40'
|
||||
: 'border-slate-200 hover:border-slate-300 bg-white dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-600'
|
||||
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-slate-900">{PLAN_LABELS[plan]}</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-zinc-100">{PLAN_LABELS[plan]}</p>
|
||||
{isActive && <span className="badge-green">{copy.active}</span>}
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-black text-slate-900">
|
||||
<p className="mt-2 text-2xl font-black text-slate-900 dark:text-zinc-100">
|
||||
{price ? formatCurrency(price, 'MAD') : '—'}
|
||||
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
|
||||
<span className="text-sm font-normal text-slate-500 dark:text-zinc-400">/{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">
|
||||
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5 dark:text-zinc-300">
|
||||
<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">
|
||||
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5 dark:text-zinc-300">
|
||||
<span className="text-green-500">✓</span> {f}
|
||||
</li>
|
||||
))}
|
||||
@@ -944,15 +1097,46 @@ export default function SubscriptionPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{upgradeProration ? (
|
||||
<div className="rounded-2xl border border-blue-200 bg-blue-50 p-5 dark:border-blue-800 dark:bg-blue-950/40">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-blue-950 dark:text-blue-100">{copy.upgradeEstimateTitle}</p>
|
||||
<p className="mt-1 text-sm text-blue-800 dark:text-blue-200">{copy.upgradeEstimateHelp}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-blue-800 dark:bg-blue-900 dark:text-blue-100">
|
||||
{copy.remainingDays}: {upgradeProration.remainingDays}
|
||||
</span>
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div className="rounded-xl bg-white p-3 dark:bg-zinc-950">
|
||||
<dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{copy.targetRemainingValue}</dt>
|
||||
<dd className="mt-1 font-semibold text-slate-900 dark:text-zinc-100">{formatCurrency(upgradeProration.targetRemainingValue, currency)}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-3 dark:bg-zinc-950">
|
||||
<dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{copy.currentPlanCredit}</dt>
|
||||
<dd className="mt-1 font-semibold text-slate-900 dark:text-zinc-100">-{formatCurrency(upgradeProration.currentCredit, currency)}</dd>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-3 dark:bg-zinc-950">
|
||||
<dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{copy.upgradeSubtotal}</dt>
|
||||
<dd className="mt-1 font-black text-slate-950 dark:text-zinc-50">{formatCurrency(upgradeProration.subtotal, currency)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="mt-3 text-xs text-blue-800 dark:text-blue-200">{copy.finalQuoteNote}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Provider selector */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2 dark:text-zinc-300">{copy.paymentProvider}</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{paymentOptions.filter((option) => option.enabled).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.method}
|
||||
disabled={isPlanUpgradeSelection && option.method === 'STRIPE'}
|
||||
onClick={() => {
|
||||
if (isPlanUpgradeSelection && option.method === 'STRIPE') return
|
||||
setSelectedMethod(option.method)
|
||||
setManualCheckout(null)
|
||||
setManualPaymentRequestNumber(null)
|
||||
@@ -963,8 +1147,8 @@ export default function SubscriptionPage() {
|
||||
setEvidenceFiles([])
|
||||
}}
|
||||
className={`flex items-center gap-2 rounded-xl border-2 px-4 py-2.5 text-sm font-medium ${
|
||||
selectedMethod === option.method ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600'
|
||||
}`}
|
||||
selectedMethod === option.method ? 'border-blue-500 bg-blue-50 text-blue-700 dark:bg-blue-950/50 dark:text-blue-200' : 'border-slate-200 text-slate-600 dark:border-zinc-700 dark:text-zinc-300'
|
||||
} ${isPlanUpgradeSelection && option.method === 'STRIPE' ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
>
|
||||
{option.method === 'STRIPE' ? 'Stripe' : option.method === 'BANK_TRANSFER' ? copy.bankTransfer : copy.check}
|
||||
</button>
|
||||
@@ -975,12 +1159,14 @@ export default function SubscriptionPage() {
|
||||
{isManualMethod && !manualCheckout ? manualDetailsForm : null}
|
||||
|
||||
{/* Checkout CTA */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-zinc-800">
|
||||
<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 className="text-sm text-slate-500 dark:text-zinc-400">{copy.total}</p>
|
||||
<p className="text-xl font-black text-slate-900 dark:text-zinc-100">
|
||||
{payableAmount ? formatCurrency(payableAmount, 'MAD') : '—'}
|
||||
{!upgradeProration ? (
|
||||
<span className="text-sm font-normal text-slate-500 ml-1 dark:text-zinc-400">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -989,6 +1175,8 @@ export default function SubscriptionPage() {
|
||||
paying
|
||||
|| loading
|
||||
|| !paymentOptions.some((option) => option.method === selectedMethod && option.enabled)
|
||||
|| isInvalidActiveUpgradeSelection
|
||||
|| activeUpgradeManualRequired
|
||||
|| (isManualMethod && (paymentReference.trim().length < 3 || evidenceFiles.length === 0))
|
||||
}
|
||||
className="btn-primary px-8 py-3"
|
||||
@@ -996,13 +1184,13 @@ export default function SubscriptionPage() {
|
||||
{paying
|
||||
? (selectedMethod === 'STRIPE' ? copy.redirecting : copy.submittingReview)
|
||||
: selectedMethod === 'STRIPE'
|
||||
? (subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow)
|
||||
: copy.submitPaymentEvidence}
|
||||
? (isActiveSubscription ? copy.planUpgrade : copy.subscribeNow)
|
||||
: (isActiveSubscription ? copy.planUpgrade : copy.submitPaymentEvidence)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{paymentSubmission && paymentSubmission.status !== 'DRAFT' && !manualCheckout ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<p className="font-semibold">{copy.awaitingVerification}</p>
|
||||
{manualPaymentRequestNumber ? (
|
||||
<p className="mt-1">
|
||||
@@ -1015,33 +1203,33 @@ export default function SubscriptionPage() {
|
||||
) : null}
|
||||
|
||||
{manualCheckout ? (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-5" aria-live="polite">
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-5 dark:border-amber-800 dark:bg-amber-950/40" aria-live="polite">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-amber-900">{copy.awaitingVerification}</p>
|
||||
<p className="mt-1 text-sm text-amber-800">
|
||||
<p className="font-semibold text-amber-900 dark:text-amber-100">{copy.awaitingVerification}</p>
|
||||
<p className="mt-1 text-sm text-amber-800 dark:text-amber-200">
|
||||
{copy.invoiceNumber}: {manualCheckout.invoice.invoiceNumber ?? manualCheckout.invoice.id} · {formatCurrency(manualCheckout.invoice.amountDue || manualCheckout.invoice.amount, 'MAD')}
|
||||
</p>
|
||||
<p className="text-sm text-amber-800">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
{copy.dueDate}: {manualCheckout.invoice.dueAt ? new Date(manualCheckout.invoice.dueAt).toLocaleDateString() : '—'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">{manualCheckout.invoice.status}</span>
|
||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800 dark:bg-amber-900 dark:text-amber-100">{manualCheckout.invoice.status}</span>
|
||||
</div>
|
||||
<div className="mt-4 rounded-xl bg-white/80 p-4">
|
||||
<p className="text-sm font-semibold text-slate-900">{copy.paymentInstructions}</p>
|
||||
<dl className="mt-2 grid gap-2 text-sm text-slate-700 sm:grid-cols-2">
|
||||
<div className="mt-4 rounded-xl bg-white/80 p-4 dark:bg-zinc-950/80">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-zinc-100">{copy.paymentInstructions}</p>
|
||||
<dl className="mt-2 grid gap-2 text-sm text-slate-700 dark:text-zinc-300 sm:grid-cols-2">
|
||||
{Object.entries(manualCheckout.instructions).map(([key, value]) => (
|
||||
<div key={key}><dt className="text-xs uppercase text-slate-500">{key}</dt><dd className="font-medium">{value}</dd></div>
|
||||
<div key={key}><dt className="text-xs uppercase text-slate-500 dark:text-zinc-500">{key}</dt><dd className="font-medium">{value}</dd></div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{paymentSubmission && paymentSubmission.status !== 'DRAFT' ? (
|
||||
<div className="mt-4 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-800">
|
||||
<div className="mt-4 rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-800 dark:border-green-800 dark:bg-green-950/40 dark:text-green-200">
|
||||
<p className="font-semibold">{copy.evidenceSubmitted}</p>
|
||||
<p className="mt-1">{paymentSubmission.status} · {paymentSubmission.documents.length} file(s)</p>
|
||||
{paymentSubmission.rejectionReason ? <p className="mt-2 text-red-700">{paymentSubmission.rejectionReason}</p> : null}
|
||||
{paymentSubmission.rejectionReason ? <p className="mt-2 text-red-700 dark:text-red-300">{paymentSubmission.rejectionReason}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
@@ -1062,16 +1250,16 @@ export default function SubscriptionPage() {
|
||||
|
||||
{communicationSettings ? (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900">{copy.communicationTitle}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.communicationHelp}</p>
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{copy.communicationTitle}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-zinc-400">{copy.communicationHelp}</p>
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700">{copy.enabledLanguages}</p>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-zinc-300">{copy.enabledLanguages}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
{(['ar', 'en', 'fr'] as CommunicationLocale[]).map((locale) => {
|
||||
const checked = communicationSettings.enabledCommunicationLocales.includes(locale)
|
||||
return (
|
||||
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase">
|
||||
<label key={locale} className="flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2 text-sm uppercase text-slate-700 dark:border-zinc-700 dark:text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
@@ -1095,30 +1283,30 @@ export default function SubscriptionPage() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
|
||||
{copy.defaultLanguage}
|
||||
<select value={communicationSettings.defaultCommunicationLocale} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, defaultCommunicationLocale: event.target.value as CommunicationLocale } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2">
|
||||
<select value={communicationSettings.defaultCommunicationLocale} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, defaultCommunicationLocale: event.target.value as CommunicationLocale } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100">
|
||||
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-zinc-300">
|
||||
{copy.timezone}
|
||||
<input value={communicationSettings.timezone} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, timezone: event.target.value } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2" />
|
||||
<input value={communicationSettings.timezone} onChange={(event) => setCommunicationSettings((current) => current ? { ...current, timezone: event.target.value } : current)} className="mt-2 w-full rounded-xl border border-slate-300 px-3 py-2 text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5 space-y-3">
|
||||
{communicationSettings.contacts.map((contact, index) => (
|
||||
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center">
|
||||
<div key={contact.id ?? contact.email} className="grid gap-3 rounded-xl border border-slate-200 p-4 sm:grid-cols-[1fr,220px] sm:items-center dark:border-zinc-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">{contact.email}</p>
|
||||
<p className="text-xs text-slate-500">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}</p>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-zinc-100">{contact.email}</p>
|
||||
<p className="text-xs text-slate-500 dark:text-zinc-400">{contact.isPrimary ? 'Primary · ' : ''}{contact.verified ? 'Verified' : 'Verification pending'} · {contact.employeeId ? 'In-app + email' : 'Email'}</p>
|
||||
</div>
|
||||
<label className="text-xs font-medium text-slate-600">
|
||||
<label className="text-xs font-medium text-slate-600 dark:text-zinc-300">
|
||||
{copy.contactLanguage}
|
||||
<select
|
||||
value={contact.locale ?? ''}
|
||||
onChange={(event) => setCommunicationSettings((current) => current ? { ...current, contacts: current.contacts.map((item, itemIndex) => itemIndex === index ? { ...item, locale: (event.target.value || null) as CommunicationLocale | null } : item) } : current)}
|
||||
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm"
|
||||
className="mt-1 w-full rounded-lg border border-slate-300 px-2 py-2 text-sm text-slate-900 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"
|
||||
>
|
||||
<option value="">{copy.inheritDefault}</option>
|
||||
{communicationSettings.enabledCommunicationLocales.map((locale) => <option key={locale} value={locale}>{locale.toUpperCase()}</option>)}
|
||||
@@ -1135,29 +1323,29 @@ export default function SubscriptionPage() {
|
||||
|
||||
{/* 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 className="px-6 py-4 border-b border-slate-200 dark:border-zinc-800">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-zinc-100">{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.invoice}</th>
|
||||
<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 className="bg-slate-50 border-b border-slate-200 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.invoice}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.date}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.provider}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.status}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.paid}</th>
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide dark:text-zinc-400">{copy.amount}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-zinc-800">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
|
||||
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400 dark:text-zinc-500">{copy.loading}</td></tr>
|
||||
) : invoices.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
|
||||
<tr><td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400 dark:text-zinc-500">{copy.noInvoices}</td></tr>
|
||||
) : invoices.map((inv) => (
|
||||
<tr key={inv.id}>
|
||||
<td className="px-6 py-4 text-sm font-medium text-blue-700">
|
||||
<td className="px-6 py-4 text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
<a
|
||||
href={`${resolveApiBase()}/subscriptions/invoices/${inv.id}/pdf`}
|
||||
target="_blank"
|
||||
@@ -1167,17 +1355,17 @@ export default function SubscriptionPage() {
|
||||
{inv.invoiceNumber ?? copy.invoice}
|
||||
</a>
|
||||
</td>
|
||||
<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 text-sm text-slate-700 dark:text-zinc-300">{new Date(inv.createdAt).toLocaleDateString()}</td>
|
||||
<td className="px-6 py-4 text-sm text-slate-700 dark:text-zinc-300">{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">
|
||||
<td className="px-6 py-4 text-sm text-slate-500 dark:text-zinc-400">
|
||||
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
|
||||
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900 dark:text-zinc-100">
|
||||
{formatCurrency(inv.amount, 'MAD')}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -184,12 +184,12 @@ describe('dashboard apiFetch', () => {
|
||||
credentials: 'include',
|
||||
}))
|
||||
expect(resolveRealtimeSocketTarget()).toEqual({
|
||||
origin: 'http://localhost:3000',
|
||||
path: '/dashboard/socket.io',
|
||||
origin: 'http://localhost:4000',
|
||||
path: '/socket.io',
|
||||
})
|
||||
})
|
||||
|
||||
it('routes proxied realtime connections through the dashboard socket path', async () => {
|
||||
it('routes realtime connections to the configured API origin instead of the dashboard proxy path', async () => {
|
||||
installBrowser()
|
||||
setBrowserHostname('rentaldrivego.ma')
|
||||
;(globalThis.window as any).location.origin = 'https://rentaldrivego.ma'
|
||||
@@ -198,8 +198,8 @@ describe('dashboard apiFetch', () => {
|
||||
const api = await import('./api')
|
||||
|
||||
expect(api.resolveRealtimeSocketTarget()).toEqual({
|
||||
origin: 'https://rentaldrivego.ma',
|
||||
path: '/dashboard/socket.io',
|
||||
origin: 'https://api.rentaldrivego.ma',
|
||||
path: '/socket.io',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -55,14 +55,29 @@ export function resolveApiOrigin(): string | null {
|
||||
export function resolveRealtimeSocketTarget(): { origin: string; path: string } | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const apiBase = resolveApiBase()
|
||||
const isDashboardProxy = apiBase === DASHBOARD_PROXY_API_BASE || apiBase.startsWith(`${DASHBOARD_PROXY_API_BASE}/`)
|
||||
const origin = isDashboardProxy ? window.location.origin : resolveApiOrigin()
|
||||
if (!origin) return null
|
||||
const configuredApiBase = process.env.NEXT_PUBLIC_API_URL
|
||||
if (configuredApiBase && !configuredApiBase.startsWith('/')) {
|
||||
try {
|
||||
return {
|
||||
origin: new URL(normalizeApiBase(configuredApiBase)).origin,
|
||||
path: '/socket.io',
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const currentHostname = window.location?.hostname
|
||||
if (currentHostname && isLocalBrowserHost(currentHostname)) {
|
||||
return {
|
||||
origin: 'http://localhost:4000',
|
||||
path: '/socket.io',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
origin,
|
||||
path: isDashboardProxy ? '/dashboard/socket.io' : '/socket.io',
|
||||
origin: resolveApiOrigin() ?? window.location.origin,
|
||||
path: '/socket.io',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user