fix(subscription): plan comparison + checkout read the live plan catalog
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m27s
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m10s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 47s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m27s
The merchant plan page hard-coded 4 tiers, prices and a feature matrix that drifted from the admin-editable platform catalog (Starter tier missing, stale prices/features). PlanComparison and CheckoutScreen now consume /platform/plans + new /platform/features-catalog: - columns = active plans by SortOrder (incl. Starter), names from DisplayNameFa/En, prices from MonthlyPriceToman - limit rows from PlanLimitsData (int.MaxValue → "نامحدود") - feature rows from the feature catalog, ticked via FeatureKeys - checkout validates the ?plan= param against isBillableOnline and prices from the catalog — no more client-side price constants fa/en/ar limit-row labels added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1320,6 +1320,17 @@
|
||||
"Business": "أعمال",
|
||||
"Enterprise": "مؤسسات"
|
||||
},
|
||||
"limits": {
|
||||
"maxOrdersPerDay": "طلبات في اليوم",
|
||||
"maxBranches": "الفروع",
|
||||
"maxTerminals": "أجهزة الكاشير",
|
||||
"maxTables": "الطاولات",
|
||||
"maxCustomers": "عملاء CRM",
|
||||
"maxSmsPerMonth": "رسائل SMS شهرياً",
|
||||
"maxMenuItems": "أصناف القائمة",
|
||||
"maxReportHistoryDays": "سجل التقارير (أيام)",
|
||||
"maxMenuAi3dPerMonth": "صور AI ثلاثية الأبعاد شهرياً"
|
||||
},
|
||||
"features": {
|
||||
"ordersPerDay": "طلبات يومياً",
|
||||
"terminals": "أجهزة نقطة البيع",
|
||||
|
||||
@@ -1402,6 +1402,17 @@
|
||||
"Business": "Business",
|
||||
"Enterprise": "Enterprise"
|
||||
},
|
||||
"limits": {
|
||||
"maxOrdersPerDay": "Orders per day",
|
||||
"maxBranches": "Branches",
|
||||
"maxTerminals": "POS terminals",
|
||||
"maxTables": "Tables",
|
||||
"maxCustomers": "CRM customers",
|
||||
"maxSmsPerMonth": "SMS per month",
|
||||
"maxMenuItems": "Menu items",
|
||||
"maxReportHistoryDays": "Report history (days)",
|
||||
"maxMenuAi3dPerMonth": "AI 3D images per month"
|
||||
},
|
||||
"features": {
|
||||
"ordersPerDay": "Orders per day",
|
||||
"terminals": "POS terminals",
|
||||
|
||||
@@ -1403,6 +1403,17 @@
|
||||
"Business": "بیزنس",
|
||||
"Enterprise": "سازمانی"
|
||||
},
|
||||
"limits": {
|
||||
"maxOrdersPerDay": "سفارش در روز",
|
||||
"maxBranches": "شعبه",
|
||||
"maxTerminals": "ترمینال صندوق",
|
||||
"maxTables": "میز",
|
||||
"maxCustomers": "مشتری CRM",
|
||||
"maxSmsPerMonth": "پیامک در ماه",
|
||||
"maxMenuItems": "آیتم منو",
|
||||
"maxReportHistoryDays": "تاریخچه گزارش (روز)",
|
||||
"maxMenuAi3dPerMonth": "تصویر AI سهبعدی در ماه"
|
||||
},
|
||||
"features": {
|
||||
"ordersPerDay": "سفارش در روز",
|
||||
"terminals": "ترمینال صندوق",
|
||||
|
||||
@@ -1,213 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, Minus, X } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Check, Loader2, Minus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatCurrency, formatNumber } from "@/lib/format";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import {
|
||||
UNLIMITED,
|
||||
featureDisplayName,
|
||||
planDisplayName,
|
||||
usePlatformFeaturesCatalog,
|
||||
usePlatformPlans,
|
||||
type PlanLimits,
|
||||
type PlatformPlan,
|
||||
} from "@/lib/api/platform-plans";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise";
|
||||
|
||||
export const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"];
|
||||
|
||||
export const PRICES: Record<PlanId, number | null> = {
|
||||
Free: 0,
|
||||
Pro: 1_490_000,
|
||||
Business: 3_490_000,
|
||||
Enterprise: null,
|
||||
};
|
||||
|
||||
type CellValue =
|
||||
| { kind: "bool"; value: boolean }
|
||||
| { kind: "limit"; value: number | null }
|
||||
| { kind: "text"; value: string };
|
||||
|
||||
type FeatureRow = {
|
||||
key: string;
|
||||
cells: Record<PlanId, CellValue>;
|
||||
};
|
||||
|
||||
const FEATURE_MATRIX: FeatureRow[] = [
|
||||
{
|
||||
key: "ordersPerDay",
|
||||
cells: {
|
||||
Free: { kind: "limit", value: 50 },
|
||||
Pro: { kind: "limit", value: null },
|
||||
Business: { kind: "limit", value: null },
|
||||
Enterprise: { kind: "limit", value: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "terminals",
|
||||
cells: {
|
||||
Free: { kind: "limit", value: 1 },
|
||||
Pro: { kind: "limit", value: 3 },
|
||||
Business: { kind: "limit", value: null },
|
||||
Enterprise: { kind: "limit", value: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "crmCustomers",
|
||||
cells: {
|
||||
Free: { kind: "limit", value: 50 },
|
||||
Pro: { kind: "limit", value: null },
|
||||
Business: { kind: "limit", value: null },
|
||||
Enterprise: { kind: "limit", value: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "smsPerMonth",
|
||||
cells: {
|
||||
Free: { kind: "limit", value: 0 },
|
||||
Pro: { kind: "limit", value: 50 },
|
||||
Business: { kind: "limit", value: 200 },
|
||||
Enterprise: { kind: "limit", value: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "branches",
|
||||
cells: {
|
||||
Free: { kind: "limit", value: 1 },
|
||||
Pro: { kind: "limit", value: 3 },
|
||||
Business: { kind: "limit", value: null },
|
||||
Enterprise: { kind: "limit", value: null },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "posKds",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: true },
|
||||
Pro: { kind: "bool", value: true },
|
||||
Business: { kind: "bool", value: true },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "tablesQr",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: true },
|
||||
Pro: { kind: "bool", value: true },
|
||||
Business: { kind: "bool", value: true },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "menuReservations",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: true },
|
||||
Pro: { kind: "bool", value: true },
|
||||
Business: { kind: "bool", value: true },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "reports",
|
||||
cells: {
|
||||
Free: { kind: "text", value: "basic" },
|
||||
Pro: { kind: "text", value: "full" },
|
||||
Business: { kind: "text", value: "full" },
|
||||
Enterprise: { kind: "text", value: "full" },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "hrModule",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: false },
|
||||
Pro: { kind: "bool", value: false },
|
||||
Business: { kind: "bool", value: true },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "snappfoodDelivery",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: false },
|
||||
Pro: { kind: "bool", value: false },
|
||||
Business: { kind: "bool", value: true },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "tarazTax",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: false },
|
||||
Pro: { kind: "bool", value: true },
|
||||
Business: { kind: "bool", value: true },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "badges",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: false },
|
||||
Pro: { kind: "bool", value: false },
|
||||
Business: { kind: "bool", value: false },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "whiteLabel",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: false },
|
||||
Pro: { kind: "bool", value: false },
|
||||
Business: { kind: "bool", value: false },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "apiAccess",
|
||||
cells: {
|
||||
Free: { kind: "bool", value: false },
|
||||
Pro: { kind: "bool", value: false },
|
||||
Business: { kind: "bool", value: false },
|
||||
Enterprise: { kind: "bool", value: true },
|
||||
},
|
||||
},
|
||||
/** Limit rows shown at the top of the comparison, in display order. */
|
||||
const LIMIT_ROWS: { key: keyof PlanLimits; zeroAsDash?: boolean }[] = [
|
||||
{ key: "maxOrdersPerDay" },
|
||||
{ key: "maxBranches" },
|
||||
{ key: "maxTerminals" },
|
||||
{ key: "maxTables" },
|
||||
{ key: "maxCustomers" },
|
||||
{ key: "maxSmsPerMonth", zeroAsDash: true },
|
||||
{ key: "maxMenuItems" },
|
||||
{ key: "maxReportHistoryDays" },
|
||||
{ key: "maxMenuAi3dPerMonth", zeroAsDash: true },
|
||||
];
|
||||
|
||||
function CellDisplay({
|
||||
cell,
|
||||
function LimitCell({
|
||||
value,
|
||||
zeroAsDash,
|
||||
unlimitedLabel,
|
||||
numberLocale,
|
||||
}: {
|
||||
value: number;
|
||||
zeroAsDash?: boolean;
|
||||
unlimitedLabel: string;
|
||||
numberLocale: string;
|
||||
}) {
|
||||
if (value >= UNLIMITED) {
|
||||
return <span className="text-sm font-medium text-[#0F6E56]">{unlimitedLabel}</span>;
|
||||
}
|
||||
if (value === 0 && zeroAsDash) {
|
||||
return <Minus className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm font-medium">{formatNumber(value, numberLocale)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BoolCell({ value }: { value: boolean }) {
|
||||
return value ? (
|
||||
<Check className="mx-auto h-5 w-5 text-[#0F6E56]" aria-hidden />
|
||||
) : (
|
||||
<X className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />
|
||||
);
|
||||
}
|
||||
|
||||
function PriceLine({
|
||||
plan,
|
||||
t,
|
||||
numberLocale,
|
||||
}: {
|
||||
cell: CellValue;
|
||||
plan: PlatformPlan;
|
||||
t: ReturnType<typeof useTranslations<"settings.plans">>;
|
||||
numberLocale: string;
|
||||
}) {
|
||||
if (cell.kind === "bool") {
|
||||
return cell.value ? (
|
||||
<Check className="mx-auto h-5 w-5 text-[#0F6E56]" aria-hidden />
|
||||
) : (
|
||||
<X className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />
|
||||
);
|
||||
if (plan.monthlyPriceToman === 0 && !plan.isBillableOnline && plan.tier !== "Free") {
|
||||
return <>{t("customPrice")}</>;
|
||||
}
|
||||
if (cell.kind === "limit") {
|
||||
if (cell.value === null) {
|
||||
return (
|
||||
<span className="text-sm font-medium text-[#0F6E56]">{t("unlimited")}</span>
|
||||
);
|
||||
}
|
||||
if (cell.value === 0) {
|
||||
return <Minus className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm font-medium">{formatNumber(cell.value, numberLocale)}</span>
|
||||
);
|
||||
if (plan.monthlyPriceToman === 0) {
|
||||
return <>{t("freePrice")}</>;
|
||||
}
|
||||
return (
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t(`levels.${cell.value}`)}
|
||||
</span>
|
||||
);
|
||||
return <>{formatCurrency(plan.monthlyPriceToman, numberLocale)}</>;
|
||||
}
|
||||
|
||||
type PlanComparisonProps = {
|
||||
currentPlan?: string;
|
||||
onSubscribe: (planTier: "Pro" | "Business") => void;
|
||||
onSubscribe: (planTier: string) => void;
|
||||
isSubscribing?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Live plan comparison — plans, prices, limits and features all come from the
|
||||
* admin-editable platform catalog (`/platform/plans` + `/platform/features-catalog`),
|
||||
* so this table never drifts from what billing actually enforces.
|
||||
*/
|
||||
export function PlanComparison({
|
||||
currentPlan = "Free",
|
||||
onSubscribe,
|
||||
@@ -215,12 +97,35 @@ export function PlanComparison({
|
||||
}: PlanComparisonProps) {
|
||||
const t = useTranslations("settings.plans");
|
||||
const tSettings = useTranslations("settings");
|
||||
const numberLocale =
|
||||
typeof document !== "undefined" && document.documentElement.lang === "en"
|
||||
? "en-US"
|
||||
: "fa-IR";
|
||||
const locale = useLocale();
|
||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
|
||||
const normalizedCurrent = currentPlan as PlanId;
|
||||
const { data: plans = [], isLoading: plansLoading } = usePlatformPlans(cafeId);
|
||||
const { data: catalog = [] } = usePlatformFeaturesCatalog(cafeId);
|
||||
|
||||
// Only catalog features that at least one plan includes — hides retired keys.
|
||||
const featureRows = useMemo(() => {
|
||||
const used = new Set(plans.flatMap((p) => p.featureKeys));
|
||||
return catalog.filter((f) => f.isEnabledGlobally && used.has(f.key));
|
||||
}, [plans, catalog]);
|
||||
|
||||
const popularTier = useMemo(() => {
|
||||
// "Popular" = the cheapest online-billable paid plan above Free.
|
||||
const billable = plans.filter((p) => p.isBillableOnline && p.monthlyPriceToman > 0);
|
||||
return billable.length > 0 ? billable[0]!.tier : null;
|
||||
}, [plans]);
|
||||
|
||||
if (plansLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-xl border border-border/80 bg-card py-12 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (plans.length === 0) return null;
|
||||
|
||||
const gridCols = `28%_repeat(${plans.length},minmax(0,1fr))`;
|
||||
|
||||
return (
|
||||
<section className="relative z-0 mb-8 space-y-4 scroll-mt-6">
|
||||
@@ -232,7 +137,7 @@ export function PlanComparison({
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("compareHint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Desktop comparison table — badges in-flow; CTAs outside scroll clip */}
|
||||
{/* Desktop comparison table */}
|
||||
<div className="relative z-0 mb-2 hidden overflow-hidden rounded-xl border border-border/80 bg-card shadow-sm lg:block">
|
||||
<div className="overflow-x-auto overscroll-x-contain">
|
||||
<div className="min-w-[720px] px-2 pb-2 pt-4">
|
||||
@@ -242,12 +147,12 @@ export function PlanComparison({
|
||||
<th className="w-[28%] bg-muted/30 px-4 pb-4 pt-2 text-start text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("featureColumn")}
|
||||
</th>
|
||||
{PLAN_ORDER.map((plan) => {
|
||||
const isCurrent = plan === normalizedCurrent;
|
||||
const isPopular = plan === "Pro";
|
||||
{plans.map((plan) => {
|
||||
const isCurrent = plan.tier === currentPlan;
|
||||
const isPopular = plan.tier === popularTier;
|
||||
return (
|
||||
<th
|
||||
key={plan}
|
||||
key={plan.tier}
|
||||
className={cn(
|
||||
"px-3 pb-4 pt-2 align-top",
|
||||
isPopular && "bg-[#E1F5EE]/60",
|
||||
@@ -271,14 +176,10 @@ export function PlanComparison({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-foreground">
|
||||
{t(`names.${plan}`)}
|
||||
{planDisplayName(plan, locale)}
|
||||
</div>
|
||||
<p className="font-medium text-[#0F6E56]">
|
||||
{PRICES[plan] === null
|
||||
? t("customPrice")
|
||||
: PRICES[plan] === 0
|
||||
? t("freePrice")
|
||||
: formatCurrency(PRICES[plan]!, numberLocale)}
|
||||
<PriceLine plan={plan} t={t} numberLocale={numberLocale} />
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">{t("perMonth")}</p>
|
||||
</div>
|
||||
@@ -288,7 +189,7 @@ export function PlanComparison({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{FEATURE_MATRIX.map((row, idx) => (
|
||||
{LIMIT_ROWS.map((row, idx) => (
|
||||
<tr
|
||||
key={row.key}
|
||||
className={cn(
|
||||
@@ -297,45 +198,75 @@ export function PlanComparison({
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 text-start text-sm text-foreground">
|
||||
{t(`features.${row.key}`)}
|
||||
{t(`limits.${row.key}`)}
|
||||
</td>
|
||||
{PLAN_ORDER.map((plan) => (
|
||||
{plans.map((plan) => (
|
||||
<td
|
||||
key={plan}
|
||||
key={plan.tier}
|
||||
className={cn(
|
||||
"px-3 py-3",
|
||||
plan === "Pro" && "bg-[#E1F5EE]/30",
|
||||
plan === normalizedCurrent && "bg-[#E1F5EE]/50"
|
||||
plan.tier === popularTier && "bg-[#E1F5EE]/30",
|
||||
plan.tier === currentPlan && "bg-[#E1F5EE]/50"
|
||||
)}
|
||||
>
|
||||
<CellDisplay
|
||||
cell={row.cells[plan]}
|
||||
t={t}
|
||||
<LimitCell
|
||||
value={plan.limits[row.key]}
|
||||
zeroAsDash={row.zeroAsDash}
|
||||
unlimitedLabel={t("unlimited")}
|
||||
numberLocale={numberLocale}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{featureRows.map((feature, idx) => (
|
||||
<tr
|
||||
key={feature.key}
|
||||
className={cn(
|
||||
"border-b border-border/60",
|
||||
(LIMIT_ROWS.length + idx) % 2 === 0 ? "bg-background" : "bg-muted/20"
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 text-start text-sm text-foreground">
|
||||
{featureDisplayName(feature, locale)}
|
||||
</td>
|
||||
{plans.map((plan) => (
|
||||
<td
|
||||
key={plan.tier}
|
||||
className={cn(
|
||||
"px-3 py-3",
|
||||
plan.tier === popularTier && "bg-[#E1F5EE]/30",
|
||||
plan.tier === currentPlan && "bg-[#E1F5EE]/50"
|
||||
)}
|
||||
>
|
||||
<BoolCell value={plan.featureKeys.includes(feature.key)} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto overscroll-x-contain border-t border-border/80 bg-muted/10">
|
||||
<div className="grid min-w-[720px] grid-cols-[28%_repeat(4,minmax(0,1fr))] items-center gap-0 px-2 py-5">
|
||||
<div
|
||||
className="grid min-w-[720px] items-center gap-0 px-2 py-5"
|
||||
style={{ gridTemplateColumns: gridCols.replace(/_/g, " ") }}
|
||||
>
|
||||
<div className="px-4" aria-hidden />
|
||||
{PLAN_ORDER.map((plan) => (
|
||||
{plans.map((plan) => (
|
||||
<div
|
||||
key={plan}
|
||||
key={plan.tier}
|
||||
className={cn(
|
||||
"px-3",
|
||||
plan === "Pro" && "bg-[#E1F5EE]/30",
|
||||
plan === normalizedCurrent && "bg-[#E1F5EE]/50"
|
||||
plan.tier === popularTier && "bg-[#E1F5EE]/30",
|
||||
plan.tier === currentPlan && "bg-[#E1F5EE]/50"
|
||||
)}
|
||||
>
|
||||
<PlanCta
|
||||
plan={plan}
|
||||
currentPlan={normalizedCurrent}
|
||||
locale={locale}
|
||||
currentPlan={currentPlan}
|
||||
onSubscribe={onSubscribe}
|
||||
isSubscribing={isSubscribing}
|
||||
t={t}
|
||||
@@ -349,12 +280,12 @@ export function PlanComparison({
|
||||
|
||||
{/* Mobile plan cards */}
|
||||
<div className="grid gap-4 lg:hidden">
|
||||
{PLAN_ORDER.map((plan) => {
|
||||
const isCurrent = plan === normalizedCurrent;
|
||||
const isPopular = plan === "Pro";
|
||||
{plans.map((plan) => {
|
||||
const isCurrent = plan.tier === currentPlan;
|
||||
const isPopular = plan.tier === popularTier;
|
||||
return (
|
||||
<article
|
||||
key={plan}
|
||||
key={plan.tier}
|
||||
className={cn(
|
||||
"relative rounded-xl border bg-card p-4 shadow-sm",
|
||||
isPopular ? "border-[#0F6E56] ring-1 ring-[#0F6E56]/30" : "border-border/80",
|
||||
@@ -362,7 +293,7 @@ export function PlanComparison({
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<h4 className="text-base font-semibold">{t(`names.${plan}`)}</h4>
|
||||
<h4 className="text-base font-semibold">{planDisplayName(plan, locale)}</h4>
|
||||
{isPopular && (
|
||||
<Badge className="bg-[#0F6E56] text-white hover:bg-[#0F6E56]">
|
||||
{t("popular")}
|
||||
@@ -375,35 +306,44 @@ export function PlanComparison({
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-4 text-lg font-medium text-[#0F6E56]">
|
||||
{PRICES[plan] === null
|
||||
? t("customPrice")
|
||||
: PRICES[plan] === 0
|
||||
? t("freePrice")
|
||||
: formatCurrency(PRICES[plan]!, numberLocale)}
|
||||
{PRICES[plan] !== null && PRICES[plan]! > 0 && (
|
||||
<PriceLine plan={plan} t={t} numberLocale={numberLocale} />
|
||||
{plan.monthlyPriceToman > 0 && (
|
||||
<span className="ms-1 text-xs font-normal text-muted-foreground">
|
||||
{t("perMonth")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<ul className="mb-4 space-y-2 border-t border-border/60 pt-3">
|
||||
{FEATURE_MATRIX.map((row) => (
|
||||
{LIMIT_ROWS.map((row) => (
|
||||
<li
|
||||
key={row.key}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">{t(`features.${row.key}`)}</span>
|
||||
<CellDisplay
|
||||
cell={row.cells[plan]}
|
||||
t={t}
|
||||
<span className="text-muted-foreground">{t(`limits.${row.key}`)}</span>
|
||||
<LimitCell
|
||||
value={plan.limits[row.key]}
|
||||
zeroAsDash={row.zeroAsDash}
|
||||
unlimitedLabel={t("unlimited")}
|
||||
numberLocale={numberLocale}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{featureRows.map((feature) => (
|
||||
<li
|
||||
key={feature.key}
|
||||
className="flex items-center justify-between gap-2 text-sm"
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{featureDisplayName(feature, locale)}
|
||||
</span>
|
||||
<BoolCell value={plan.featureKeys.includes(feature.key)} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<PlanCta
|
||||
plan={plan}
|
||||
currentPlan={normalizedCurrent}
|
||||
locale={locale}
|
||||
currentPlan={currentPlan}
|
||||
onSubscribe={onSubscribe}
|
||||
isSubscribing={isSubscribing}
|
||||
t={t}
|
||||
@@ -419,22 +359,24 @@ export function PlanComparison({
|
||||
|
||||
function PlanCta({
|
||||
plan,
|
||||
locale,
|
||||
currentPlan,
|
||||
onSubscribe,
|
||||
isSubscribing,
|
||||
t,
|
||||
fullWidth,
|
||||
}: {
|
||||
plan: PlanId;
|
||||
currentPlan: PlanId;
|
||||
onSubscribe: (planTier: "Pro" | "Business") => void;
|
||||
plan: PlatformPlan;
|
||||
locale: string;
|
||||
currentPlan: string;
|
||||
onSubscribe: (planTier: string) => void;
|
||||
isSubscribing: boolean;
|
||||
t: ReturnType<typeof useTranslations<"settings.plans">>;
|
||||
fullWidth?: boolean;
|
||||
}) {
|
||||
const isCurrent = plan === currentPlan;
|
||||
const isCurrent = plan.tier === currentPlan;
|
||||
|
||||
if (plan === "Free") {
|
||||
if (plan.monthlyPriceToman === 0 && plan.tier === "Free") {
|
||||
return (
|
||||
<Button variant="outline" disabled className={fullWidth ? "w-full" : ""} size="sm">
|
||||
{isCurrent ? t("currentPlanBtn") : t("included")}
|
||||
@@ -442,7 +384,7 @@ function PlanCta({
|
||||
);
|
||||
}
|
||||
|
||||
if (plan === "Enterprise") {
|
||||
if (!plan.isBillableOnline) {
|
||||
return (
|
||||
<Button variant="outline" className={fullWidth ? "w-full" : ""} size="sm" asChild>
|
||||
<a href="mailto:sales@meezi.ir">{t("contactSales")}</a>
|
||||
@@ -460,15 +402,12 @@ function PlanCta({
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"bg-[#0F6E56] hover:bg-[#0c5a46]",
|
||||
fullWidth ? "w-full" : ""
|
||||
)}
|
||||
className={cn("bg-[#0F6E56] hover:bg-[#0c5a46]", fullWidth ? "w-full" : "")}
|
||||
size="sm"
|
||||
disabled={isSubscribing}
|
||||
onClick={() => onSubscribe(plan)}
|
||||
onClick={() => onSubscribe(plan.tier)}
|
||||
>
|
||||
{t("subscribe", { plan: t(`names.${plan}`) })}
|
||||
{t("subscribe", { plan: planDisplayName(plan, locale) })}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ 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";
|
||||
import { planDisplayName, usePlatformPlans } from "@/lib/api/platform-plans";
|
||||
|
||||
type SubscribeResponse = {
|
||||
paymentId: string;
|
||||
@@ -28,23 +28,31 @@ type PaymentMethod = {
|
||||
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 apiError = useApiError();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const cafeId = user?.cafeId;
|
||||
const role = user?.role;
|
||||
const locale =
|
||||
typeof document !== "undefined" && document.documentElement.lang === "en"
|
||||
? "en"
|
||||
: "fa";
|
||||
|
||||
const planParam = searchParams.get("plan") as PlanId | null;
|
||||
const isBillable = !!planParam && BILLABLE_PLANS.includes(planParam);
|
||||
const plan = (isBillable ? planParam : "Pro") as PlanId;
|
||||
const planParam = searchParams.get("plan");
|
||||
// Validate against the live, admin-editable plan catalog — only plans that
|
||||
// are active AND billable online can be checked out.
|
||||
const { data: plans = [], isLoading: plansLoading } = usePlatformPlans(cafeId);
|
||||
const selectedPlan =
|
||||
plans.find((p) => p.tier === planParam && p.isBillableOnline && p.monthlyPriceToman > 0) ??
|
||||
null;
|
||||
const isBillable = !!selectedPlan;
|
||||
const plan = selectedPlan?.tier ?? "Pro";
|
||||
|
||||
const [months, setMonths] = useState(1);
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
@@ -129,7 +137,16 @@ export function CheckoutScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isBillable) {
|
||||
if (plansLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isBillable || !selectedPlan) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={tc("title")} subtitle={tc("subtitle")} />
|
||||
@@ -145,10 +162,10 @@ export function CheckoutScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const unitPrice = PRICES[plan] ?? 0;
|
||||
const unitPrice = selectedPlan.monthlyPriceToman;
|
||||
const subtotal = unitPrice * months;
|
||||
const total = subtotal;
|
||||
const planName = tPlans(`names.${plan}`);
|
||||
const planName = planDisplayName(selectedPlan, locale);
|
||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||
const issuedAt = new Date().toLocaleDateString(numberLocale);
|
||||
const invoiceNo = `MZ-${Date.now().toString().slice(-8)}`;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiGet } from "@/lib/api/client";
|
||||
|
||||
/** Backend sends int.MaxValue for "no limit". */
|
||||
export const UNLIMITED = 2147483647;
|
||||
|
||||
export interface PlanLimits {
|
||||
maxOrdersPerDay: number;
|
||||
maxTables: number;
|
||||
maxTerminals: number;
|
||||
maxCustomers: number;
|
||||
maxSmsPerMonth: number;
|
||||
maxBranches: number;
|
||||
maxReportHistoryDays: number;
|
||||
maxMenuCategories: number;
|
||||
maxMenuItems: number;
|
||||
maxMenuAi3dPerMonth: number;
|
||||
}
|
||||
|
||||
export interface PlatformPlan {
|
||||
tier: string;
|
||||
displayNameFa: string;
|
||||
displayNameEn?: string | null;
|
||||
monthlyPriceToman: number;
|
||||
isBillableOnline: boolean;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
limits: PlanLimits;
|
||||
featureKeys: string[];
|
||||
}
|
||||
|
||||
export interface PlatformFeature {
|
||||
id: string;
|
||||
key: string;
|
||||
displayNameFa: string;
|
||||
displayNameEn?: string | null;
|
||||
moduleGroup: string;
|
||||
isEnabledGlobally: boolean;
|
||||
}
|
||||
|
||||
/** Live, admin-editable plan matrix — the single source of truth for plan
|
||||
* names, prices, limits, and included features. */
|
||||
export function usePlatformPlans(cafeId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["platform-plans", cafeId],
|
||||
queryFn: async () => {
|
||||
const plans = await apiGet<PlatformPlan[]>(`/api/cafes/${cafeId}/platform/plans`);
|
||||
return plans
|
||||
.filter((p) => p.isActive)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
},
|
||||
enabled: !!cafeId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlatformFeaturesCatalog(cafeId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["platform-features-catalog", cafeId],
|
||||
queryFn: () =>
|
||||
apiGet<PlatformFeature[]>(`/api/cafes/${cafeId}/platform/features-catalog`),
|
||||
enabled: !!cafeId,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function planDisplayName(plan: PlatformPlan, locale: string): string {
|
||||
if (locale === "en" && plan.displayNameEn) return plan.displayNameEn;
|
||||
return plan.displayNameFa;
|
||||
}
|
||||
|
||||
export function featureDisplayName(feature: PlatformFeature, locale: string): string {
|
||||
if (locale === "en" && feature.displayNameEn) return feature.displayNameEn;
|
||||
return feature.displayNameFa;
|
||||
}
|
||||
Reference in New Issue
Block a user