Compare commits

2 Commits

Author SHA1 Message Date
soroush.asadi 9b2f15151d feat(website): reflect new features + 5-tier pricing
CI/CD / CI · API (dotnet build + test) (push) Successful in 50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 45s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m18s
- Pricing: add the Starter tier (now Free·Starter·Pro·Business·Enterprise),
  fix currency ₺ (Turkish Lira) → Toman, and rewrite every plan's bullets to the
  agreed matrix (Free: 6 tables/30 orders/Koja/offline + watermark; Starter:
  watermark-removal/custom-styling/review-reply; Pro: CRM/reports/taxes/payroll/
  delivery/3 branches; Business: 3D + AI-3D + unlimited; Enterprise: API/white-label/
  SLA/24-7). 5-column responsive grid.
- Features: add two headliner cards that were missing — "Works offline" and
  "Get discovered on Koja". fa/en.

Website tsc + build clean.
2026-06-03 02:20:16 +03:30
soroush.asadi 7d06f149d3 feat(plans): menu watermark on Free (removed by paid feature)
Guest QR menu shows a "ساخته‌شده با میزی" watermark under the menu unless the café's
plan has the `watermark_removed` feature (Starter+).

- PublicMenuDto gains ShowWatermark; PublicService computes it from
  IsFeatureEnabledForCafeAsync("watermark_removed") for both slug and branch menus.
- Guest menu renders the watermark footer when showWatermark.
- NoOpPlatformCatalogService test double (all features on) for the PublicService
  ctor; QrMenuTests updated.

