From 615d5348de3e52e6fce80b3e0bd1e2a5f7343144 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Fri, 12 Jun 2026 08:16:29 +0330 Subject: [PATCH] fix(subscription): plan comparison + checkout read the live plan catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/CafePlatformController.cs | 9 + web/dashboard/messages/ar.json | 11 + web/dashboard/messages/en.json | 11 + web/dashboard/messages/fa.json | 11 + .../components/settings/plan-comparison.tsx | 431 ++++++++---------- .../subscription/checkout-screen.tsx | 35 +- web/dashboard/src/lib/api/platform-plans.ts | 75 +++ 7 files changed, 328 insertions(+), 255 deletions(-) create mode 100644 web/dashboard/src/lib/api/platform-plans.ts diff --git a/src/Meezi.API/Controllers/CafePlatformController.cs b/src/Meezi.API/Controllers/CafePlatformController.cs index ebcb91d..539ac32 100644 --- a/src/Meezi.API/Controllers/CafePlatformController.cs +++ b/src/Meezi.API/Controllers/CafePlatformController.cs @@ -36,4 +36,13 @@ public class CafePlatformController : CafeApiControllerBase var plans = await _catalog.GetPlansAsync(cancellationToken); return Ok(new ApiResponse(true, plans)); } + + /// Feature catalog (key → display name / module group) so clients can + /// label the FeatureKeys returned by the plans endpoint. + [HttpGet("features-catalog")] + public async Task GetFeaturesCatalog(CancellationToken cancellationToken) + { + var features = await _catalog.GetFeaturesAsync(cancellationToken); + return Ok(new ApiResponse(true, features)); + } } diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json index 624b634..6868ef8 100644 --- a/web/dashboard/messages/ar.json +++ b/web/dashboard/messages/ar.json @@ -1320,6 +1320,17 @@ "Business": "أعمال", "Enterprise": "مؤسسات" }, + "limits": { + "maxOrdersPerDay": "طلبات في اليوم", + "maxBranches": "الفروع", + "maxTerminals": "أجهزة الكاشير", + "maxTables": "الطاولات", + "maxCustomers": "عملاء CRM", + "maxSmsPerMonth": "رسائل SMS شهرياً", + "maxMenuItems": "أصناف القائمة", + "maxReportHistoryDays": "سجل التقارير (أيام)", + "maxMenuAi3dPerMonth": "صور AI ثلاثية الأبعاد شهرياً" + }, "features": { "ordersPerDay": "طلبات يومياً", "terminals": "أجهزة نقطة البيع", diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 40acff8..4f15bd0 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -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", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index effae62..2ffc86d 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -1403,6 +1403,17 @@ "Business": "بیزنس", "Enterprise": "سازمانی" }, + "limits": { + "maxOrdersPerDay": "سفارش در روز", + "maxBranches": "شعبه", + "maxTerminals": "ترمینال صندوق", + "maxTables": "میز", + "maxCustomers": "مشتری CRM", + "maxSmsPerMonth": "پیامک در ماه", + "maxMenuItems": "آیتم منو", + "maxReportHistoryDays": "تاریخچه گزارش (روز)", + "maxMenuAi3dPerMonth": "تصویر AI سه‌بعدی در ماه" + }, "features": { "ordersPerDay": "سفارش در روز", "terminals": "ترمینال صندوق", diff --git a/web/dashboard/src/components/settings/plan-comparison.tsx b/web/dashboard/src/components/settings/plan-comparison.tsx index edaf74c..368cbc4 100644 --- a/web/dashboard/src/components/settings/plan-comparison.tsx +++ b/web/dashboard/src/components/settings/plan-comparison.tsx @@ -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 = { - 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; -}; - -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 {unlimitedLabel}; + } + if (value === 0 && zeroAsDash) { + return ; + } + return ( + {formatNumber(value, numberLocale)} + ); +} + +function BoolCell({ value }: { value: boolean }) { + return value ? ( + + ) : ( + + ); +} + +function PriceLine({ + plan, t, numberLocale, }: { - cell: CellValue; + plan: PlatformPlan; t: ReturnType>; numberLocale: string; }) { - if (cell.kind === "bool") { - return cell.value ? ( - - ) : ( - - ); + if (plan.monthlyPriceToman === 0 && !plan.isBillableOnline && plan.tier !== "Free") { + return <>{t("customPrice")}; } - if (cell.kind === "limit") { - if (cell.value === null) { - return ( - {t("unlimited")} - ); - } - if (cell.value === 0) { - return ; - } - return ( - {formatNumber(cell.value, numberLocale)} - ); + if (plan.monthlyPriceToman === 0) { + return <>{t("freePrice")}; } - return ( - - {t(`levels.${cell.value}`)} - - ); + 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 ( +
+ +
+ ); + } + if (plans.length === 0) return null; + + const gridCols = `28%_repeat(${plans.length},minmax(0,1fr))`; return (
@@ -232,7 +137,7 @@ export function PlanComparison({

{t("compareHint")}

- {/* Desktop comparison table — badges in-flow; CTAs outside scroll clip */} + {/* Desktop comparison table */}
@@ -242,12 +147,12 @@ export function PlanComparison({ {t("featureColumn")} - {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 (
- {t(`names.${plan}`)} + {planDisplayName(plan, locale)}

- {PRICES[plan] === null - ? t("customPrice") - : PRICES[plan] === 0 - ? t("freePrice") - : formatCurrency(PRICES[plan]!, numberLocale)} +

{t("perMonth")}

@@ -288,7 +189,7 @@ export function PlanComparison({ - {FEATURE_MATRIX.map((row, idx) => ( + {LIMIT_ROWS.map((row, idx) => ( - {t(`features.${row.key}`)} + {t(`limits.${row.key}`)} - {PLAN_ORDER.map((plan) => ( + {plans.map((plan) => ( - ))} ))} + {featureRows.map((feature, idx) => ( + + + {featureDisplayName(feature, locale)} + + {plans.map((plan) => ( + + + + ))} + + ))}
-
+
- {PLAN_ORDER.map((plan) => ( + {plans.map((plan) => (
- {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 (
-

{t(`names.${plan}`)}

+

{planDisplayName(plan, locale)}

{isPopular && ( {t("popular")} @@ -375,35 +306,44 @@ export function PlanComparison({ )}

- {PRICES[plan] === null - ? t("customPrice") - : PRICES[plan] === 0 - ? t("freePrice") - : formatCurrency(PRICES[plan]!, numberLocale)} - {PRICES[plan] !== null && PRICES[plan]! > 0 && ( + + {plan.monthlyPriceToman > 0 && ( {t("perMonth")} )}

    - {FEATURE_MATRIX.map((row) => ( + {LIMIT_ROWS.map((row) => (
  • - {t(`features.${row.key}`)} - {t(`limits.${row.key}`)} +
  • ))} + {featureRows.map((feature) => ( +
  • + + {featureDisplayName(feature, locale)} + + +
  • + ))}
void; + plan: PlatformPlan; + locale: string; + currentPlan: string; + onSubscribe: (planTier: string) => void; isSubscribing: boolean; t: ReturnType>; fullWidth?: boolean; }) { - const isCurrent = plan === currentPlan; + const isCurrent = plan.tier === currentPlan; - if (plan === "Free") { + if (plan.monthlyPriceToman === 0 && plan.tier === "Free") { return ( ); } diff --git a/web/dashboard/src/components/subscription/checkout-screen.tsx b/web/dashboard/src/components/subscription/checkout-screen.tsx index 3c2c040..786cada 100644 --- a/web/dashboard/src/components/subscription/checkout-screen.tsx +++ b/web/dashboard/src/components/subscription/checkout-screen.tsx @@ -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 ( +
+ +

{t("loading")}

+
+ ); + } + + if (!isBillable || !selectedPlan) { return (
@@ -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)}`; diff --git a/web/dashboard/src/lib/api/platform-plans.ts b/web/dashboard/src/lib/api/platform-plans.ts new file mode 100644 index 0000000..c8448e9 --- /dev/null +++ b/web/dashboard/src/lib/api/platform-plans.ts @@ -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(`/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(`/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; +}