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";
|
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,
|
Free: 0,
|
||||||
Pro: 1_490_000,
|
Pro: 1_490_000,
|
||||||
Business: 3_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 { useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { apiGet, apiPost } from "@/lib/api/client";
|
import { apiGet, apiPost } from "@/lib/api/client";
|
||||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -32,27 +32,16 @@ type BillingStatus = {
|
|||||||
isPlanExpired: boolean;
|
isPlanExpired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubscribeResponse = {
|
|
||||||
paymentId: string;
|
|
||||||
paymentUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PaymentMethod = {
|
|
||||||
id: string;
|
|
||||||
displayNameFa: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function SubscriptionScreen() {
|
export function SubscriptionScreen() {
|
||||||
const t = useTranslations("subscription");
|
const t = useTranslations("subscription");
|
||||||
const tSettings = useTranslations("settings");
|
const tSettings = useTranslations("settings");
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const role = useAuthStore((s) => s.user?.role);
|
const role = useAuthStore((s) => s.user?.role);
|
||||||
const setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
const billingRefreshed = useRef(false);
|
const billingRefreshed = useRef(false);
|
||||||
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
||||||
const [paymentMethod, setPaymentMethod] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const billing = searchParams.get("billing");
|
const billing = searchParams.get("billing");
|
||||||
@@ -72,19 +61,6 @@ export function SubscriptionScreen() {
|
|||||||
enabled: !!cafeId,
|
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(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
||||||
const refresh = localStorage.getItem("meezi_refresh_token");
|
const refresh = localStorage.getItem("meezi_refresh_token");
|
||||||
@@ -98,14 +74,6 @@ export function SubscriptionScreen() {
|
|||||||
.catch(() => notify.warning(tSettings("profile.reloginHint")));
|
.catch(() => notify.warning(tSettings("profile.reloginHint")));
|
||||||
}, [searchParams, setAuth, refetch, tSettings]);
|
}, [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 (!cafeId) return null;
|
||||||
|
|
||||||
if (!isCafeOwner(role)) {
|
if (!isCafeOwner(role)) {
|
||||||
@@ -187,41 +155,11 @@ export function SubscriptionScreen() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
<PlanComparison
|
||||||
currentPlan={status?.planTier ?? "Free"}
|
currentPlan={status?.planTier ?? "Free"}
|
||||||
onSubscribe={(planTier) =>
|
onSubscribe={(planTier) =>
|
||||||
subscribe.mutate({
|
router.push(`/subscription/checkout?plan=${planTier}`)
|
||||||
planTier,
|
|
||||||
months: 1,
|
|
||||||
paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
isSubscribing={subscribe.isPending}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user