86 tests pass; dashboard tsc clean.
2026-06-03 02:10:24 +03:30
10 changed files with 195 additions and 81 deletions
+2 -1
View File
@@ -107,7 +107,8 @@ public record PublicMenuDto(
string CafeName,
string Slug,
CafeThemeDto Theme,
IReadOnlyList<PublicMenuCategoryDto> Categories);
IReadOnlyList<PublicMenuCategoryDto> Categories,
bool ShowWatermark);
public record GuestCreateOrderRequest(
OrderType OrderType,
+12 -3
View File
@@ -53,6 +53,7 @@ public class PublicService : IPublicService
private readonly IBranchIdentityService _identity;
private readonly IAbuseProtectionService _abuse;
private readonly IHttpContextAccessor _http;
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
public PublicService(
AppDbContext db,
@@ -62,7 +63,8 @@ public class PublicService : IPublicService
IBranchMenuService branchMenu,
IBranchIdentityService identity,
IAbuseProtectionService abuse,
IHttpContextAccessor http)
IHttpContextAccessor http,
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
{
_db = db;
_orders = orders;
@@ -72,8 +74,13 @@ public class PublicService : IPublicService
_identity = identity;
_abuse = abuse;
_http = http;
_catalog = catalog;
}
/// <summary>Free menus show a Meezi watermark; the `watermark_removed` feature (paid) hides it.</summary>
private async Task<bool> ShowWatermarkAsync(Cafe cafe, CancellationToken ct) =>
!await _catalog.IsFeatureEnabledForCafeAsync(cafe.Id, cafe.PlanTier, "watermark_removed", ct);
public Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
DiscoverFilterParams filters,
CancellationToken cancellationToken = default) =>
@@ -190,7 +197,8 @@ public class PublicService : IPublicService
.Where(c => c.Items.Count > 0)
.ToList();
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
await ShowWatermarkAsync(cafe, cancellationToken));
}
public async Task<(GuestOrderPlacedDto? Data, string? ErrorCode, string? ErrorMessage)> PlaceOrderAsync(
@@ -357,7 +365,8 @@ public class PublicService : IPublicService
.OrderBy(c => categoryById.GetValueOrDefault(c.Id)?.SortOrder ?? 0)
.ToList();
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped);
return new PublicMenuDto(cafe.Id, cafe.Name, cafe.Slug, CafeThemeMapping.FromJson(cafe.ThemeJson), grouped,
await ShowWatermarkAsync(cafe, cancellationToken));
}
public async Task<(GuestQrOrderPlacedDto? Data, string? ErrorCode, string? Message)> PlaceBranchGuestOrderAsync(
@@ -0,0 +1,44 @@
using Meezi.Core.Enums;
using Meezi.Core.Platform;
using Meezi.Infrastructure.Services.Platform;
namespace Meezi.API.Tests;
/// <summary>Test double: every feature enabled, unlimited limits. Keeps plan gating
/// out of the way for service-level tests.</summary>
internal sealed class NoOpPlatformCatalogService : IPlatformCatalogService
{
public Task<IReadOnlyList<PlanDefinitionDto>> GetPlansAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<PlanDefinitionDto>>([]);
public Task<PlanDefinitionDto?> GetPlanAsync(PlanTier tier, CancellationToken ct = default) =>
Task.FromResult<PlanDefinitionDto?>(null);
public Task<PlanLimitsData> GetLimitsAsync(PlanTier tier, CancellationToken ct = default) =>
Task.FromResult(new PlanLimitsData());
public Task<decimal> GetMonthlyPriceTomanAsync(PlanTier tier, CancellationToken ct = default) =>
Task.FromResult(0m);
public Task<bool> IsBillableOnlineAsync(PlanTier tier, CancellationToken ct = default) =>
Task.FromResult(false);
public Task<IReadOnlyList<PlatformSettingDto>> GetSettingsAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<PlatformSettingDto>>([]);
public Task<string?> GetSettingAsync(string key, CancellationToken ct = default) =>
Task.FromResult<string?>(null);
public Task<IReadOnlyList<PlatformFeatureDto>> GetFeaturesAsync(CancellationToken ct = default) =>
Task.FromResult<IReadOnlyList<PlatformFeatureDto>>([]);
public Task<IReadOnlyDictionary<string, bool>> GetEffectiveFeaturesForCafeAsync(
string cafeId, PlanTier planTier, CancellationToken ct = default) =>
Task.FromResult<IReadOnlyDictionary<string, bool>>(new Dictionary<string, bool>());
public Task<bool> IsFeatureEnabledForCafeAsync(
string cafeId, PlanTier planTier, string featureKey, CancellationToken ct = default) =>
Task.FromResult(true);
public void InvalidateCache() { }
}
+1 -1
View File
@@ -120,7 +120,7 @@ public class QrMenuTests
var http = new HttpContextAccessor();
var media = new NoOpMediaStorageService();
var reviews = new ReviewService(db, abuse, http, media);
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http, new NoOpPlatformCatalogService());
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
}
@@ -65,6 +65,7 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
const [tableOrders, setTableOrders] = useState<GuestOrderRef[]>([]);
const [submitting, setSubmitting] = useState(false);
const [menuTheme, setMenuTheme] = useState<CafeTheme | null>(null);
const [showWatermark, setShowWatermark] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [view3dItem, setView3dItem] = useState<QrPublicMenuItem | null>(null);
const [security, setSecurity] = useState<PublicSecurityConfig | null>(null);
@@ -111,6 +112,7 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
const cats = menu.categories ?? [];
setCategories(cats);
setMenuTheme(normalizeCafeTheme(menu.theme ?? undefined));
setShowWatermark(menu.showWatermark ?? false);
setActiveCategory(QR_ALL_CATEGORY_ID);
if (cats.length === 0) {
setError(t("emptyMenu"));
@@ -565,6 +567,16 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
view3d: t("view3d"),
}}
/>
{showWatermark ? (
<a
href="https://meezi.ir"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1 py-5 text-xs qr-muted opacity-70"
>
ساختهشده با <span className="font-bold">میزی</span>
</a>
) : null}
</div>
{totalItems > 0 ? (
<div className="pointer-events-none fixed inset-x-0 bottom-[3.25rem] z-40 mx-auto max-w-md px-3 pb-1">
+2
View File
@@ -53,6 +53,8 @@ export type QrPublicMenu = {
slug: string;
theme?: CafeTheme | null;
categories: QrPublicMenuCategory[];
/** Free plan shows the Meezi watermark under the menu; paid plans hide it. */
showWatermark?: boolean;
};
export type QrCartLine = {
+44 -30
View File
@@ -73,7 +73,11 @@
"inventory": "Inventory Management",
"inventoryDesc": "Automatic ingredient tracking, low-stock alerts, and daily consumption reports.",
"multiBranch": "Multi-Branch Management",
"multiBranchDesc": "Manage and compare all your branches from a single central dashboard."
"multiBranchDesc": "Manage and compare all your branches from a single central dashboard.",
"offline": "Works offline",
"offlineDesc": "Keep taking orders with no internet — everything syncs automatically when you reconnect.",
"koja": "Get discovered on Koja",
"kojaDesc": "Your cafe appears on the Koja discovery platform (koja.meezi.ir) to attract new customers."
},
"howItWorks": {
"badge": "Easy Setup",
@@ -121,50 +125,60 @@
"yearlyDiscount": "2 months free",
"popular": "Most popular",
"freeName": "Free",
"freePrice": "Free",
"freePrice": "0",
"freePriceNote": "forever",
"freeDesc": "For small cafes just getting started.",
"ctaFree": "Start for free",
"f1": "1 branch",
"f2": "Up to 50 orders/day",
"f3": "QR digital menu",
"f4": "Tables & reservations",
"f5": "Basic dashboard",
"freeDesc": "For small cafes getting started.",
"ctaFree": "Start free",
"f1": "Full QR digital menu",
"f2": "Up to 6 tables",
"f3": "30 orders/day",
"f4": "Listed on Koja (koja.meezi.ir)",
"f5": "Offline mode + waiter app",
"proName": "Pro",
"proPrice": "1,490,000",
"proPrice": "1,490,000 Toman",
"proPriceNote": "/ month",
"proDesc": "For growing cafes that need professional features.",
"proDesc": "For growing cafes.",
"ctaPro": "Get Pro",
"p1": "3 branches — unlimited orders",
"p2": "3 POS terminals",
"p3": "Full POS & kitchen KDS",
"p1": "Everything in Starter",
"p2": "3 branches & 3 terminals",
"p3": "CRM / loyalty",
"p4": "Full analytics & reports",
"p5": "Tax system integration",
"p6": "50 marketing SMS / month",
"p7": "Phone support",
"p5": "Tax system",
"p6": "Delivery integration",
"p7": "Payroll management",
"businessName": "Business",
"businessPrice": "3,490,000",
"businessPrice": "3,490,000 Toman",
"businessPriceNote": "/ month",
"businessDesc": "For restaurants and multi-branch chains.",
"businessDesc": "For restaurants & chains.",
"ctaBusiness": "Get Business",
"b1": "Unlimited branches — unlimited orders",
"b2": "Unlimited terminals",
"b3": "HR module & shift management",
"b4": "Delivery platform integration",
"b5": "200 marketing SMS / month",
"b6": "Waiter mobile app",
"b1": "Everything in Pro",
"b2": "Unlimited branches",
"b3": "Unlimited terminals",
"b4": "3D menu",
"b5": "AI 3D model generation",
"b6": "Advanced delivery integration",
"b7": "Priority support",
"enterpriseName": "Enterprise",
"enterprisePrice": "Contact us",
"enterprisePriceNote": "custom pricing",
"enterpriseDesc": "For large chains with specific needs.",
"enterpriseDesc": "For large chains.",
"ctaEnterprise": "Contact us",
"e1": "Unlimited branches",
"e1": "Everything in Business",
"e2": "Public API",
"e3": "White-label branding",
"e4": "Trust badges",
"e5": "Custom SLA",
"e6": "24/7 support"
"e4": "Custom SLA",
"e5": "24/7 support",
"e6": "Dedicated manager",
"starterName": "Starter",
"starterPrice": "690,000 Toman",
"starterPriceNote": "/ month",
"starterDesc": "Remove the watermark & customize.",
"ctaStarter": "Get Starter",
"s1": "Everything in Free",
"s2": "Remove Meezi watermark",
"s3": "Custom menu styling",
"s4": "Reply to customer reviews",
"s5": "Up to 15 tables"
},
"faq": {
"badge": "FAQ",
+56 -42
View File
@@ -73,7 +73,11 @@
"inventory": "مدیریت موجودی",
"inventoryDesc": "کنترل خودکار مواد اولیه، هشدار کمبود موجودی و گزارش مصرف روزانه.",
"multiBranch": "مدیریت چند شعبه",
"multiBranchDesc": "تمام شعبه‌هایتان را از یک داشبورد مرکزی مدیریت و مقایسه کنید."
"multiBranchDesc": "تمام شعبه‌هایتان را از یک داشبورد مرکزی مدیریت و مقایسه کنید.",
"offline": "کار بدون اینترنت",
"offlineDesc": "حتی با قطع اینترنت، ثبت سفارش و کار ادامه دارد و هنگام اتصال همه‌چیز همگام می‌شود.",
"koja": "نمایش در کجا",
"kojaDesc": "کافه شما در پلتفرم کشف «کجا» (koja.meezi.ir) دیده می‌شود و مشتری جدید جذب می‌کنید."
},
"howItWorks": {
"badge": "شروع آسان",
@@ -113,58 +117,68 @@
"t3Text": "با میزی می‌توانم همه ۴ شعبه‌ام را از یک جا مدیریت کنم. دیگر نیازی به گزارش جداگانه نیست."
},
"pricing": {
"badge": "قیمت‌گذاری",
"title": "برای هر مقیاسی یک پلن مناسب",
"subtitle": "بدون هزینه پنهان دقیقاً همان چیزی که می‌بینید پرداخت می‌کنید.",
"badge": "تعرفه‌ها",
"title": "یک پلن برای هر مقیاس",
"subtitle": "بدون هزینه پنهان؛ دقیقاً همان چیزی که می‌بینید پرداخت می‌کنید.",
"monthly": "ماهانه",
"yearly": "سالانه",
"yearlyDiscount": "۲ ماه رایگان",
"popular": "پرفروش",
"popular": "محبوب‌ترین",
"freeName": "رایگان",
"freePrice": "رایگان",
"freePriceNote": "برای همیشه",
"freeDesc": "برای کافه‌های کوچک که می‌خواهند شروع کنند.",
"freePrice": "۰",
"freePriceNote": "همیشه رایگان",
"freeDesc": "برای شروع کافه‌های کوچک.",
"ctaFree": "شروع رایگان",
"f1": "۱ شعبه",
"f2": "تا ۵۰ سفارش در روز",
"f3": "منوی دیجیتال QR",
"f4": "میز و رزرو",
"f5": "داشبورد پایه",
"proName": "پرو",
"proPrice": "۱٬۴۹۰٬۰۰۰",
"proPriceNote": "تومان / ماه",
"proDesc": "برای کافه‌های در حال رشد با نیاز به امکانات حرفه‌ای.",
"ctaPro": "خرید پرو",
"p1": "۳ شعبه — سفارش نامحدود",
"p2": "۳ ترمینال صندوق",
"p3": "POS کامل و آشپزخانه KDS",
"p4": "گزارش‌های کامل و تحلیلی",
"p5": امانه مودیان (تاراز)",
"p6": "۵۰ پیامک بازاریابی",
"p7": "پشتیبانی تلفنی",
"businessName": "بیزنس",
"businessPrice": "۳٬۴۹۰٬۰۰۰",
"businessPriceNote": "تومان / ماه",
"businessDesc": "برای رستوران‌ها و زنجیره‌های چند شعبه‌ای.",
"ctaBusiness": "خرید بیزنس",
"b1": "شعبه نامحدود — سفارش نامحدود",
"b2": "ترمینال نامحدود",
"b3": "ماژول منابع انسانی و شیفت",
"b4": "یکپارچگی اسنپ‌فود / پیک",
"b5": "۲۰۰ پیامک بازاریابی",
"b6": "اپ موبایل گارسون",
"f1": "منوی دیجیتال QR کامل",
"f2": "تا ۶ میز",
"f3": "۳۰ سفارش در روز",
"f4": "نمایش در کجا (koja.meezi.ir)",
"f5": "حالت آفلاین + اپ گارسون",
"proName": "حرفه‌ای",
"proPrice": "۱٬۴۹۰٬۰۰۰ تومان",
"proPriceNote": "/ ماه",
"proDesc": "برای کافه‌های در حال رشد.",
"ctaPro": "انتخاب حرفه‌ای",
"p1": "همه امکانات پایه",
"p2": "تا ۳ شعبه و ۳ پایانه",
"p3": "باشگاه مشتریان (CRM)",
"p4": "گزارش‌ها و تحلیل کامل",
"p5": یستم مالیات",
"p6": "اتصال به پلتفرم‌های پیک",
"p7": "مدیریت حقوق و دستمزد",
"businessName": "کسب‌وکار",
"businessPrice": "۳٬۴۹۰٬۰۰۰ تومان",
"businessPriceNote": "/ ماه",
"businessDesc": "برای رستوران‌ها و زنجیره‌ها.",
"ctaBusiness": "انتخاب کسب‌وکار",
"b1": "همه امکانات حرفه‌ای",
"b2": "شعب نامحدود",
"b3": "پایانه نامحدود",
"b4": "منوی سه‌بعدی",
"b5": "ساخت ۳D با هوش مصنوعی",
"b6": "اتصال پیک پیشرفته",
"b7": "پشتیبانی اولویت‌دار",
"enterpriseName": "سازمانی",
"enterprisePrice": "تماس بگیرید",
"enterprisePriceNote": "قیمت سفارشی",
"enterpriseDesc": "برای زنجیره‌های بزرگ با نیازهای خاص.",
"enterprisePriceNote": "قیمت اختصاصی",
"enterpriseDesc": "برای زنجیره‌های بزرگ.",
"ctaEnterprise": "تماس با ما",
"e1": "شعبه نامحدود",
"e1": "همه امکانات کسب‌وکار",
"e2": "API عمومی",
"e3": "برند اختصاصی (White-label)",
"e4": "نشان اعتبار",
"e5": "SLA اختصاصی",
"e6": "پشتیبانی ۲۴/۷"
"e4": "SLA اختصاصی",
"e5": "پشتیبانی ۲۴/۷",
"e6": "مدیر اختصاصی",
"starterName": "پایه",
"starterPrice": "۶۹۰٬۰۰۰ تومان",
"starterPriceNote": "/ ماه",
"starterDesc": "برای حذف واترمارک و شخصی‌سازی.",
"ctaStarter": "شروع پایه",
"s1": "همه امکانات رایگان",
"s2": "حذف واترمارک میزی از منو",
"s3": "طراحی اختصاصی منو",
"s4": "پاسخ به نظرات مشتریان",
"s5": "تا ۱۵ میز"
},
"faq": {
"badge": "سوالات متداول",
@@ -6,10 +6,14 @@ import {
Users,
Package,
Building2,
WifiOff,
MapPin,
} from "lucide-react";
const FEATURES = [
{ icon: QrCode, key: "qrMenu", descKey: "qrMenuDesc", color: "bg-brand-50 text-brand-700" },
{ icon: WifiOff, key: "offline", descKey: "offlineDesc", color: "bg-emerald-50 text-emerald-700" },
{ icon: MapPin, key: "koja", descKey: "kojaDesc", color: "bg-sky-50 text-sky-700" },
{ icon: ShoppingCart, key: "pos", descKey: "posDesc", color: "bg-amber-50 text-amber-700" },
{ icon: BarChart3, key: "analytics", descKey: "analyticsDesc", color: "bg-blue-50 text-blue-700" },
{ icon: Users, key: "staff", descKey: "staffDesc", color: "bg-purple-50 text-purple-700" },
@@ -39,11 +39,25 @@ export function PricingSection() {
popular: false,
variant: "outline",
},
{
id: "starter",
name: t("starterName"),
price: yearly
? (locale === "fa" ? "۵۷۵٬۰۰۰ تومان" : "575,000 Toman")
: t("starterPrice"),
priceNote: t("starterPriceNote"),
desc: t("starterDesc"),
cta: t("ctaStarter"),
href: `${base}/demo`,
features: [t("s1"), t("s2"), t("s3"), t("s4"), t("s5")],
popular: false,
variant: "outline",
},
{
id: "pro",
name: t("proName"),
price: yearly
? (locale === "fa" ? "۱٬۲۴۲٬۰۰۰" : "1,242,000")
? (locale === "fa" ? "۱٬۲۴۲٬۰۰۰ تومان" : "1,242,000 Toman")
: t("proPrice"),
priceNote: t("proPriceNote"),
desc: t("proDesc"),
@@ -57,7 +71,7 @@ export function PricingSection() {
id: "business",
name: t("businessName"),
price: yearly
? (locale === "fa" ? "۲٬۹۰۸٬۰۰۰" : "2,908,000")
? (locale === "fa" ? "۲٬۹۰۸٬۰۰۰ تومان" : "2,908,000 Toman")
: t("businessPrice"),
priceNote: t("businessPriceNote"),
desc: t("businessDesc"),
@@ -120,8 +134,8 @@ export function PricingSection() {
</div>
</div>
{/* Plan cards — 4-column grid on xl, 2-col on md, 1-col on mobile */}
<div className="mt-12 grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
{/* Plan cards — 5 tiers: 5-col on xl, 3-col on lg, 2-col on md, 1-col on mobile */}
<div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{plans.map((plan) => (
<PlanCard key={plan.id} plan={plan} t={t} />
))}