2026-05-30 00:29:17 +03:30
|
|
|
"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";
|
2026-06-02 00:04:48 +03:30
|
|
|
import { useApiError } from "@/lib/use-api-error";
|
2026-05-30 00:29:17 +03:30
|
|
|
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");
|
2026-06-02 00:04:48 +03:30
|
|
|
const apiError = useApiError();
|
2026-05-30 00:29:17 +03:30
|
|
|
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("");
|
2026-05-31 22:40:04 +03:30
|
|
|
const [payError, setPayError] = useState<string | null>(null);
|
2026-05-30 00:29:17 +03:30
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-02 16:44:32 +03:30
|
|
|
// If the owner is still covered (active plan and/or queued plans), this purchase will be
|
|
|
|
|
// queued to start when the current coverage ends rather than activating immediately.
|
|
|
|
|
const { data: billingStatus } = useQuery({
|
|
|
|
|
queryKey: ["billing-status", cafeId],
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
apiGet<{
|
|
|
|
|
planTier: string;
|
|
|
|
|
planExpiresAt: string | null;
|
|
|
|
|
isPlanExpired: boolean;
|
|
|
|
|
queuedPlans: { effectiveTo: string }[];
|
|
|
|
|
}>("/api/billing/status"),
|
|
|
|
|
enabled: !!cafeId && isCafeOwner(role),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const coverageEnd = useMemo(() => {
|
|
|
|
|
if (!billingStatus) return null;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
let end = 0;
|
|
|
|
|
if (
|
|
|
|
|
billingStatus.planTier !== "Free" &&
|
|
|
|
|
billingStatus.planExpiresAt &&
|
|
|
|
|
!billingStatus.isPlanExpired
|
|
|
|
|
) {
|
|
|
|
|
end = Math.max(end, new Date(billingStatus.planExpiresAt).getTime());
|
|
|
|
|
}
|
|
|
|
|
for (const q of billingStatus.queuedPlans ?? []) {
|
|
|
|
|
end = Math.max(end, new Date(q.effectiveTo).getTime());
|
|
|
|
|
}
|
|
|
|
|
return end > now ? new Date(end) : null;
|
|
|
|
|
}, [billingStatus]);
|
|
|
|
|
|
2026-05-30 00:29:17 +03:30
|
|
|
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) => {
|
2026-05-31 22:40:04 +03:30
|
|
|
setPayError(null);
|
2026-05-30 00:29:17 +03:30
|
|
|
window.location.href = data.paymentUrl;
|
|
|
|
|
},
|
2026-05-31 22:40:04 +03:30
|
|
|
onError: (err: unknown) => {
|
2026-06-02 00:04:48 +03:30
|
|
|
setPayError(apiError(err, tc("paymentFailed")));
|
2026-05-31 22:40:04 +03:30
|
|
|
},
|
2026-05-30 00:29:17 +03:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-06-02 16:44:32 +03:30
|
|
|
{coverageEnd ? (
|
|
|
|
|
<div className="flex items-start gap-2 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/50 px-4 py-3 text-sm text-[#0F6E56]">
|
|
|
|
|
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
|
|
|
|
<p>{tc("queuedNotice", { date: coverageEnd.toLocaleDateString(numberLocale) })}</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-05-30 00:29:17 +03:30
|
|
|
{/* 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">
|
2026-05-31 22:40:04 +03:30
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
<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>
|
|
|
|
|
{payError && (
|
|
|
|
|
<p className="text-xs text-destructive">{payError}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-30 00:29:17 +03:30
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|