03376b3ea1
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>
1033 lines
36 KiB
Markdown
1033 lines
36 KiB
Markdown
# 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.*
|