From 7d06f149d3e80ca861cdbfcbafbeb78c5a819184 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 02:10:24 +0330 Subject: [PATCH] feat(plans): menu watermark on Free (removed by paid feature) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Meezi.API/Models/Public/PublicDtos.cs | 3 +- src/Meezi.API/Services/PublicService.cs | 15 +++++-- .../NoOpPlatformCatalogService.cs | 44 +++++++++++++++++++ tests/Meezi.API.Tests/QrMenuTests.cs | 2 +- .../src/components/qr/qr-guest-menu.tsx | 12 +++++ web/dashboard/src/lib/api/qr-public.ts | 2 + 6 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 tests/Meezi.API.Tests/NoOpPlatformCatalogService.cs diff --git a/src/Meezi.API/Models/Public/PublicDtos.cs b/src/Meezi.API/Models/Public/PublicDtos.cs index 9293acb..fd254ec 100644 --- a/src/Meezi.API/Models/Public/PublicDtos.cs +++ b/src/Meezi.API/Models/Public/PublicDtos.cs @@ -107,7 +107,8 @@ public record PublicMenuDto( string CafeName, string Slug, CafeThemeDto Theme, - IReadOnlyList Categories); + IReadOnlyList Categories, + bool ShowWatermark); public record GuestCreateOrderRequest( OrderType OrderType, diff --git a/src/Meezi.API/Services/PublicService.cs b/src/Meezi.API/Services/PublicService.cs index fd18a9e..27aa76a 100644 --- a/src/Meezi.API/Services/PublicService.cs +++ b/src/Meezi.API/Services/PublicService.cs @@ -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; } + /// Free menus show a Meezi watermark; the `watermark_removed` feature (paid) hides it. + private async Task ShowWatermarkAsync(Cafe cafe, CancellationToken ct) => + !await _catalog.IsFeatureEnabledForCafeAsync(cafe.Id, cafe.PlanTier, "watermark_removed", ct); + public Task> 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( diff --git a/tests/Meezi.API.Tests/NoOpPlatformCatalogService.cs b/tests/Meezi.API.Tests/NoOpPlatformCatalogService.cs new file mode 100644 index 0000000..f30c423 --- /dev/null +++ b/tests/Meezi.API.Tests/NoOpPlatformCatalogService.cs @@ -0,0 +1,44 @@ +using Meezi.Core.Enums; +using Meezi.Core.Platform; +using Meezi.Infrastructure.Services.Platform; + +namespace Meezi.API.Tests; + +/// Test double: every feature enabled, unlimited limits. Keeps plan gating +/// out of the way for service-level tests. +internal sealed class NoOpPlatformCatalogService : IPlatformCatalogService +{ + public Task> GetPlansAsync(CancellationToken ct = default) => + Task.FromResult>([]); + + public Task GetPlanAsync(PlanTier tier, CancellationToken ct = default) => + Task.FromResult(null); + + public Task GetLimitsAsync(PlanTier tier, CancellationToken ct = default) => + Task.FromResult(new PlanLimitsData()); + + public Task GetMonthlyPriceTomanAsync(PlanTier tier, CancellationToken ct = default) => + Task.FromResult(0m); + + public Task IsBillableOnlineAsync(PlanTier tier, CancellationToken ct = default) => + Task.FromResult(false); + + public Task> GetSettingsAsync(CancellationToken ct = default) => + Task.FromResult>([]); + + public Task GetSettingAsync(string key, CancellationToken ct = default) => + Task.FromResult(null); + + public Task> GetFeaturesAsync(CancellationToken ct = default) => + Task.FromResult>([]); + + public Task> GetEffectiveFeaturesForCafeAsync( + string cafeId, PlanTier planTier, CancellationToken ct = default) => + Task.FromResult>(new Dictionary()); + + public Task IsFeatureEnabledForCafeAsync( + string cafeId, PlanTier planTier, string featureKey, CancellationToken ct = default) => + Task.FromResult(true); + + public void InvalidateCache() { } +} diff --git a/tests/Meezi.API.Tests/QrMenuTests.cs b/tests/Meezi.API.Tests/QrMenuTests.cs index 40ab6a6..23e83a8 100644 --- a/tests/Meezi.API.Tests/QrMenuTests.cs +++ b/tests/Meezi.API.Tests/QrMenuTests.cs @@ -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); } diff --git a/web/dashboard/src/components/qr/qr-guest-menu.tsx b/web/dashboard/src/components/qr/qr-guest-menu.tsx index 559d8ab..7dad448 100644 --- a/web/dashboard/src/components/qr/qr-guest-menu.tsx +++ b/web/dashboard/src/components/qr/qr-guest-menu.tsx @@ -65,6 +65,7 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) { const [tableOrders, setTableOrders] = useState([]); const [submitting, setSubmitting] = useState(false); const [menuTheme, setMenuTheme] = useState(null); + const [showWatermark, setShowWatermark] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [view3dItem, setView3dItem] = useState(null); const [security, setSecurity] = useState(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 ? ( + + ساخته‌شده با میزی + + ) : null} {totalItems > 0 ? (
diff --git a/web/dashboard/src/lib/api/qr-public.ts b/web/dashboard/src/lib/api/qr-public.ts index adabd4b..dbaf3bc 100644 --- a/web/dashboard/src/lib/api/qr-public.ts +++ b/web/dashboard/src/lib/api/qr-public.ts @@ -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 = {