Files
meezi/docs/MEEZI_QR_MENU_PLAN.md
T
soroush.asadi 03376b3ea1 feat(docker): multi-stage Dockerfiles with npmmirror registry
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>
2026-05-27 21:33:29 +03:30

1033 lines
36 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```csharp
[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):
```csharp
/// <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
```csharp
/// <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:**
```csharp
// 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
```bash
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.
```tsx
"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:
```tsx
{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
```csharp
// src/Meezi.Core/Entities/CafeSettings.cs — add:
public decimal DefaultTaxRate { get; set; } = 0m;
public bool AllowBranchTaxOverride { get; set; } = false;
```
---
### STEP 2 — Update EffectiveSettingsService
```csharp
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
```csharp
// 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
```bash
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)
```csharp
// 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)
```csharp
// 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
```csharp
// 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
```bash
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
```json
// 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.*