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.
This commit is contained in:
soroush.asadi
2026-06-03 02:10:24 +03:30
parent 2487f9e30f
commit 7d06f149d3
6 changed files with 73 additions and 5 deletions
+2 -1
View File
@@ -107,7 +107,8 @@ public record PublicMenuDto(
string CafeName, string CafeName,
string Slug, string Slug,
CafeThemeDto Theme, CafeThemeDto Theme,
IReadOnlyList<PublicMenuCategoryDto> Categories); IReadOnlyList<PublicMenuCategoryDto> Categories,
bool ShowWatermark);
public record GuestCreateOrderRequest( public record GuestCreateOrderRequest(
OrderType OrderType, OrderType OrderType,
+12 -3
View File
@@ -53,6 +53,7 @@ public class PublicService : IPublicService
private readonly IBranchIdentityService _identity; private readonly IBranchIdentityService _identity;
private readonly IAbuseProtectionService _abuse; private readonly IAbuseProtectionService _abuse;
private readonly IHttpContextAccessor _http; private readonly IHttpContextAccessor _http;
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
public PublicService( public PublicService(
AppDbContext db, AppDbContext db,
@@ -62,7 +63,8 @@ public class PublicService : IPublicService
IBranchMenuService branchMenu, IBranchMenuService branchMenu,
IBranchIdentityService identity, IBranchIdentityService identity,
IAbuseProtectionService abuse, IAbuseProtectionService abuse,
IHttpContextAccessor http) IHttpContextAccessor http,
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
{ {
_db = db; _db = db;
_orders = orders; _orders = orders;
@@ -72,8 +74,13 @@ public class PublicService : IPublicService
_identity = identity; _identity = identity;
_abuse = abuse; _abuse = abuse;
_http = http; _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( public Task<IReadOnlyList<CafeDiscoverDto>> DiscoverAsync(
DiscoverFilterParams filters, DiscoverFilterParams filters,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
@@ -190,7 +197,8 @@ public class PublicService : IPublicService
.Where(c => c.Items.Count > 0) .Where(c => c.Items.Count > 0)
.ToList(); .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( 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) .OrderBy(c => categoryById.GetValueOrDefault(c.Id)?.SortOrder ?? 0)
.ToList(); .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( 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 http = new HttpContextAccessor();
var media = new NoOpMediaStorageService(); var media = new NoOpMediaStorageService();
var reviews = new ReviewService(db, abuse, http, media); 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); 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 [tableOrders, setTableOrders] = useState<GuestOrderRef[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [menuTheme, setMenuTheme] = useState<CafeTheme | null>(null); const [menuTheme, setMenuTheme] = useState<CafeTheme | null>(null);
const [showWatermark, setShowWatermark] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [view3dItem, setView3dItem] = useState<QrPublicMenuItem | null>(null); const [view3dItem, setView3dItem] = useState<QrPublicMenuItem | null>(null);
const [security, setSecurity] = useState<PublicSecurityConfig | null>(null); const [security, setSecurity] = useState<PublicSecurityConfig | null>(null);
@@ -111,6 +112,7 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
const cats = menu.categories ?? []; const cats = menu.categories ?? [];
setCategories(cats); setCategories(cats);
setMenuTheme(normalizeCafeTheme(menu.theme ?? undefined)); setMenuTheme(normalizeCafeTheme(menu.theme ?? undefined));
setShowWatermark(menu.showWatermark ?? false);
setActiveCategory(QR_ALL_CATEGORY_ID); setActiveCategory(QR_ALL_CATEGORY_ID);
if (cats.length === 0) { if (cats.length === 0) {
setError(t("emptyMenu")); setError(t("emptyMenu"));
@@ -565,6 +567,16 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
view3d: t("view3d"), 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> </div>
{totalItems > 0 ? ( {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"> <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; slug: string;
theme?: CafeTheme | null; theme?: CafeTheme | null;
categories: QrPublicMenuCategory[]; categories: QrPublicMenuCategory[];
/** Free plan shows the Meezi watermark under the menu; paid plans hide it. */
showWatermark?: boolean;
}; };
export type QrCartLine = { export type QrCartLine = {