Add proforma invoice step to subscription checkout
Insert a factor/invoice page between plan selection and payment showing billing-period choice, line items, and totals before redirecting to the gateway, moving payment-method selection to where the charge happens. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { CheckoutScreen } from "@/components/subscription/checkout-screen";
|
||||
|
||||
export default function SubscriptionCheckoutPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CheckoutScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
|
||||
|
||||
const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
|
||||
export const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
|
||||
|
||||
const PRICES: Record<PlanId, number | null> = {
|
||||
export const PRICES: Record<PlanId, number | null> = {
|
||||
Free: 0,
|
||||
Pro: 1_490_000,
|
||||
Business: 3_490_000,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { PRICES, type PlanId } from "@/components/settings/plan-comparison";
|
||||
|
||||
type SubscribeResponse = {
|
||||
paymentId: string;
|
||||
paymentUrl: string;
|
||||
};
|
||||
|
||||
type PaymentMethod = {
|
||||
id: string;
|
||||
displayNameFa: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
const BILLABLE_PLANS: PlanId[] = ["Pro", "Business"];
|
||||
const MONTH_OPTIONS = [1, 3, 6, 12];
|
||||
|
||||
export function CheckoutScreen() {
|
||||
const t = useTranslations("subscription");
|
||||
const tc = useTranslations("subscription.checkout");
|
||||
const tPlans = useTranslations("settings.plans");
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const cafeId = user?.cafeId;
|
||||
const role = user?.role;
|
||||
|
||||
const planParam = searchParams.get("plan") as PlanId | null;
|
||||
const isBillable = !!planParam && BILLABLE_PLANS.includes(planParam);
|
||||
const plan = (isBillable ? planParam : "Pro") as PlanId;
|
||||
|
||||
const [months, setMonths] = useState(1);
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
|
||||
const numberLocale =
|
||||
typeof document !== "undefined" && document.documentElement.lang === "en"
|
||||
? "en-US"
|
||||
: "fa-IR";
|
||||
const isRtl = numberLocale !== "en-US";
|
||||
|
||||
const cafeName = useMemo(() => {
|
||||
if (!user) return "";
|
||||
const membership = user.memberships?.find((m) => m.cafeId === user.cafeId);
|
||||
return membership?.cafeName ?? "";
|
||||
}, [user]);
|
||||
|
||||
const { data: paymentMethods = [] } = useQuery({
|
||||
queryKey: ["billing-payment-methods", cafeId],
|
||||
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
|
||||
enabled: !!cafeId && isCafeOwner(role),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentMethod && paymentMethods.length > 0) {
|
||||
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
||||
setPaymentMethod(def.id);
|
||||
}
|
||||
}, [paymentMethods, paymentMethod]);
|
||||
|
||||
const subscribe = useMutation({
|
||||
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
|
||||
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.paymentUrl;
|
||||
},
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
if (!isCafeOwner(role)) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
|
||||
<p className="text-sm text-muted-foreground">{t("ownerOnly")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isBillable) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardContent className="space-y-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{tc("invalidPlan")}</p>
|
||||
<Button variant="outline" onClick={() => router.push("/subscription")}>
|
||||
{tc("backToPlans")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const unitPrice = PRICES[plan] ?? 0;
|
||||
const subtotal = unitPrice * months;
|
||||
const total = subtotal;
|
||||
const planName = tPlans(`names.${plan}`);
|
||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||
const issuedAt = new Date().toLocaleDateString(numberLocale);
|
||||
const invoiceNo = `MZ-${Date.now().toString().slice(-8)}`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<PageHeader
|
||||
title={tc("title")}
|
||||
subtitle={tc("subtitle")}
|
||||
action={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => router.push("/subscription")}
|
||||
>
|
||||
<BackIcon className="h-4 w-4" aria-hidden />
|
||||
{tc("backToPlans")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Factor / invoice */}
|
||||
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
|
||||
{/* Invoice header */}
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-border/80 bg-muted/30 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{tc("invoiceLabel")}
|
||||
</p>
|
||||
<p className="mt-0.5 text-base font-semibold text-foreground">
|
||||
{cafeName || tc("invoiceLabel")}
|
||||
</p>
|
||||
</div>
|
||||
<dl className="text-end text-xs text-muted-foreground">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<dt>{tc("invoiceNo")}:</dt>
|
||||
<dd className="font-medium text-foreground" dir="ltr">
|
||||
{invoiceNo}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-end gap-1.5">
|
||||
<dt>{tc("issuedAt")}:</dt>
|
||||
<dd className="font-medium text-foreground">{issuedAt}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<CardContent className="space-y-6 px-5 py-5">
|
||||
{/* Billing period selector */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">{tc("billingPeriod")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MONTH_OPTIONS.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setMonths(m)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3.5 py-2 text-sm transition active:scale-[0.98]",
|
||||
months === m
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] font-medium text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{tc("monthsCount", { count: formatNumber(m, numberLocale) })}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="overflow-hidden rounded-lg border border-border/60">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/20 text-[11px] uppercase tracking-[0.06em] text-muted-foreground">
|
||||
<th className="px-4 py-2.5 text-start font-medium">{tc("description")}</th>
|
||||
<th className="px-4 py-2.5 text-center font-medium">{tc("qty")}</th>
|
||||
<th className="px-4 py-2.5 text-end font-medium">{tc("unitPrice")}</th>
|
||||
<th className="px-4 py-2.5 text-end font-medium">{tc("amount")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-border/40">
|
||||
<td className="px-4 py-3 text-start">
|
||||
<span className="font-medium text-foreground">
|
||||
{tc("planLine", { plan: planName })}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-muted-foreground">
|
||||
{tc("monthsCount", { count: formatNumber(months, numberLocale) })}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-end text-muted-foreground" dir="ltr">
|
||||
{formatCurrency(unitPrice, numberLocale)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-end font-medium text-foreground" dir="ltr">
|
||||
{formatCurrency(subtotal, numberLocale)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>{tc("subtotal")}</span>
|
||||
<span dir="ltr">{formatCurrency(subtotal, numberLocale)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t border-border/60 pt-2.5 text-base font-semibold text-foreground">
|
||||
<span>{tc("total")}</span>
|
||||
<span className="text-[#0F6E56]" dir="ltr">
|
||||
{formatCurrency(total, numberLocale)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method */}
|
||||
{paymentMethods.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">{t("paymentMethod")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{paymentMethods.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod(m.id)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
|
||||
paymentMethod === m.id
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{m.displayNameFa}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
{/* Pay action */}
|
||||
<div className="flex flex-col gap-3 border-t border-border/80 bg-muted/20 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-[#0F6E56]" aria-hidden />
|
||||
{tc("secureNote")}
|
||||
</p>
|
||||
<Button
|
||||
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
|
||||
disabled={subscribe.isPending}
|
||||
onClick={() =>
|
||||
subscribe.mutate({
|
||||
planTier: plan,
|
||||
months,
|
||||
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
|
||||
})
|
||||
}
|
||||
>
|
||||
{subscribe.isPending
|
||||
? tc("redirecting")
|
||||
: tc("payTotal", { total: formatCurrency(total, numberLocale) })}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/routing";
|
||||
import { apiGet, apiPost } from "@/lib/api/client";
|
||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -32,27 +32,16 @@ type BillingStatus = {
|
||||
isPlanExpired: boolean;
|
||||
};
|
||||
|
||||
type SubscribeResponse = {
|
||||
paymentId: string;
|
||||
paymentUrl: string;
|
||||
};
|
||||
|
||||
type PaymentMethod = {
|
||||
id: string;
|
||||
displayNameFa: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
export function SubscriptionScreen() {
|
||||
const t = useTranslations("subscription");
|
||||
const tSettings = useTranslations("settings");
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const setAuth = useAuthStore((s) => s.setAuth);
|
||||
const billingRefreshed = useRef(false);
|
||||
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const billing = searchParams.get("billing");
|
||||
@@ -72,19 +61,6 @@ export function SubscriptionScreen() {
|
||||
enabled: !!cafeId,
|
||||
});
|
||||
|
||||
const { data: paymentMethods = [] } = useQuery({
|
||||
queryKey: ["billing-payment-methods", cafeId],
|
||||
queryFn: () => apiGet<PaymentMethod[]>("/api/billing/payment-methods"),
|
||||
enabled: !!cafeId && isCafeOwner(role),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentMethod && paymentMethods.length > 0) {
|
||||
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
||||
setPaymentMethod(def.id);
|
||||
}
|
||||
}, [paymentMethods, paymentMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
||||
const refresh = localStorage.getItem("meezi_refresh_token");
|
||||
@@ -98,14 +74,6 @@ export function SubscriptionScreen() {
|
||||
.catch(() => notify.warning(tSettings("profile.reloginHint")));
|
||||
}, [searchParams, setAuth, refetch, tSettings]);
|
||||
|
||||
const subscribe = useMutation({
|
||||
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
|
||||
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
|
||||
onSuccess: (data) => {
|
||||
window.location.href = data.paymentUrl;
|
||||
},
|
||||
});
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
if (!isCafeOwner(role)) {
|
||||
@@ -187,41 +155,11 @@ export function SubscriptionScreen() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{paymentMethods.length > 0 ? (
|
||||
<Card className="rounded-xl border border-border/80 shadow-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{t("paymentMethod")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-2">
|
||||
{paymentMethods.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod(m.id)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-sm transition active:scale-[0.98]",
|
||||
paymentMethod === m.id
|
||||
? "border-[#0F6E56] bg-[#E1F5EE] text-[#0F6E56]"
|
||||
: "border-border/80 hover:border-[#0F6E56]/40"
|
||||
)}
|
||||
>
|
||||
{m.displayNameFa}
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<PlanComparison
|
||||
currentPlan={status?.planTier ?? "Free"}
|
||||
onSubscribe={(planTier) =>
|
||||
subscribe.mutate({
|
||||
planTier,
|
||||
months: 1,
|
||||
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
|
||||
})
|
||||
router.push(`/subscription/checkout?plan=${planTier}`)
|
||||
}
|
||||
isSubscribing={subscribe.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user