Rewrites dashboard and finder Dockerfiles to use a clean multi-stage build (deps → builder → runner) that installs npm packages inside Alpine Linux, avoiding the SWC musl binary issue when building from Windows host. Uses registry.npmmirror.com for reliable installs from restricted networks (Iran). - docker/api/Dockerfile: .NET 10 multi-stage build - docker/web/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/finder/Dockerfile: Node 20-alpine multi-stage, npmmirror - docker/website/Dockerfile: marketing website build - scripts/: PowerShell helper scripts for local dev Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
36 KiB
Meezi — QR Guest Menu + Branch Visual Identity + Tax Inheritance
Copy-paste into Cursor. Based on the bug at /q/demo_table_01 and the branching decisions. Three self-contained PRs. Start with PR-1 (the broken QR flow).
What's broken and what's new
| Area | Status | Work |
|---|---|---|
/q/[code] → shows menu |
❌ Broken | Full fix in PR-1 |
| Guest can browse menu by category | ❌ Missing | PR-1 |
| Guest can place order from QR | ❌ Missing | PR-1 |
| Order appears in dashboard panel | ❌ Missing | PR-1 |
| Branch has own menu (from parent catalog) | ⚠️ Partial | PR-1 uses BranchMenuItemOverride |
| Parent defines child branch tax rates | ❌ Missing | PR-2 |
| Parent defines visual identity (colors, icon) | ❌ Missing | PR-3 |
PR-1 — Fix QR Guest Menu: Full Flow
The flow (end to end)
Guest scans QR on table
↓
/q/demo_table_01 (Next.js public page)
↓
GET /api/q/demo_table_01
→ resolves table → branch → cafe
→ returns: { cafeId, branchId, tableId, tableName, branchName, cafeName }
↓
Guest sees: branch name, menu categories, items with prices
↓
Guest picks items → taps "سفارش" (Order)
↓
POST /api/public/{cafeId}/branches/{branchId}/orders
Body: { tableId, guestName?, guestPhone?, items: [{menuItemId, qty, note?}] }
↓
Order created → appears in dashboard POS board immediately (SignalR)
↓
Guest sees: "سفارش شما ثبت شد ✓" confirmation screen
STEP 1 — Fix the public QR resolver endpoint
File: src/Meezi.API/Controllers/QrController.cs
The current endpoint likely returns minimal data. Expand it:
[AllowAnonymous]
[HttpGet("/api/q/{qrCode}")]
public async Task<IActionResult> ResolveQr(string qrCode, CancellationToken ct)
{
// Find table by QrCode field
var table = await _db.Tables
.Include(t => t.Branch)
.ThenInclude(b => b.Cafe)
.Include(t => t.Branch)
.ThenInclude(b => b.BranchSettings)
.FirstOrDefaultAsync(t => t.QrCode == qrCode && t.IsActive, ct);
if (table is null)
return NotFound(ApiError.Create("TABLE_NOT_FOUND"));
// Load branch visual identity (colors, icon) — PR-3 adds this
var identity = table.Branch.VisualIdentity; // may be null until PR-3
return Ok(new QrResolveDto
{
TableId = table.Id,
TableName = table.Name,
BranchId = table.BranchId,
BranchName = table.Branch.Name,
CafeId = table.Branch.CafeId,
CafeName = table.Branch.Cafe.Name,
// Visual identity — fallback to cafe defaults if branch hasn't set own
PrimaryColor = identity?.PrimaryColor ?? "#C47B2B", // warm coffee default
LogoUrl = identity?.LogoUrl ?? table.Branch.Cafe.LogoUrl,
WelcomeText = table.Branch.BranchSettings?.WelcomeText
?? table.Branch.Cafe.WelcomeText
?? "خوش آمدید",
});
}
STEP 2 — Public branch menu endpoint
File: src/Meezi.API/Controllers/PublicController.cs
Add (no auth — [AllowAnonymous] already on controller):
/// <summary>Returns the effective menu for a branch, visible to guests.</summary>
[HttpGet("/api/public/{cafeId}/branches/{branchId}/menu")]
public async Task<IActionResult> GetBranchMenu(
Guid cafeId, Guid branchId, CancellationToken ct)
{
// Validate branch belongs to cafe
var branch = await _db.Branches
.FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId && b.IsActive, ct);
if (branch is null)
return NotFound(ApiError.Create("BRANCH_NOT_FOUND"));
// Load all active menu items for this cafe
var items = await _db.MenuItems
.Include(m => m.Category)
.Where(m => m.CafeId == cafeId && m.IsActive)
.ToListAsync(ct);
// Load branch overrides
var overrides = await _db.BranchMenuItemOverrides
.Where(o => o.BranchId == branchId && o.CafeId == cafeId)
.ToDictionaryAsync(o => o.MenuItemId, ct);
// Resolve effective menu — exclude unavailable items
var resolved = items
.Where(item => !overrides.TryGetValue(item.Id, out var ov) || ov.IsAvailable)
.Select(item =>
{
overrides.TryGetValue(item.Id, out var ov);
return new PublicMenuItemDto
{
Id = item.Id,
CategoryId = item.CategoryId,
CategoryName = item.Category.Name,
CategorySort = item.Category.SortOrder,
Name = item.Name,
Description = item.Description,
ImageUrl = item.ImageUrl,
EffectivePrice = ov?.PriceOverride ?? item.BasePrice,
Tags = item.Tags,
IsAvailable = true,
SortOrder = ov?.SortOrderOverride ?? item.SortOrder,
};
})
.OrderBy(i => i.CategorySort)
.ThenBy(i => i.SortOrder)
.GroupBy(i => new { i.CategoryId, i.CategoryName, i.CategorySort })
.Select(g => new PublicMenuCategoryDto
{
Id = g.Key.CategoryId,
Name = g.Key.CategoryName,
Sort = g.Key.CategorySort,
Items = g.ToList(),
})
.ToList();
return Ok(ApiResponse<List<PublicMenuCategoryDto>>.Ok(resolved));
}
STEP 3 — Public order placement endpoint
/// <summary>Guest places an order from QR menu.</summary>
[HttpPost("/api/public/{cafeId}/branches/{branchId}/orders")]
public async Task<IActionResult> PlaceGuestOrder(
Guid cafeId, Guid branchId,
[FromBody] PlaceGuestOrderRequest request,
CancellationToken ct)
{
// Validate table belongs to this branch
var table = await _db.Tables
.FirstOrDefaultAsync(t => t.Id == request.TableId
&& t.BranchId == branchId
&& t.CafeId == cafeId
&& t.IsActive, ct);
if (table is null)
return BadRequest(ApiError.Create("TABLE_NOT_FOUND"));
if (table.IsCleaning)
return BadRequest(ApiError.Create("TABLE_CLEANING"));
// Validate items exist and are available at this branch
var itemIds = request.Items.Select(i => i.MenuItemId).ToList();
var menuItems = await _db.MenuItems
.Where(m => itemIds.Contains(m.Id) && m.CafeId == cafeId && m.IsActive)
.ToListAsync(ct);
if (menuItems.Count != itemIds.Distinct().Count())
return BadRequest(ApiError.Create("INVALID_MENU_ITEMS"));
// Load price overrides
var overrides = await _db.BranchMenuItemOverrides
.Where(o => o.BranchId == branchId && itemIds.Contains(o.MenuItemId))
.ToDictionaryAsync(o => o.MenuItemId, ct);
// Find or create open order for this table (merge logic — same as POS)
var existingOrder = await _db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.TableId == table.Id
&& o.CafeId == cafeId
&& o.Status == OrderStatus.Open, ct);
var order = existingOrder ?? new Order
{
Id = Guid.NewGuid(),
CafeId = cafeId,
BranchId = branchId,
TableId = table.Id,
Status = OrderStatus.Open,
Source = OrderSource.GuestQr, // new enum value
GuestName = request.GuestName,
GuestPhone = request.GuestPhone,
CreatedAt = DateTime.UtcNow,
};
// Append items
foreach (var line in request.Items)
{
var menuItem = menuItems.First(m => m.Id == line.MenuItemId);
overrides.TryGetValue(line.MenuItemId, out var ov);
var price = ov?.PriceOverride ?? menuItem.BasePrice;
order.Items.Add(new OrderItem
{
Id = Guid.NewGuid(),
MenuItemId = line.MenuItemId,
ProductName = menuItem.Name,
Quantity = line.Quantity,
UnitPrice = price,
Notes = line.Notes,
Source = ItemSource.GuestQr,
});
}
order.TotalAmount = order.Items
.Where(i => !i.IsVoided)
.Sum(i => i.UnitPrice * i.Quantity);
if (existingOrder is null)
_db.Orders.Add(order);
await _db.SaveChangesAsync(ct);
// Notify dashboard via SignalR
await _boardNotifier.OrderUpdatedAsync(cafeId, order.Id, order.TableId);
return Ok(ApiResponse<GuestOrderConfirmDto>.Ok(new GuestOrderConfirmDto
{
OrderId = order.Id,
OrderNumber = order.OrderNumber,
TotalAmount = order.TotalAmount,
ItemCount = request.Items.Sum(i => i.Quantity),
}));
}
DTOs to add:
// PlaceGuestOrderRequest.cs
public record PlaceGuestOrderRequest(
Guid TableId,
string? GuestName,
string? GuestPhone,
List<GuestOrderLine> Items
);
public record GuestOrderLine(Guid MenuItemId, int Quantity, string? Notes);
// GuestOrderConfirmDto.cs
public record GuestOrderConfirmDto(
Guid OrderId, int OrderNumber, decimal TotalAmount, int ItemCount
);
// Add to Order entity:
public OrderSource Source { get; set; } = OrderSource.Pos;
// OrderSource enum:
public enum OrderSource { Pos, GuestQr, Kiosk, SnappFood }
// Add to OrderItem:
public ItemSource Source { get; set; } = ItemSource.Pos;
public enum ItemSource { Pos, GuestQr }
STEP 4 — EF migration
dotnet ef migrations add GuestQrOrderSource \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API \
--output-dir Data/Migrations
STEP 5 — Fix web/dashboard/src/app/q/[code]/page.tsx
Full rewrite of the guest-facing QR page. This is a public page — no auth, no dashboard layout.
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
type BranchInfo = {
tableId: string; tableName: string;
branchId: string; branchName: string;
cafeId: string; cafeName: string;
primaryColor: string; logoUrl?: string; welcomeText: string;
};
type MenuItem = {
id: string; name: string; description?: string;
imageUrl?: string; effectivePrice: number; tags?: string[];
};
type Category = { id: string; name: string; items: MenuItem[] };
type CartItem = { item: MenuItem; qty: number; note?: string };
type Screen = "loading" | "error" | "menu" | "cart" | "confirm" | "success";
export default function QrMenuPage() {
const { code } = useParams<{ code: string }>();
const [screen, setScreen] = useState<Screen>("loading");
const [error, setError] = useState<string>("");
const [branch, setBranch] = useState<BranchInfo | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [activeCategory, setActiveCategory] = useState<string>("");
const [cart, setCart] = useState<CartItem[]>([]);
const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("");
const [orderId, setOrderId] = useState<string>("");
const [orderNumber, setOrderNumber] = useState<number>(0);
const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "";
// Step 1: resolve QR code → branch info
useEffect(() => {
fetch(`${apiBase}/api/q/${code}`)
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(data => {
setBranch(data);
// Step 2: load menu
return fetch(
`${apiBase}/api/public/${data.cafeId}/branches/${data.branchId}/menu`
);
})
.then(r => r.ok ? r.json() : Promise.reject(r.status))
.then(res => {
setCategories(res.data ?? []);
setActiveCategory(res.data?.[0]?.id ?? "");
setScreen("menu");
})
.catch(() => {
setError("میز یافت نشد یا منو در دسترس نیست");
setScreen("error");
});
}, [code]);
const totalItems = cart.reduce((s, c) => s + c.qty, 0);
const totalPrice = cart.reduce((s, c) => s + c.item.effectivePrice * c.qty, 0);
function addToCart(item: MenuItem) {
setCart(prev => {
const idx = prev.findIndex(c => c.item.id === item.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = { ...next[idx], qty: next[idx].qty + 1 };
return next;
}
return [...prev, { item, qty: 1 }];
});
}
function removeFromCart(itemId: string) {
setCart(prev => {
const idx = prev.findIndex(c => c.item.id === itemId);
if (idx < 0) return prev;
const next = [...prev];
if (next[idx].qty > 1) {
next[idx] = { ...next[idx], qty: next[idx].qty - 1 };
} else {
next.splice(idx, 1);
}
return next;
});
}
async function submitOrder() {
if (!branch || cart.length === 0) return;
setScreen("loading");
try {
const res = await fetch(
`${apiBase}/api/public/${branch.cafeId}/branches/${branch.branchId}/orders`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tableId: branch.tableId,
guestName: guestName || null,
guestPhone: guestPhone || null,
items: cart.map(c => ({
menuItemId: c.item.id,
quantity: c.qty,
notes: c.note ?? null,
})),
}),
}
);
if (!res.ok) throw new Error();
const data = await res.json();
setOrderId(data.data.orderId);
setOrderNumber(data.data.orderNumber);
setScreen("success");
} catch {
setError("خطا در ثبت سفارش. دوباره امتحان کنید");
setScreen("cart");
}
}
const primary = branch?.primaryColor ?? "#C47B2B";
// ── Screens ──────────────────────────────────────────────────────────────
if (screen === "loading") return (
<div style={{ display: "flex", justifyContent: "center", alignItems: "center",
height: "100svh", flexDirection: "column", gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: "50%",
border: `3px solid ${primary}`,
borderTopColor: "transparent", animation: "spin 0.8s linear infinite" }}/>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
<p style={{ color: "#888", fontSize: 14 }}>در حال بارگذاری...</p>
</div>
);
if (screen === "error") return (
<div style={{ textAlign: "center", padding: "4rem 2rem" }}>
<p style={{ fontSize: 48 }}>😕</p>
<p style={{ fontWeight: 500, marginBottom: 8 }}>{error}</p>
<p style={{ color: "#888", fontSize: 14 }}>لطفاً دوباره کد QR را اسکن کنید</p>
</div>
);
if (screen === "success") return (
<div style={{ textAlign: "center", padding: "4rem 2rem" }}>
<div style={{ fontSize: 64, marginBottom: 16 }}>✅</div>
<h2 style={{ fontWeight: 600, fontSize: 22, marginBottom: 8 }}>سفارش ثبت شد!</h2>
<p style={{ color: "#666", marginBottom: 4 }}>شماره سفارش: #{orderNumber}</p>
<p style={{ color: "#888", fontSize: 13 }}>کارکنان به زودی سفارش شما را آماده میکنند</p>
<button
onClick={() => { setCart([]); setScreen("menu"); }}
style={{ marginTop: 32, padding: "12px 32px", borderRadius: 12,
background: primary, color: "#fff", border: "none",
fontSize: 15, fontWeight: 600, cursor: "pointer" }}
>
افزودن آیتم دیگر
</button>
</div>
);
if (screen === "cart") return (
<div style={{ direction: "rtl", fontFamily: "system-ui, sans-serif",
maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24 }}>
<button onClick={() => setScreen("menu")}
style={{ background: "none", border: "none", cursor: "pointer",
fontSize: 22, padding: 0 }}>←</button>
<h2 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>سبد خرید</h2>
</div>
{/* Cart items */}
{cart.map(c => (
<div key={c.item.id} style={{ display: "flex", justifyContent: "space-between",
alignItems: "center", padding: "12px 0",
borderBottom: "1px solid #eee" }}>
<div>
<p style={{ margin: 0, fontWeight: 500 }}>{c.item.name}</p>
<p style={{ margin: 0, fontSize: 13, color: "#888" }}>
{c.item.effectivePrice.toLocaleString("fa-IR")} تومان
</p>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button onClick={() => removeFromCart(c.item.id)}
style={{ width: 32, height: 32, borderRadius: "50%",
border: `1.5px solid ${primary}`, background: "none",
cursor: "pointer", fontSize: 20, color: primary,
display: "flex", alignItems: "center", justifyContent: "center" }}>
−
</button>
<span style={{ fontWeight: 600, minWidth: 20, textAlign: "center" }}>{c.qty}</span>
<button onClick={() => addToCart(c.item)}
style={{ width: 32, height: 32, borderRadius: "50%",
background: primary, border: "none",
cursor: "pointer", fontSize: 20, color: "#fff",
display: "flex", alignItems: "center", justifyContent: "center" }}>
+
</button>
</div>
</div>
))}
{/* Guest info */}
<div style={{ marginTop: 24 }}>
<input value={guestName} onChange={e => setGuestName(e.target.value)}
placeholder="نام شما (اختیاری)"
style={{ width: "100%", padding: "10px 14px", borderRadius: 10,
border: "1px solid #ddd", fontSize: 15, marginBottom: 10,
boxSizing: "border-box", textAlign: "right" }} />
<input value={guestPhone} onChange={e => setGuestPhone(e.target.value)}
placeholder="شماره موبایل (اختیاری)"
inputMode="tel"
style={{ width: "100%", padding: "10px 14px", borderRadius: 10,
border: "1px solid #ddd", fontSize: 15,
boxSizing: "border-box", textAlign: "right" }} />
</div>
{/* Total + submit */}
<div style={{ marginTop: 24, padding: 16, background: "#f9f9f9",
borderRadius: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between",
marginBottom: 16 }}>
<span style={{ fontWeight: 600 }}>جمع کل:</span>
<span style={{ fontWeight: 700, color: primary }}>
{totalPrice.toLocaleString("fa-IR")} تومان
</span>
</div>
<button onClick={submitOrder}
style={{ width: "100%", padding: "14px", borderRadius: 12,
background: primary, color: "#fff", border: "none",
fontSize: 16, fontWeight: 700, cursor: "pointer" }}>
ثبت سفارش
</button>
</div>
</div>
);
// screen === "menu"
return (
<div style={{ direction: "rtl", fontFamily: "system-ui, sans-serif",
maxWidth: 480, margin: "0 auto", paddingBottom: 100 }}>
{/* Branch header */}
<div style={{ padding: "20px 16px 12px", borderBottom: "1px solid #eee",
textAlign: "center" }}>
{branch?.logoUrl && (
<img src={branch.logoUrl} alt={branch.cafeName}
style={{ width: 56, height: 56, borderRadius: "50%",
objectFit: "cover", marginBottom: 8 }} />
)}
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{branch?.cafeName}</h1>
<p style={{ color: "#888", fontSize: 14, margin: "4px 0 0" }}>{branch?.branchName}</p>
<p style={{ fontSize: 13, color: "#aaa", margin: "4px 0 0" }}>
{branch?.welcomeText} — میز {branch?.tableName}
</p>
</div>
{/* Category tabs */}
<div style={{ display: "flex", overflowX: "auto", padding: "12px 16px",
gap: 8, borderBottom: "1px solid #eee",
scrollbarWidth: "none", position: "sticky", top: 0,
background: "#fff", zIndex: 10 }}>
{categories.map(cat => (
<button key={cat.id}
onClick={() => setActiveCategory(cat.id)}
style={{ flexShrink: 0, padding: "6px 16px", borderRadius: 20,
border: activeCategory === cat.id
? `1.5px solid ${primary}` : "1.5px solid #ddd",
background: activeCategory === cat.id ? primary : "transparent",
color: activeCategory === cat.id ? "#fff" : "#444",
fontSize: 13, fontWeight: 500, cursor: "pointer",
whiteSpace: "nowrap" }}>
{cat.name}
</button>
))}
</div>
{/* Menu items */}
<div style={{ padding: "12px 16px" }}>
{categories
.filter(cat => cat.id === activeCategory)
.flatMap(cat => cat.items)
.map(item => {
const inCart = cart.find(c => c.item.id === item.id);
return (
<div key={item.id}
style={{ display: "flex", gap: 12, padding: "14px 0",
borderBottom: "1px solid #f0f0f0" }}>
{item.imageUrl && (
<img src={item.imageUrl} alt={item.name}
style={{ width: 72, height: 72, borderRadius: 10,
objectFit: "cover", flexShrink: 0 }} />
)}
<div style={{ flex: 1 }}>
<p style={{ margin: "0 0 4px", fontWeight: 600, fontSize: 15 }}>
{item.name}
</p>
{item.description && (
<p style={{ margin: "0 0 8px", fontSize: 12, color: "#888",
lineHeight: 1.5 }}>
{item.description}
</p>
)}
<div style={{ display: "flex", justifyContent: "space-between",
alignItems: "center" }}>
<span style={{ fontWeight: 700, color: primary, fontSize: 15 }}>
{item.effectivePrice.toLocaleString("fa-IR")} تومان
</span>
{inCart ? (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<button onClick={() => removeFromCart(item.id)}
style={{ width: 28, height: 28, borderRadius: "50%",
border: `1.5px solid ${primary}`, background: "none",
cursor: "pointer", color: primary, fontSize: 18,
display: "flex", alignItems: "center",
justifyContent: "center" }}>−</button>
<span style={{ fontWeight: 700 }}>{inCart.qty}</span>
<button onClick={() => addToCart(item)}
style={{ width: 28, height: 28, borderRadius: "50%",
background: primary, border: "none",
cursor: "pointer", color: "#fff", fontSize: 18,
display: "flex", alignItems: "center",
justifyContent: "center" }}>+</button>
</div>
) : (
<button onClick={() => addToCart(item)}
style={{ padding: "6px 16px", borderRadius: 20,
background: primary, color: "#fff", border: "none",
cursor: "pointer", fontSize: 13, fontWeight: 600 }}>
افزودن
</button>
)}
</div>
</div>
</div>
);
})}
</div>
{/* Floating cart bar */}
{totalItems > 0 && (
<div style={{ position: "fixed", bottom: 16, right: 16, left: 16,
maxWidth: 448, margin: "0 auto" }}>
<button onClick={() => setScreen("cart")}
style={{ width: "100%", padding: "14px 20px", borderRadius: 16,
background: primary, color: "#fff", border: "none",
fontSize: 15, fontWeight: 700, cursor: "pointer",
display: "flex", justifyContent: "space-between",
alignItems: "center", boxShadow: "0 4px 24px rgba(0,0,0,0.18)" }}>
<span style={{ background: "rgba(255,255,255,0.25)",
borderRadius: "50%", width: 28, height: 28,
display: "flex", alignItems: "center",
justifyContent: "center", fontWeight: 700 }}>
{totalItems}
</span>
<span>مشاهده سبد خرید</span>
<span>{totalPrice.toLocaleString("fa-IR")} تومان</span>
</button>
</div>
)}
</div>
);
}
STEP 6 — Dashboard: show guest QR orders in POS board
In pos-table-board.tsx — orders with Source == GuestQr should show a badge:
{order.source === "GuestQr" && (
<span style={{ fontSize: 11, background: "#FEF3C7",
color: "#92400E", padding: "2px 8px",
borderRadius: 10, fontWeight: 600 }}>
QR سفارش مهمان
</span>
)}
In OrderDto, add: public string Source { get; set; } (mapped from OrderSource enum).
STEP 7 — Tests
Add to tests/Meezi.API.Tests/QrMenuTests.cs:
✓ ResolveQr_ValidCode_ReturnsBranchInfo
✓ ResolveQr_InvalidCode_ReturnsNotFound
✓ GetBranchMenu_ExcludesUnavailableItems
✓ GetBranchMenu_AppliesBranchPriceOverride
✓ PlaceGuestOrder_ValidItems_CreatesOrder
✓ PlaceGuestOrder_MergesWithExistingOpenOrder
✓ PlaceGuestOrder_CleaningTable_ReturnsTableCleaning
✓ PlaceGuestOrder_InvalidMenuItemForBranch_ReturnsInvalidMenuItems
PR-2 — Parent Branch Tax Inheritance
The rule (finalized)
Cafe (owner) defines:
- DefaultTaxRate → applies to ALL branches unless overridden
- AllowBranchTaxOverride (bool) — if false, branches CANNOT change their own tax
Branch can ONLY change tax if parent AllowBranchTaxOverride == true
Otherwise: branch tax is ALWAYS the cafe's DefaultTaxRate, read-only
STEP 1 — Add to CafeSettings
// src/Meezi.Core/Entities/CafeSettings.cs — add:
public decimal DefaultTaxRate { get; set; } = 0m;
public bool AllowBranchTaxOverride { get; set; } = false;
STEP 2 — Update EffectiveSettingsService
public async Task<BranchEffectiveSettingsDto> GetEffectiveSettingsAsync(
Guid cafeId, Guid branchId, CancellationToken ct)
{
var cafeSettings = await _db.CafeSettings
.FirstOrDefaultAsync(s => s.CafeId == cafeId, ct);
var branchSettings = await _db.BranchSettings
.FirstOrDefaultAsync(s => s.BranchId == branchId, ct);
// Tax: only use branch override if parent explicitly allows it
var allowTaxOverride = cafeSettings?.AllowBranchTaxOverride ?? false;
var effectiveTaxRate = (allowTaxOverride && branchSettings?.TaxRate != null)
? branchSettings.TaxRate.Value
: (cafeSettings?.DefaultTaxRate ?? 0m);
return new BranchEffectiveSettingsDto
{
TaxRate = effectiveTaxRate,
TaxRateIsOverridden = allowTaxOverride && branchSettings?.TaxRate != null,
TaxRateLocked = !allowTaxOverride, // UI shows lock icon if true
// ... other settings
};
}
STEP 3 — Guard in BranchSettings PATCH endpoint
// In BranchSettingsController.PatchAsync — before saving tax rate:
if (request.TaxRate.HasValue)
{
var cafeSettings = await _db.CafeSettings
.FirstOrDefaultAsync(s => s.CafeId == _tenant.CafeId, ct);
if (cafeSettings?.AllowBranchTaxOverride != true)
return BadRequest(ApiResponse.Fail("TAX_OVERRIDE_NOT_ALLOWED",
"تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است"));
}
STEP 4 — Dashboard: Tax settings UI
In cafe-level settings (owner only):
مالیات پیشفرض کل کافه: [ 9 ] %
☐ اجازه تغییر نرخ مالیات به مدیران شعبه
In branch settings (manager view):
نرخ مالیات: 9% 🔒 (تعریف شده توسط مالک)
OR if AllowBranchTaxOverride == true:
نرخ مالیات: [ 12 ] % ✏️ (قابل تغییر)
Migration
dotnet ef migrations add TaxInheritanceControl \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API
PR-3 — Visual Identity Per Branch
What the parent defines for children
Cafe (parent) defines the BRAND:
- CafeName, LogoUrl
- PrimaryColor (hex) ← all branches inherit this
- SecondaryColor (hex)
- FontFamily (string) ← optional: "Vazir", "IRANSans", "default"
- FaviconUrl
Branch CAN override for its location:
- BranchLogoUrl ← e.g. branch-specific photo
- AccentColor ← slight variation
- WelcomeText ← "خوش آمدید به شعبه نیاوران"
- WifiPassword ← shown on QR menu and receipts
Branch CANNOT override:
- CafeName (always the brand name)
- PrimaryColor (brand consistency)
- FontFamily (brand consistency)
STEP 1 — CafeIdentity entity (new)
// src/Meezi.Core/Entities/CafeIdentity.cs
public class CafeIdentity
{
public Guid Id { get; set; }
public Guid CafeId { get; set; }
public string PrimaryColor { get; set; } = "#C47B2B"; // warm coffee default
public string SecondaryColor { get; set; } = "#F5F0E8"; // light cream
public string? FontFamily { get; set; }
public string? LogoUrl { get; set; }
public string? FaviconUrl { get; set; }
public string? IconName { get; set; } // Tabler icon name e.g. "coffee"
public DateTime UpdatedAt { get; set; }
}
STEP 2 — BranchIdentity entity (new)
// src/Meezi.Core/Entities/BranchIdentity.cs
public class BranchIdentity
{
public Guid Id { get; set; }
public Guid BranchId { get; set; }
public Guid CafeId { get; set; }
// Branch-level overrides (nulls = use CafeIdentity value)
public string? LogoUrl { get; set; } // branch-specific photo
public string? AccentColor { get; set; } // slight color variation
public string? WelcomeText { get; set; } // greeting on QR menu
public string? WifiPassword { get; set; }
public string? Address { get; set; } // shown on QR menu footer
}
STEP 3 — Resolve effective identity
// In EffectiveSettingsService or new IdentityService:
public async Task<BranchEffectiveIdentityDto> GetEffectiveIdentityAsync(
Guid cafeId, Guid branchId, CancellationToken ct)
{
var cafe = await _db.CafeIdentities
.FirstOrDefaultAsync(i => i.CafeId == cafeId, ct);
var branch = await _db.BranchIdentities
.FirstOrDefaultAsync(i => i.BranchId == branchId, ct);
return new BranchEffectiveIdentityDto
{
PrimaryColor = cafe?.PrimaryColor ?? "#C47B2B", // NEVER branch override
SecondaryColor = cafe?.SecondaryColor ?? "#F5F0E8",
FontFamily = cafe?.FontFamily,
LogoUrl = branch?.LogoUrl ?? cafe?.LogoUrl, // branch photo first
IconName = cafe?.IconName ?? "coffee",
WelcomeText = branch?.WelcomeText ?? "خوش آمدید",
WifiPassword = branch?.WifiPassword,
Address = branch?.Address,
};
}
STEP 4 — Endpoints
// Cafe identity (owner only):
GET /api/cafes/{cafeId}/identity
PUT /api/cafes/{cafeId}/identity
Body: { primaryColor, secondaryColor, fontFamily, iconName, logoUrl, faviconUrl }
// Branch identity (owner or branch manager):
GET /api/cafes/{cafeId}/branches/{branchId}/identity
PUT /api/cafes/{cafeId}/branches/{branchId}/identity
Body: { logoUrl, welcomeText, wifiPassword, address }
// Note: primaryColor NOT in body — branch cannot change it
// Public (used by QR menu):
GET /api/public/{cafeId}/branches/{branchId}/identity
→ Returns BranchEffectiveIdentityDto
STEP 5 — Dashboard: Visual Identity Settings Page
File: web/dashboard/src/components/settings/identity-settings.tsx (new)
Owner view (cafe-level):
Brand Color: [■ #C47B2B] color picker
Secondary Color: [■ #F5F0E8]
Logo: [upload]
Icon: icon picker (grid of Tabler icon names: coffee, cup, utensils, store, ...)
Font: dropdown [پیشفرض | Vazir | IRANSans]
Preview: shows QR menu header with current settings
Branch manager view:
رنگ برند: ■ #C47B2B 🔒 (تعریف شده توسط مالک — قابل تغییر نیست)
لوگو شعبه: [upload] ← branch-specific photo
متن خوشآمدگویی: [___________]
رمز WiFi: [___________]
آدرس: [___________]
STEP 6 — Apply identity to QR menu
Update the QR resolver endpoint (STEP 1 of PR-1) to also call GetEffectiveIdentityAsync
and include the color/logo in QrResolveDto. The guest menu page already uses
branch.primaryColor for all buttons and accents — it will automatically
reflect the brand color.
Migration
dotnet ef migrations add BranchVisualIdentity \
--project src/Meezi.Infrastructure \
--startup-project src/Meezi.API
Execution Order
PR-1 feature/qr-guest-menu ← fix the broken flow FIRST (2 day)
PR-2 feature/tax-inheritance ← simple, 4 hours
PR-3 feature/visual-identity ← after PR-1 (QR menu uses the colors), 1 day
New Error Codes
| Code | Meaning |
|---|---|
INVALID_MENU_ITEMS |
Guest submitted item not in branch menu |
TAX_OVERRIDE_NOT_ALLOWED |
Branch tried to set tax but parent locked it |
BRANCH_IDENTITY_NOT_FOUND |
Identity record missing (auto-create on first save) |
New i18n Strings
// fa.json additions:
"qrMenu": {
"welcome": "خوش آمدید",
"tableLabel": "میز",
"addToCart": "افزودن",
"viewCart": "مشاهده سبد خرید",
"placeOrder": "ثبت سفارش",
"orderPlaced": "سفارش ثبت شد!",
"orderNumber": "شماره سفارش",
"guestName": "نام شما (اختیاری)",
"guestPhone": "شماره موبایل (اختیاری)",
"addMoreItems": "افزودن آیتم دیگر",
"tableNotFound": "میز یافت نشد",
"loadError": "خطا در بارگذاری",
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
"subtotal": "جمع کل",
"guestQrBadge": "سفارش QR مهمان"
},
"identity": {
"brandColor": "رنگ برند",
"secondaryColor": "رنگ ثانویه",
"logo": "لوگو",
"icon": "آیکون",
"font": "فونت",
"branchLogo": "لوگو شعبه",
"welcomeText": "متن خوشآمدگویی",
"wifiPassword": "رمز WiFi",
"lockedByOwner": "تعریف شده توسط مالک — قابل تغییر نیست",
"preview": "پیشنمایش"
},
"tax": {
"defaultTaxRate": "نرخ مالیات پیشفرض",
"allowBranchOverride": "اجازه تغییر نرخ مالیات به مدیران شعبه",
"lockedByOwner": "نرخ مالیات توسط مالک قفل شده است",
"overrideNotAllowed": "تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است"
}
Start with PR-1 — the QR menu fix. The other two are independent and can run in parallel after PR-1 merges.