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

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:
soroush.asadi
2026-06-12 08:16:29 +03:30
parent 74f46a4781
commit 615d5348de
7 changed files with 328 additions and 255 deletions
@@ -36,4 +36,13 @@ public class CafePlatformController : CafeApiControllerBase
var plans = await _catalog.GetPlansAsync(cancellationToken); var plans = await _catalog.GetPlansAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, plans)); return Ok(new ApiResponse<object>(true, plans));
} }
/// <summary>Feature catalog (key → display name / module group) so clients can
/// label the FeatureKeys returned by the plans endpoint.</summary>
[HttpGet("features-catalog")]
public async Task<IActionResult> GetFeaturesCatalog(CancellationToken cancellationToken)
{
var features = await _catalog.GetFeaturesAsync(cancellationToken);
return Ok(new ApiResponse<object>(true, features));
}
} }
+11
View File
@@ -1320,6 +1320,17 @@
"Business": "أعمال", "Business": "أعمال",
"Enterprise": "مؤسسات" "Enterprise": "مؤسسات"
}, },
"limits": {
"maxOrdersPerDay": "طلبات في اليوم",
"maxBranches": "الفروع",
"maxTerminals": "أجهزة الكاشير",
"maxTables": "الطاولات",
"maxCustomers": "عملاء CRM",
"maxSmsPerMonth": "رسائل SMS شهرياً",
"maxMenuItems": "أصناف القائمة",
"maxReportHistoryDays": "سجل التقارير (أيام)",
"maxMenuAi3dPerMonth": "صور AI ثلاثية الأبعاد شهرياً"
},
"features": { "features": {
"ordersPerDay": "طلبات يومياً", "ordersPerDay": "طلبات يومياً",
"terminals": "أجهزة نقطة البيع", "terminals": "أجهزة نقطة البيع",
+11
View File
@@ -1402,6 +1402,17 @@
"Business": "Business", "Business": "Business",
"Enterprise": "Enterprise" "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": { "features": {
"ordersPerDay": "Orders per day", "ordersPerDay": "Orders per day",
"terminals": "POS terminals", "terminals": "POS terminals",
+11
View File
@@ -1403,6 +1403,17 @@
"Business": "بیزنس", "Business": "بیزنس",
"Enterprise": "سازمانی" "Enterprise": "سازمانی"
}, },
"limits": {
"maxOrdersPerDay": "سفارش در روز",
"maxBranches": "شعبه",
"maxTerminals": "ترمینال صندوق",
"maxTables": "میز",
"maxCustomers": "مشتری CRM",
"maxSmsPerMonth": "پیامک در ماه",
"maxMenuItems": "آیتم منو",
"maxReportHistoryDays": "تاریخچه گزارش (روز)",
"maxMenuAi3dPerMonth": "تصویر AI سه‌بعدی در ماه"
},
"features": { "features": {
"ordersPerDay": "سفارش در روز", "ordersPerDay": "سفارش در روز",
"terminals": "ترمینال صندوق", "terminals": "ترمینال صندوق",
@@ -1,213 +1,95 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useMemo } from "react";
import { Check, Minus, X } from "lucide-react"; import { useLocale, useTranslations } from "next-intl";
import { Check, Loader2, Minus, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatCurrency, formatNumber } from "@/lib/format"; 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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
export type PlanId = "Free" | "Pro" | "Business" | "Enterprise"; /** Limit rows shown at the top of the comparison, in display order. */
const LIMIT_ROWS: { key: keyof PlanLimits; zeroAsDash?: boolean }[] = [
export const PLAN_ORDER: PlanId[] = ["Free", "Pro", "Business", "Enterprise"]; { key: "maxOrdersPerDay" },
{ key: "maxBranches" },
export const PRICES: Record<PlanId, number | null> = { { key: "maxTerminals" },
Free: 0, { key: "maxTables" },
Pro: 1_490_000, { key: "maxCustomers" },
Business: 3_490_000, { key: "maxSmsPerMonth", zeroAsDash: true },
Enterprise: null, { key: "maxMenuItems" },
}; { key: "maxReportHistoryDays" },
{ key: "maxMenuAi3dPerMonth", zeroAsDash: true },
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 },
},
},
]; ];
function CellDisplay({ function LimitCell({
cell, value,
t, zeroAsDash,
unlimitedLabel,
numberLocale, numberLocale,
}: { }: {
cell: CellValue; value: number;
t: ReturnType<typeof useTranslations<"settings.plans">>; zeroAsDash?: boolean;
unlimitedLabel: string;
numberLocale: string; numberLocale: string;
}) { }) {
if (cell.kind === "bool") { if (value >= UNLIMITED) {
return cell.value ? ( 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 /> <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 /> <X className="mx-auto h-5 w-5 text-muted-foreground/50" aria-hidden />
); );
}
function PriceLine({
plan,
t,
numberLocale,
}: {
plan: PlatformPlan;
t: ReturnType<typeof useTranslations<"settings.plans">>;
numberLocale: string;
}) {
if (plan.monthlyPriceToman === 0 && !plan.isBillableOnline && plan.tier !== "Free") {
return <>{t("customPrice")}</>;
} }
if (cell.kind === "limit") { if (plan.monthlyPriceToman === 0) {
if (cell.value === null) { return <>{t("freePrice")}</>;
return (
<span className="text-sm font-medium text-[#0F6E56]">{t("unlimited")}</span>
);
} }
if (cell.value === 0) { return <>{formatCurrency(plan.monthlyPriceToman, numberLocale)}</>;
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>
);
}
return (
<span className="text-xs font-medium text-muted-foreground">
{t(`levels.${cell.value}`)}
</span>
);
} }
type PlanComparisonProps = { type PlanComparisonProps = {
currentPlan?: string; currentPlan?: string;
onSubscribe: (planTier: "Pro" | "Business") => void; onSubscribe: (planTier: string) => void;
isSubscribing?: boolean; 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({ export function PlanComparison({
currentPlan = "Free", currentPlan = "Free",
onSubscribe, onSubscribe,
@@ -215,12 +97,35 @@ export function PlanComparison({
}: PlanComparisonProps) { }: PlanComparisonProps) {
const t = useTranslations("settings.plans"); const t = useTranslations("settings.plans");
const tSettings = useTranslations("settings"); const tSettings = useTranslations("settings");
const numberLocale = const locale = useLocale();
typeof document !== "undefined" && document.documentElement.lang === "en" const numberLocale = locale === "en" ? "en-US" : "fa-IR";
? "en-US" const cafeId = useAuthStore((s) => s.user?.cafeId);
: "fa-IR";
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 ( return (
<section className="relative z-0 mb-8 space-y-4 scroll-mt-6"> <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> <p className="mt-1 text-sm text-muted-foreground">{t("compareHint")}</p>
</div> </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="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="overflow-x-auto overscroll-x-contain">
<div className="min-w-[720px] px-2 pb-2 pt-4"> <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"> <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")} {t("featureColumn")}
</th> </th>
{PLAN_ORDER.map((plan) => { {plans.map((plan) => {
const isCurrent = plan === normalizedCurrent; const isCurrent = plan.tier === currentPlan;
const isPopular = plan === "Pro"; const isPopular = plan.tier === popularTier;
return ( return (
<th <th
key={plan} key={plan.tier}
className={cn( className={cn(
"px-3 pb-4 pt-2 align-top", "px-3 pb-4 pt-2 align-top",
isPopular && "bg-[#E1F5EE]/60", isPopular && "bg-[#E1F5EE]/60",
@@ -271,14 +176,10 @@ export function PlanComparison({
) : null} ) : null}
</div> </div>
<div className="text-base font-semibold text-foreground"> <div className="text-base font-semibold text-foreground">
{t(`names.${plan}`)} {planDisplayName(plan, locale)}
</div> </div>
<p className="font-medium text-[#0F6E56]"> <p className="font-medium text-[#0F6E56]">
{PRICES[plan] === null <PriceLine plan={plan} t={t} numberLocale={numberLocale} />
? t("customPrice")
: PRICES[plan] === 0
? t("freePrice")
: formatCurrency(PRICES[plan]!, numberLocale)}
</p> </p>
<p className="text-[11px] text-muted-foreground">{t("perMonth")}</p> <p className="text-[11px] text-muted-foreground">{t("perMonth")}</p>
</div> </div>
@@ -288,7 +189,7 @@ export function PlanComparison({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{FEATURE_MATRIX.map((row, idx) => ( {LIMIT_ROWS.map((row, idx) => (
<tr <tr
key={row.key} key={row.key}
className={cn( className={cn(
@@ -297,45 +198,75 @@ export function PlanComparison({
)} )}
> >
<td className="px-4 py-3 text-start text-sm text-foreground"> <td className="px-4 py-3 text-start text-sm text-foreground">
{t(`features.${row.key}`)} {t(`limits.${row.key}`)}
</td> </td>
{PLAN_ORDER.map((plan) => ( {plans.map((plan) => (
<td <td
key={plan} key={plan.tier}
className={cn( className={cn(
"px-3 py-3", "px-3 py-3",
plan === "Pro" && "bg-[#E1F5EE]/30", plan.tier === popularTier && "bg-[#E1F5EE]/30",
plan === normalizedCurrent && "bg-[#E1F5EE]/50" plan.tier === currentPlan && "bg-[#E1F5EE]/50"
)} )}
> >
<CellDisplay <LimitCell
cell={row.cells[plan]} value={plan.limits[row.key]}
t={t} zeroAsDash={row.zeroAsDash}
unlimitedLabel={t("unlimited")}
numberLocale={numberLocale} numberLocale={numberLocale}
/> />
</td> </td>
))} ))}
</tr> </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> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div className="overflow-x-auto overscroll-x-contain border-t border-border/80 bg-muted/10"> <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="px-4" aria-hidden />
{PLAN_ORDER.map((plan) => (
<div <div
key={plan} className="grid min-w-[720px] items-center gap-0 px-2 py-5"
style={{ gridTemplateColumns: gridCols.replace(/_/g, " ") }}
>
<div className="px-4" aria-hidden />
{plans.map((plan) => (
<div
key={plan.tier}
className={cn( className={cn(
"px-3", "px-3",
plan === "Pro" && "bg-[#E1F5EE]/30", plan.tier === popularTier && "bg-[#E1F5EE]/30",
plan === normalizedCurrent && "bg-[#E1F5EE]/50" plan.tier === currentPlan && "bg-[#E1F5EE]/50"
)} )}
> >
<PlanCta <PlanCta
plan={plan} plan={plan}
currentPlan={normalizedCurrent} locale={locale}
currentPlan={currentPlan}
onSubscribe={onSubscribe} onSubscribe={onSubscribe}
isSubscribing={isSubscribing} isSubscribing={isSubscribing}
t={t} t={t}
@@ -349,12 +280,12 @@ export function PlanComparison({
{/* Mobile plan cards */} {/* Mobile plan cards */}
<div className="grid gap-4 lg:hidden"> <div className="grid gap-4 lg:hidden">
{PLAN_ORDER.map((plan) => { {plans.map((plan) => {
const isCurrent = plan === normalizedCurrent; const isCurrent = plan.tier === currentPlan;
const isPopular = plan === "Pro"; const isPopular = plan.tier === popularTier;
return ( return (
<article <article
key={plan} key={plan.tier}
className={cn( className={cn(
"relative rounded-xl border bg-card p-4 shadow-sm", "relative rounded-xl border bg-card p-4 shadow-sm",
isPopular ? "border-[#0F6E56] ring-1 ring-[#0F6E56]/30" : "border-border/80", 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"> <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 && ( {isPopular && (
<Badge className="bg-[#0F6E56] text-white hover:bg-[#0F6E56]"> <Badge className="bg-[#0F6E56] text-white hover:bg-[#0F6E56]">
{t("popular")} {t("popular")}
@@ -375,35 +306,44 @@ export function PlanComparison({
)} )}
</div> </div>
<p className="mb-4 text-lg font-medium text-[#0F6E56]"> <p className="mb-4 text-lg font-medium text-[#0F6E56]">
{PRICES[plan] === null <PriceLine plan={plan} t={t} numberLocale={numberLocale} />
? t("customPrice") {plan.monthlyPriceToman > 0 && (
: PRICES[plan] === 0
? t("freePrice")
: formatCurrency(PRICES[plan]!, numberLocale)}
{PRICES[plan] !== null && PRICES[plan]! > 0 && (
<span className="ms-1 text-xs font-normal text-muted-foreground"> <span className="ms-1 text-xs font-normal text-muted-foreground">
{t("perMonth")} {t("perMonth")}
</span> </span>
)} )}
</p> </p>
<ul className="mb-4 space-y-2 border-t border-border/60 pt-3"> <ul className="mb-4 space-y-2 border-t border-border/60 pt-3">
{FEATURE_MATRIX.map((row) => ( {LIMIT_ROWS.map((row) => (
<li <li
key={row.key} key={row.key}
className="flex items-center justify-between gap-2 text-sm" className="flex items-center justify-between gap-2 text-sm"
> >
<span className="text-muted-foreground">{t(`features.${row.key}`)}</span> <span className="text-muted-foreground">{t(`limits.${row.key}`)}</span>
<CellDisplay <LimitCell
cell={row.cells[plan]} value={plan.limits[row.key]}
t={t} zeroAsDash={row.zeroAsDash}
unlimitedLabel={t("unlimited")}
numberLocale={numberLocale} numberLocale={numberLocale}
/> />
</li> </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> </ul>
<PlanCta <PlanCta
plan={plan} plan={plan}
currentPlan={normalizedCurrent} locale={locale}
currentPlan={currentPlan}
onSubscribe={onSubscribe} onSubscribe={onSubscribe}
isSubscribing={isSubscribing} isSubscribing={isSubscribing}
t={t} t={t}
@@ -419,22 +359,24 @@ export function PlanComparison({
function PlanCta({ function PlanCta({
plan, plan,
locale,
currentPlan, currentPlan,
onSubscribe, onSubscribe,
isSubscribing, isSubscribing,
t, t,
fullWidth, fullWidth,
}: { }: {
plan: PlanId; plan: PlatformPlan;
currentPlan: PlanId; locale: string;
onSubscribe: (planTier: "Pro" | "Business") => void; currentPlan: string;
onSubscribe: (planTier: string) => void;
isSubscribing: boolean; isSubscribing: boolean;
t: ReturnType<typeof useTranslations<"settings.plans">>; t: ReturnType<typeof useTranslations<"settings.plans">>;
fullWidth?: boolean; fullWidth?: boolean;
}) { }) {
const isCurrent = plan === currentPlan; const isCurrent = plan.tier === currentPlan;
if (plan === "Free") { if (plan.monthlyPriceToman === 0 && plan.tier === "Free") {
return ( return (
<Button variant="outline" disabled className={fullWidth ? "w-full" : ""} size="sm"> <Button variant="outline" disabled className={fullWidth ? "w-full" : ""} size="sm">
{isCurrent ? t("currentPlanBtn") : t("included")} {isCurrent ? t("currentPlanBtn") : t("included")}
@@ -442,7 +384,7 @@ function PlanCta({
); );
} }
if (plan === "Enterprise") { if (!plan.isBillableOnline) {
return ( return (
<Button variant="outline" className={fullWidth ? "w-full" : ""} size="sm" asChild> <Button variant="outline" className={fullWidth ? "w-full" : ""} size="sm" asChild>
<a href="mailto:sales@meezi.ir">{t("contactSales")}</a> <a href="mailto:sales@meezi.ir">{t("contactSales")}</a>
@@ -460,15 +402,12 @@ function PlanCta({
return ( return (
<Button <Button
className={cn( className={cn("bg-[#0F6E56] hover:bg-[#0c5a46]", fullWidth ? "w-full" : "")}
"bg-[#0F6E56] hover:bg-[#0c5a46]",
fullWidth ? "w-full" : ""
)}
size="sm" size="sm"
disabled={isSubscribing} disabled={isSubscribing}
onClick={() => onSubscribe(plan)} onClick={() => onSubscribe(plan.tier)}
> >
{t("subscribe", { plan: t(`names.${plan}`) })} {t("subscribe", { plan: planDisplayName(plan, locale) })}
</Button> </Button>
); );
} }
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/layout/page-header"; 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 = { type SubscribeResponse = {
paymentId: string; paymentId: string;
@@ -28,23 +28,31 @@ type PaymentMethod = {
isDefault: boolean; isDefault: boolean;
}; };
const BILLABLE_PLANS: PlanId[] = ["Pro", "Business"];
const MONTH_OPTIONS = [1, 3, 6, 12]; const MONTH_OPTIONS = [1, 3, 6, 12];
export function CheckoutScreen() { export function CheckoutScreen() {
const t = useTranslations("subscription"); const t = useTranslations("subscription");
const tc = useTranslations("subscription.checkout"); const tc = useTranslations("subscription.checkout");
const tPlans = useTranslations("settings.plans");
const apiError = useApiError(); const apiError = useApiError();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const cafeId = user?.cafeId; const cafeId = user?.cafeId;
const role = user?.role; const role = user?.role;
const locale =
typeof document !== "undefined" && document.documentElement.lang === "en"
? "en"
: "fa";
const planParam = searchParams.get("plan") as PlanId | null; const planParam = searchParams.get("plan");
const isBillable = !!planParam && BILLABLE_PLANS.includes(planParam); // Validate against the live, admin-editable plan catalog — only plans that
const plan = (isBillable ? planParam : "Pro") as PlanId; // 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 [months, setMonths] = useState(1);
const [paymentMethod, setPaymentMethod] = useState(""); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader title={tc("title")} subtitle={tc("subtitle")} /> <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 subtotal = unitPrice * months;
const total = subtotal; const total = subtotal;
const planName = tPlans(`names.${plan}`); const planName = planDisplayName(selectedPlan, locale);
const BackIcon = isRtl ? ArrowRight : ArrowLeft; const BackIcon = isRtl ? ArrowRight : ArrowLeft;
const issuedAt = new Date().toLocaleDateString(numberLocale); const issuedAt = new Date().toLocaleDateString(numberLocale);
const invoiceNo = `MZ-${Date.now().toString().slice(-8)}`; 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;
}