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:
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "أجهزة نقطة البيع",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
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,
|
t,
|
||||||
numberLocale,
|
numberLocale,
|
||||||
}: {
|
}: {
|
||||||
cell: CellValue;
|
plan: PlatformPlan;
|
||||||
t: ReturnType<typeof useTranslations<"settings.plans">>;
|
t: ReturnType<typeof useTranslations<"settings.plans">>;
|
||||||
numberLocale: string;
|
numberLocale: string;
|
||||||
}) {
|
}) {
|
||||||
if (cell.kind === "bool") {
|
if (plan.monthlyPriceToman === 0 && !plan.isBillableOnline && plan.tier !== "Free") {
|
||||||
return cell.value ? (
|
return <>{t("customPrice")}</>;
|
||||||
<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 (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 <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 (
|
return <>{formatCurrency(plan.monthlyPriceToman, numberLocale)}</>;
|
||||||
<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="grid min-w-[720px] items-center gap-0 px-2 py-5"
|
||||||
|
style={{ gridTemplateColumns: gridCols.replace(/_/g, " ") }}
|
||||||
|
>
|
||||||
<div className="px-4" aria-hidden />
|
<div className="px-4" aria-hidden />
|
||||||
{PLAN_ORDER.map((plan) => (
|
{plans.map((plan) => (
|
||||||
<div
|
<div
|
||||||
key={plan}
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user