From 09c55669cac34e1f536c7f24d039edcf781e29af Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 30 May 2026 00:29:17 +0330 Subject: [PATCH] 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 --- .../subscription/checkout/page.tsx | 10 + .../components/settings/plan-comparison.tsx | 4 +- .../subscription/checkout-screen.tsx | 281 ++++++++++++++++++ .../subscription/subscription-screen.tsx | 70 +---- 4 files changed, 297 insertions(+), 68 deletions(-) create mode 100644 web/dashboard/src/app/[locale]/(dashboard)/subscription/checkout/page.tsx create mode 100644 web/dashboard/src/components/subscription/checkout-screen.tsx diff --git a/web/dashboard/src/app/[locale]/(dashboard)/subscription/checkout/page.tsx b/web/dashboard/src/app/[locale]/(dashboard)/subscription/checkout/page.tsx new file mode 100644 index 0000000..44f403b --- /dev/null +++ b/web/dashboard/src/app/[locale]/(dashboard)/subscription/checkout/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import { CheckoutScreen } from "@/components/subscription/checkout-screen"; + +export default function SubscriptionCheckoutPage() { + return ( + + + + ); +} diff --git a/web/dashboard/src/components/settings/plan-comparison.tsx b/web/dashboard/src/components/settings/plan-comparison.tsx index 718bb51..79d62e7 100644 --- a/web/dashboard/src/components/settings/plan-comparison.tsx +++ b/web/dashboard/src/components/settings/plan-comparison.tsx @@ -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 = { +export const PRICES: Record = { Free: 0, Pro: 1_490_000, Business: 3_490_000, diff --git a/web/dashboard/src/components/subscription/checkout-screen.tsx b/web/dashboard/src/components/subscription/checkout-screen.tsx new file mode 100644 index 0000000..c54cb00 --- /dev/null +++ b/web/dashboard/src/components/subscription/checkout-screen.tsx @@ -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("/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("/api/billing/subscribe", body), + onSuccess: (data) => { + window.location.href = data.paymentUrl; + }, + }); + + if (!cafeId) return null; + + if (!isCafeOwner(role)) { + return ( +
+ +

{t("ownerOnly")}

+
+ ); + } + + if (!isBillable) { + return ( +
+ + + +

{tc("invalidPlan")}

+ +
+
+
+ ); + } + + 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 ( +
+ router.push("/subscription")} + > + + {tc("backToPlans")} + + } + /> + + {/* Factor / invoice */} + + {/* Invoice header */} +
+
+

+ {tc("invoiceLabel")} +

+

+ {cafeName || tc("invoiceLabel")} +

+
+
+
+
{tc("invoiceNo")}:
+
+ {invoiceNo} +
+
+
+
{tc("issuedAt")}:
+
{issuedAt}
+
+
+
+ + + {/* Billing period selector */} +
+

{tc("billingPeriod")}

+
+ {MONTH_OPTIONS.map((m) => ( + + ))} +
+
+ + {/* Line items */} +
+ + + + + + + + + + + + + + + + + +
{tc("description")}{tc("qty")}{tc("unitPrice")}{tc("amount")}
+ + {tc("planLine", { plan: planName })} + + + {tc("monthsCount", { count: formatNumber(months, numberLocale) })} + + {formatCurrency(unitPrice, numberLocale)} + + {formatCurrency(subtotal, numberLocale)} +
+
+ + {/* Totals */} +
+
+ {tc("subtotal")} + {formatCurrency(subtotal, numberLocale)} +
+
+ {tc("total")} + + {formatCurrency(total, numberLocale)} + +
+
+ + {/* Payment method */} + {paymentMethods.length > 0 ? ( +
+

{t("paymentMethod")}

+
+ {paymentMethods.map((m) => ( + + ))} +
+
+ ) : null} +
+ + {/* Pay action */} +
+

+ + {tc("secureNote")} +

+ +
+
+
+ ); +} diff --git a/web/dashboard/src/components/subscription/subscription-screen.tsx b/web/dashboard/src/components/subscription/subscription-screen.tsx index 2a847ee..310549b 100644 --- a/web/dashboard/src/components/subscription/subscription-screen.tsx +++ b/web/dashboard/src/components/subscription/subscription-screen.tsx @@ -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("/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("/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() { - {paymentMethods.length > 0 ? ( - - - {t("paymentMethod")} - - - {paymentMethods.map((m) => ( - - ))} - - - ) : null} - - subscribe.mutate({ - planTier, - months: 1, - paymentMethod: paymentMethod || paymentMethods[0]?.id || "zarinpal", - }) + router.push(`/subscription/checkout?plan=${planTier}`) } - isSubscribing={subscribe.isPending} /> );