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.*
|