Files
meezi/docs/MEEZI_QR_MENU_PLAN.md
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

36 KiB
Raw Permalink Blame History

Meezi — QR Guest Menu + Branch Visual Identity + Tax Inheritance

Copy-paste into Cursor. Based on the bug at /q/demo_table_01 and the branching decisions. Three self-contained PRs. Start with PR-1 (the broken QR flow).


What's broken and what's new

Area Status Work
/q/[code] → shows menu Broken Full fix in PR-1
Guest can browse menu by category Missing PR-1
Guest can place order from QR Missing PR-1
Order appears in dashboard panel Missing PR-1
Branch has own menu (from parent catalog) ⚠️ Partial PR-1 uses BranchMenuItemOverride
Parent defines child branch tax rates Missing PR-2
Parent defines visual identity (colors, icon) Missing PR-3

PR-1 — Fix QR Guest Menu: Full Flow

The flow (end to end)

Guest scans QR on table
  ↓
/q/demo_table_01  (Next.js public page)
  ↓
GET /api/q/demo_table_01
  → resolves table → branch → cafe
  → returns: { cafeId, branchId, tableId, tableName, branchName, cafeName }
  ↓
Guest sees: branch name, menu categories, items with prices
  ↓
Guest picks items → taps "سفارش" (Order)
  ↓
POST /api/public/{cafeId}/branches/{branchId}/orders
  Body: { tableId, guestName?, guestPhone?, items: [{menuItemId, qty, note?}] }
  ↓
Order created → appears in dashboard POS board immediately (SignalR)
  ↓
Guest sees: "سفارش شما ثبت شد ✓" confirmation screen

STEP 1 — Fix the public QR resolver endpoint

File: src/Meezi.API/Controllers/QrController.cs

The current endpoint likely returns minimal data. Expand it:

[AllowAnonymous]
[HttpGet("/api/q/{qrCode}")]
public async Task<IActionResult> ResolveQr(string qrCode, CancellationToken ct)
{
    // Find table by QrCode field
    var table = await _db.Tables
        .Include(t => t.Branch)
            .ThenInclude(b => b.Cafe)
        .Include(t => t.Branch)
            .ThenInclude(b => b.BranchSettings)
        .FirstOrDefaultAsync(t => t.QrCode == qrCode && t.IsActive, ct);

    if (table is null)
        return NotFound(ApiError.Create("TABLE_NOT_FOUND"));

    // Load branch visual identity (colors, icon) — PR-3 adds this
    var identity = table.Branch.VisualIdentity; // may be null until PR-3

    return Ok(new QrResolveDto
    {
        TableId       = table.Id,
        TableName     = table.Name,
        BranchId      = table.BranchId,
        BranchName    = table.Branch.Name,
        CafeId        = table.Branch.CafeId,
        CafeName      = table.Branch.Cafe.Name,
        // Visual identity — fallback to cafe defaults if branch hasn't set own
        PrimaryColor  = identity?.PrimaryColor ?? "#C47B2B",  // warm coffee default
        LogoUrl       = identity?.LogoUrl ?? table.Branch.Cafe.LogoUrl,
        WelcomeText   = table.Branch.BranchSettings?.WelcomeText
                        ?? table.Branch.Cafe.WelcomeText
                        ?? "خوش آمدید",
    });
}

STEP 2 — Public branch menu endpoint

File: src/Meezi.API/Controllers/PublicController.cs

Add (no auth — [AllowAnonymous] already on controller):

/// <summary>Returns the effective menu for a branch, visible to guests.</summary>
[HttpGet("/api/public/{cafeId}/branches/{branchId}/menu")]
public async Task<IActionResult> GetBranchMenu(
    Guid cafeId, Guid branchId, CancellationToken ct)
{
    // Validate branch belongs to cafe
    var branch = await _db.Branches
        .FirstOrDefaultAsync(b => b.Id == branchId && b.CafeId == cafeId && b.IsActive, ct);

    if (branch is null)
        return NotFound(ApiError.Create("BRANCH_NOT_FOUND"));

    // Load all active menu items for this cafe
    var items = await _db.MenuItems
        .Include(m => m.Category)
        .Where(m => m.CafeId == cafeId && m.IsActive)
        .ToListAsync(ct);

    // Load branch overrides
    var overrides = await _db.BranchMenuItemOverrides
        .Where(o => o.BranchId == branchId && o.CafeId == cafeId)
        .ToDictionaryAsync(o => o.MenuItemId, ct);

    // Resolve effective menu — exclude unavailable items
    var resolved = items
        .Where(item => !overrides.TryGetValue(item.Id, out var ov) || ov.IsAvailable)
        .Select(item =>
        {
            overrides.TryGetValue(item.Id, out var ov);
            return new PublicMenuItemDto
            {
                Id            = item.Id,
                CategoryId    = item.CategoryId,
                CategoryName  = item.Category.Name,
                CategorySort  = item.Category.SortOrder,
                Name          = item.Name,
                Description   = item.Description,
                ImageUrl      = item.ImageUrl,
                EffectivePrice = ov?.PriceOverride ?? item.BasePrice,
                Tags          = item.Tags,
                IsAvailable   = true,
                SortOrder     = ov?.SortOrderOverride ?? item.SortOrder,
            };
        })
        .OrderBy(i => i.CategorySort)
        .ThenBy(i => i.SortOrder)
        .GroupBy(i => new { i.CategoryId, i.CategoryName, i.CategorySort })
        .Select(g => new PublicMenuCategoryDto
        {
            Id    = g.Key.CategoryId,
            Name  = g.Key.CategoryName,
            Sort  = g.Key.CategorySort,
            Items = g.ToList(),
        })
        .ToList();

    return Ok(ApiResponse<List<PublicMenuCategoryDto>>.Ok(resolved));
}

STEP 3 — Public order placement endpoint

/// <summary>Guest places an order from QR menu.</summary>
[HttpPost("/api/public/{cafeId}/branches/{branchId}/orders")]
public async Task<IActionResult> PlaceGuestOrder(
    Guid cafeId, Guid branchId,
    [FromBody] PlaceGuestOrderRequest request,
    CancellationToken ct)
{
    // Validate table belongs to this branch
    var table = await _db.Tables
        .FirstOrDefaultAsync(t => t.Id == request.TableId
            && t.BranchId == branchId
            && t.CafeId == cafeId
            && t.IsActive, ct);

    if (table is null)
        return BadRequest(ApiError.Create("TABLE_NOT_FOUND"));

    if (table.IsCleaning)
        return BadRequest(ApiError.Create("TABLE_CLEANING"));

    // Validate items exist and are available at this branch
    var itemIds = request.Items.Select(i => i.MenuItemId).ToList();
    var menuItems = await _db.MenuItems
        .Where(m => itemIds.Contains(m.Id) && m.CafeId == cafeId && m.IsActive)
        .ToListAsync(ct);

    if (menuItems.Count != itemIds.Distinct().Count())
        return BadRequest(ApiError.Create("INVALID_MENU_ITEMS"));

    // Load price overrides
    var overrides = await _db.BranchMenuItemOverrides
        .Where(o => o.BranchId == branchId && itemIds.Contains(o.MenuItemId))
        .ToDictionaryAsync(o => o.MenuItemId, ct);

    // Find or create open order for this table (merge logic — same as POS)
    var existingOrder = await _db.Orders
        .Include(o => o.Items)
        .FirstOrDefaultAsync(o => o.TableId == table.Id
            && o.CafeId == cafeId
            && o.Status == OrderStatus.Open, ct);

    var order = existingOrder ?? new Order
    {
        Id          = Guid.NewGuid(),
        CafeId      = cafeId,
        BranchId    = branchId,
        TableId     = table.Id,
        Status      = OrderStatus.Open,
        Source      = OrderSource.GuestQr,   // new enum value
        GuestName   = request.GuestName,
        GuestPhone  = request.GuestPhone,
        CreatedAt   = DateTime.UtcNow,
    };

    // Append items
    foreach (var line in request.Items)
    {
        var menuItem = menuItems.First(m => m.Id == line.MenuItemId);
        overrides.TryGetValue(line.MenuItemId, out var ov);
        var price = ov?.PriceOverride ?? menuItem.BasePrice;

        order.Items.Add(new OrderItem
        {
            Id           = Guid.NewGuid(),
            MenuItemId   = line.MenuItemId,
            ProductName  = menuItem.Name,
            Quantity     = line.Quantity,
            UnitPrice    = price,
            Notes        = line.Notes,
            Source       = ItemSource.GuestQr,
        });
    }

    order.TotalAmount = order.Items
        .Where(i => !i.IsVoided)
        .Sum(i => i.UnitPrice * i.Quantity);

    if (existingOrder is null)
        _db.Orders.Add(order);

    await _db.SaveChangesAsync(ct);

    // Notify dashboard via SignalR
    await _boardNotifier.OrderUpdatedAsync(cafeId, order.Id, order.TableId);

    return Ok(ApiResponse<GuestOrderConfirmDto>.Ok(new GuestOrderConfirmDto
    {
        OrderId     = order.Id,
        OrderNumber = order.OrderNumber,
        TotalAmount = order.TotalAmount,
        ItemCount   = request.Items.Sum(i => i.Quantity),
    }));
}

DTOs to add:

// PlaceGuestOrderRequest.cs
public record PlaceGuestOrderRequest(
    Guid TableId,
    string? GuestName,
    string? GuestPhone,
    List<GuestOrderLine> Items
);

public record GuestOrderLine(Guid MenuItemId, int Quantity, string? Notes);

// GuestOrderConfirmDto.cs
public record GuestOrderConfirmDto(
    Guid OrderId, int OrderNumber, decimal TotalAmount, int ItemCount
);

// Add to Order entity:
public OrderSource Source { get; set; } = OrderSource.Pos;

// OrderSource enum:
public enum OrderSource { Pos, GuestQr, Kiosk, SnappFood }

// Add to OrderItem:
public ItemSource Source { get; set; } = ItemSource.Pos;
public enum ItemSource { Pos, GuestQr }

STEP 4 — EF migration

dotnet ef migrations add GuestQrOrderSource \
  --project src/Meezi.Infrastructure \
  --startup-project src/Meezi.API \
  --output-dir Data/Migrations

STEP 5 — Fix web/dashboard/src/app/q/[code]/page.tsx

Full rewrite of the guest-facing QR page. This is a public page — no auth, no dashboard layout.

"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";

type BranchInfo = {
  tableId: string; tableName: string;
  branchId: string; branchName: string;
  cafeId: string; cafeName: string;
  primaryColor: string; logoUrl?: string; welcomeText: string;
};

type MenuItem = {
  id: string; name: string; description?: string;
  imageUrl?: string; effectivePrice: number; tags?: string[];
};

type Category = { id: string; name: string; items: MenuItem[] };

type CartItem = { item: MenuItem; qty: number; note?: string };

type Screen = "loading" | "error" | "menu" | "cart" | "confirm" | "success";

export default function QrMenuPage() {
  const { code } = useParams<{ code: string }>();

  const [screen, setScreen] = useState<Screen>("loading");
  const [error, setError] = useState<string>("");
  const [branch, setBranch] = useState<BranchInfo | null>(null);
  const [categories, setCategories] = useState<Category[]>([]);
  const [activeCategory, setActiveCategory] = useState<string>("");
  const [cart, setCart] = useState<CartItem[]>([]);
  const [guestName, setGuestName] = useState("");
  const [guestPhone, setGuestPhone] = useState("");
  const [orderId, setOrderId] = useState<string>("");
  const [orderNumber, setOrderNumber] = useState<number>(0);

  const apiBase = process.env.NEXT_PUBLIC_API_URL ?? "";

  // Step 1: resolve QR code → branch info
  useEffect(() => {
    fetch(`${apiBase}/api/q/${code}`)
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(data => {
        setBranch(data);
        // Step 2: load menu
        return fetch(
          `${apiBase}/api/public/${data.cafeId}/branches/${data.branchId}/menu`
        );
      })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(res => {
        setCategories(res.data ?? []);
        setActiveCategory(res.data?.[0]?.id ?? "");
        setScreen("menu");
      })
      .catch(() => {
        setError("میز یافت نشد یا منو در دسترس نیست");
        setScreen("error");
      });
  }, [code]);

  const totalItems = cart.reduce((s, c) => s + c.qty, 0);
  const totalPrice = cart.reduce((s, c) => s + c.item.effectivePrice * c.qty, 0);

  function addToCart(item: MenuItem) {
    setCart(prev => {
      const idx = prev.findIndex(c => c.item.id === item.id);
      if (idx >= 0) {
        const next = [...prev];
        next[idx] = { ...next[idx], qty: next[idx].qty + 1 };
        return next;
      }
      return [...prev, { item, qty: 1 }];
    });
  }

  function removeFromCart(itemId: string) {
    setCart(prev => {
      const idx = prev.findIndex(c => c.item.id === itemId);
      if (idx < 0) return prev;
      const next = [...prev];
      if (next[idx].qty > 1) {
        next[idx] = { ...next[idx], qty: next[idx].qty - 1 };
      } else {
        next.splice(idx, 1);
      }
      return next;
    });
  }

  async function submitOrder() {
    if (!branch || cart.length === 0) return;
    setScreen("loading");
    try {
      const res = await fetch(
        `${apiBase}/api/public/${branch.cafeId}/branches/${branch.branchId}/orders`,
        {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            tableId: branch.tableId,
            guestName: guestName || null,
            guestPhone: guestPhone || null,
            items: cart.map(c => ({
              menuItemId: c.item.id,
              quantity: c.qty,
              notes: c.note ?? null,
            })),
          }),
        }
      );
      if (!res.ok) throw new Error();
      const data = await res.json();
      setOrderId(data.data.orderId);
      setOrderNumber(data.data.orderNumber);
      setScreen("success");
    } catch {
      setError("خطا در ثبت سفارش. دوباره امتحان کنید");
      setScreen("cart");
    }
  }

  const primary = branch?.primaryColor ?? "#C47B2B";

  // ── Screens ──────────────────────────────────────────────────────────────

  if (screen === "loading") return (
    <div style={{ display: "flex", justifyContent: "center", alignItems: "center",
                  height: "100svh", flexDirection: "column", gap: 12 }}>
      <div style={{ width: 40, height: 40, borderRadius: "50%",
                    border: `3px solid ${primary}`,
                    borderTopColor: "transparent", animation: "spin 0.8s linear infinite" }}/>
      <style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
      <p style={{ color: "#888", fontSize: 14 }}>در حال بارگذاری...</p>
    </div>
  );

  if (screen === "error") return (
    <div style={{ textAlign: "center", padding: "4rem 2rem" }}>
      <p style={{ fontSize: 48 }}>😕</p>
      <p style={{ fontWeight: 500, marginBottom: 8 }}>{error}</p>
      <p style={{ color: "#888", fontSize: 14 }}>لطفاً دوباره کد QR را اسکن کنید</p>
    </div>
  );

  if (screen === "success") return (
    <div style={{ textAlign: "center", padding: "4rem 2rem" }}>
      <div style={{ fontSize: 64, marginBottom: 16 }}></div>
      <h2 style={{ fontWeight: 600, fontSize: 22, marginBottom: 8 }}>سفارش ثبت شد!</h2>
      <p style={{ color: "#666", marginBottom: 4 }}>شماره سفارش: #{orderNumber}</p>
      <p style={{ color: "#888", fontSize: 13 }}>کارکنان به زودی سفارش شما را آماده می‌کنند</p>
      <button
        onClick={() => { setCart([]); setScreen("menu"); }}
        style={{ marginTop: 32, padding: "12px 32px", borderRadius: 12,
                 background: primary, color: "#fff", border: "none",
                 fontSize: 15, fontWeight: 600, cursor: "pointer" }}
      >
        افزودن آیتم دیگر
      </button>
    </div>
  );

  if (screen === "cart") return (
    <div style={{ direction: "rtl", fontFamily: "system-ui, sans-serif",
                  maxWidth: 480, margin: "0 auto", padding: "1rem" }}>
      {/* Header */}
      <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 24 }}>
        <button onClick={() => setScreen("menu")}
                style={{ background: "none", border: "none", cursor: "pointer",
                         fontSize: 22, padding: 0 }}></button>
        <h2 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>سبد خرید</h2>
      </div>

      {/* Cart items */}
      {cart.map(c => (
        <div key={c.item.id} style={{ display: "flex", justifyContent: "space-between",
                                       alignItems: "center", padding: "12px 0",
                                       borderBottom: "1px solid #eee" }}>
          <div>
            <p style={{ margin: 0, fontWeight: 500 }}>{c.item.name}</p>
            <p style={{ margin: 0, fontSize: 13, color: "#888" }}>
              {c.item.effectivePrice.toLocaleString("fa-IR")} تومان
            </p>
          </div>
          <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
            <button onClick={() => removeFromCart(c.item.id)}
                    style={{ width: 32, height: 32, borderRadius: "50%",
                             border: `1.5px solid ${primary}`, background: "none",
                             cursor: "pointer", fontSize: 20, color: primary,
                             display: "flex", alignItems: "center", justifyContent: "center" }}>
              
            </button>
            <span style={{ fontWeight: 600, minWidth: 20, textAlign: "center" }}>{c.qty}</span>
            <button onClick={() => addToCart(c.item)}
                    style={{ width: 32, height: 32, borderRadius: "50%",
                             background: primary, border: "none",
                             cursor: "pointer", fontSize: 20, color: "#fff",
                             display: "flex", alignItems: "center", justifyContent: "center" }}>
              +
            </button>
          </div>
        </div>
      ))}

      {/* Guest info */}
      <div style={{ marginTop: 24 }}>
        <input value={guestName} onChange={e => setGuestName(e.target.value)}
               placeholder="نام شما (اختیاری)"
               style={{ width: "100%", padding: "10px 14px", borderRadius: 10,
                        border: "1px solid #ddd", fontSize: 15, marginBottom: 10,
                        boxSizing: "border-box", textAlign: "right" }} />
        <input value={guestPhone} onChange={e => setGuestPhone(e.target.value)}
               placeholder="شماره موبایل (اختیاری)"
               inputMode="tel"
               style={{ width: "100%", padding: "10px 14px", borderRadius: 10,
                        border: "1px solid #ddd", fontSize: 15,
                        boxSizing: "border-box", textAlign: "right" }} />
      </div>

      {/* Total + submit */}
      <div style={{ marginTop: 24, padding: 16, background: "#f9f9f9",
                    borderRadius: 12 }}>
        <div style={{ display: "flex", justifyContent: "space-between",
                      marginBottom: 16 }}>
          <span style={{ fontWeight: 600 }}>جمع کل:</span>
          <span style={{ fontWeight: 700, color: primary }}>
            {totalPrice.toLocaleString("fa-IR")} تومان
          </span>
        </div>
        <button onClick={submitOrder}
                style={{ width: "100%", padding: "14px", borderRadius: 12,
                         background: primary, color: "#fff", border: "none",
                         fontSize: 16, fontWeight: 700, cursor: "pointer" }}>
          ثبت سفارش
        </button>
      </div>
    </div>
  );

  // screen === "menu"
  return (
    <div style={{ direction: "rtl", fontFamily: "system-ui, sans-serif",
                  maxWidth: 480, margin: "0 auto", paddingBottom: 100 }}>

      {/* Branch header */}
      <div style={{ padding: "20px 16px 12px", borderBottom: "1px solid #eee",
                    textAlign: "center" }}>
        {branch?.logoUrl && (
          <img src={branch.logoUrl} alt={branch.cafeName}
               style={{ width: 56, height: 56, borderRadius: "50%",
                        objectFit: "cover", marginBottom: 8 }} />
        )}
        <h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{branch?.cafeName}</h1>
        <p style={{ color: "#888", fontSize: 14, margin: "4px 0 0" }}>{branch?.branchName}</p>
        <p style={{ fontSize: 13, color: "#aaa", margin: "4px 0 0" }}>
          {branch?.welcomeText}  میز {branch?.tableName}
        </p>
      </div>

      {/* Category tabs */}
      <div style={{ display: "flex", overflowX: "auto", padding: "12px 16px",
                    gap: 8, borderBottom: "1px solid #eee",
                    scrollbarWidth: "none", position: "sticky", top: 0,
                    background: "#fff", zIndex: 10 }}>
        {categories.map(cat => (
          <button key={cat.id}
                  onClick={() => setActiveCategory(cat.id)}
                  style={{ flexShrink: 0, padding: "6px 16px", borderRadius: 20,
                           border: activeCategory === cat.id
                             ? `1.5px solid ${primary}` : "1.5px solid #ddd",
                           background: activeCategory === cat.id ? primary : "transparent",
                           color: activeCategory === cat.id ? "#fff" : "#444",
                           fontSize: 13, fontWeight: 500, cursor: "pointer",
                           whiteSpace: "nowrap" }}>
            {cat.name}
          </button>
        ))}
      </div>

      {/* Menu items */}
      <div style={{ padding: "12px 16px" }}>
        {categories
          .filter(cat => cat.id === activeCategory)
          .flatMap(cat => cat.items)
          .map(item => {
            const inCart = cart.find(c => c.item.id === item.id);
            return (
              <div key={item.id}
                   style={{ display: "flex", gap: 12, padding: "14px 0",
                            borderBottom: "1px solid #f0f0f0" }}>
                {item.imageUrl && (
                  <img src={item.imageUrl} alt={item.name}
                       style={{ width: 72, height: 72, borderRadius: 10,
                                objectFit: "cover", flexShrink: 0 }} />
                )}
                <div style={{ flex: 1 }}>
                  <p style={{ margin: "0 0 4px", fontWeight: 600, fontSize: 15 }}>
                    {item.name}
                  </p>
                  {item.description && (
                    <p style={{ margin: "0 0 8px", fontSize: 12, color: "#888",
                                lineHeight: 1.5 }}>
                      {item.description}
                    </p>
                  )}
                  <div style={{ display: "flex", justifyContent: "space-between",
                                alignItems: "center" }}>
                    <span style={{ fontWeight: 700, color: primary, fontSize: 15 }}>
                      {item.effectivePrice.toLocaleString("fa-IR")} تومان
                    </span>
                    {inCart ? (
                      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
                        <button onClick={() => removeFromCart(item.id)}
                                style={{ width: 28, height: 28, borderRadius: "50%",
                                         border: `1.5px solid ${primary}`, background: "none",
                                         cursor: "pointer", color: primary, fontSize: 18,
                                         display: "flex", alignItems: "center",
                                         justifyContent: "center" }}></button>
                        <span style={{ fontWeight: 700 }}>{inCart.qty}</span>
                        <button onClick={() => addToCart(item)}
                                style={{ width: 28, height: 28, borderRadius: "50%",
                                         background: primary, border: "none",
                                         cursor: "pointer", color: "#fff", fontSize: 18,
                                         display: "flex", alignItems: "center",
                                         justifyContent: "center" }}>+</button>
                      </div>
                    ) : (
                      <button onClick={() => addToCart(item)}
                              style={{ padding: "6px 16px", borderRadius: 20,
                                       background: primary, color: "#fff", border: "none",
                                       cursor: "pointer", fontSize: 13, fontWeight: 600 }}>
                        افزودن
                      </button>
                    )}
                  </div>
                </div>
              </div>
            );
          })}
      </div>

      {/* Floating cart bar */}
      {totalItems > 0 && (
        <div style={{ position: "fixed", bottom: 16, right: 16, left: 16,
                      maxWidth: 448, margin: "0 auto" }}>
          <button onClick={() => setScreen("cart")}
                  style={{ width: "100%", padding: "14px 20px", borderRadius: 16,
                           background: primary, color: "#fff", border: "none",
                           fontSize: 15, fontWeight: 700, cursor: "pointer",
                           display: "flex", justifyContent: "space-between",
                           alignItems: "center", boxShadow: "0 4px 24px rgba(0,0,0,0.18)" }}>
            <span style={{ background: "rgba(255,255,255,0.25)",
                           borderRadius: "50%", width: 28, height: 28,
                           display: "flex", alignItems: "center",
                           justifyContent: "center", fontWeight: 700 }}>
              {totalItems}
            </span>
            <span>مشاهده سبد خرید</span>
            <span>{totalPrice.toLocaleString("fa-IR")} تومان</span>
          </button>
        </div>
      )}
    </div>
  );
}

STEP 6 — Dashboard: show guest QR orders in POS board

In pos-table-board.tsx — orders with Source == GuestQr should show a badge:

{order.source === "GuestQr" && (
  <span style={{ fontSize: 11, background: "#FEF3C7",
                 color: "#92400E", padding: "2px 8px",
                 borderRadius: 10, fontWeight: 600 }}>
    QR سفارش مهمان
  </span>
)}

In OrderDto, add: public string Source { get; set; } (mapped from OrderSource enum).


STEP 7 — Tests

Add to tests/Meezi.API.Tests/QrMenuTests.cs:

✓ ResolveQr_ValidCode_ReturnsBranchInfo
✓ ResolveQr_InvalidCode_ReturnsNotFound
✓ GetBranchMenu_ExcludesUnavailableItems
✓ GetBranchMenu_AppliesBranchPriceOverride
✓ PlaceGuestOrder_ValidItems_CreatesOrder
✓ PlaceGuestOrder_MergesWithExistingOpenOrder
✓ PlaceGuestOrder_CleaningTable_ReturnsTableCleaning
✓ PlaceGuestOrder_InvalidMenuItemForBranch_ReturnsInvalidMenuItems

PR-2 — Parent Branch Tax Inheritance

The rule (finalized)

Cafe (owner) defines:
  - DefaultTaxRate   → applies to ALL branches unless overridden
  - AllowBranchTaxOverride (bool) — if false, branches CANNOT change their own tax

Branch can ONLY change tax if parent AllowBranchTaxOverride == true
Otherwise: branch tax is ALWAYS the cafe's DefaultTaxRate, read-only

STEP 1 — Add to CafeSettings

// src/Meezi.Core/Entities/CafeSettings.cs — add:
public decimal DefaultTaxRate { get; set; } = 0m;
public bool AllowBranchTaxOverride { get; set; } = false;

STEP 2 — Update EffectiveSettingsService

public async Task<BranchEffectiveSettingsDto> GetEffectiveSettingsAsync(
    Guid cafeId, Guid branchId, CancellationToken ct)
{
    var cafeSettings = await _db.CafeSettings
        .FirstOrDefaultAsync(s => s.CafeId == cafeId, ct);

    var branchSettings = await _db.BranchSettings
        .FirstOrDefaultAsync(s => s.BranchId == branchId, ct);

    // Tax: only use branch override if parent explicitly allows it
    var allowTaxOverride = cafeSettings?.AllowBranchTaxOverride ?? false;
    var effectiveTaxRate = (allowTaxOverride && branchSettings?.TaxRate != null)
        ? branchSettings.TaxRate.Value
        : (cafeSettings?.DefaultTaxRate ?? 0m);

    return new BranchEffectiveSettingsDto
    {
        TaxRate              = effectiveTaxRate,
        TaxRateIsOverridden  = allowTaxOverride && branchSettings?.TaxRate != null,
        TaxRateLocked        = !allowTaxOverride,  // UI shows lock icon if true
        // ... other settings
    };
}

STEP 3 — Guard in BranchSettings PATCH endpoint

// In BranchSettingsController.PatchAsync — before saving tax rate:
if (request.TaxRate.HasValue)
{
    var cafeSettings = await _db.CafeSettings
        .FirstOrDefaultAsync(s => s.CafeId == _tenant.CafeId, ct);

    if (cafeSettings?.AllowBranchTaxOverride != true)
        return BadRequest(ApiResponse.Fail("TAX_OVERRIDE_NOT_ALLOWED",
            "تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است"));
}

STEP 4 — Dashboard: Tax settings UI

In cafe-level settings (owner only):

مالیات پیش‌فرض کل کافه:  [  9  ] %
☐ اجازه تغییر نرخ مالیات به مدیران شعبه

In branch settings (manager view):

نرخ مالیات:  9%  🔒 (تعریف شده توسط مالک)

OR if AllowBranchTaxOverride == true:

نرخ مالیات:  [ 12 ] %  ✏️ (قابل تغییر)

Migration

dotnet ef migrations add TaxInheritanceControl \
  --project src/Meezi.Infrastructure \
  --startup-project src/Meezi.API

PR-3 — Visual Identity Per Branch

What the parent defines for children

Cafe (parent) defines the BRAND:
  - CafeName, LogoUrl
  - PrimaryColor (hex)     ← all branches inherit this
  - SecondaryColor (hex)
  - FontFamily (string)    ← optional: "Vazir", "IRANSans", "default"
  - FaviconUrl

Branch CAN override for its location:
  - BranchLogoUrl          ← e.g. branch-specific photo
  - AccentColor            ← slight variation
  - WelcomeText            ← "خوش آمدید به شعبه نیاوران"
  - WifiPassword           ← shown on QR menu and receipts

Branch CANNOT override:
  - CafeName (always the brand name)
  - PrimaryColor (brand consistency)
  - FontFamily (brand consistency)

STEP 1 — CafeIdentity entity (new)

// src/Meezi.Core/Entities/CafeIdentity.cs
public class CafeIdentity
{
    public Guid Id { get; set; }
    public Guid CafeId { get; set; }
    public string PrimaryColor { get; set; } = "#C47B2B";   // warm coffee default
    public string SecondaryColor { get; set; } = "#F5F0E8"; // light cream
    public string? FontFamily { get; set; }
    public string? LogoUrl { get; set; }
    public string? FaviconUrl { get; set; }
    public string? IconName { get; set; }  // Tabler icon name e.g. "coffee"
    public DateTime UpdatedAt { get; set; }
}

STEP 2 — BranchIdentity entity (new)

// src/Meezi.Core/Entities/BranchIdentity.cs
public class BranchIdentity
{
    public Guid Id { get; set; }
    public Guid BranchId { get; set; }
    public Guid CafeId { get; set; }
    // Branch-level overrides (nulls = use CafeIdentity value)
    public string? LogoUrl { get; set; }      // branch-specific photo
    public string? AccentColor { get; set; }  // slight color variation
    public string? WelcomeText { get; set; }  // greeting on QR menu
    public string? WifiPassword { get; set; }
    public string? Address { get; set; }       // shown on QR menu footer
}

STEP 3 — Resolve effective identity

// In EffectiveSettingsService or new IdentityService:

public async Task<BranchEffectiveIdentityDto> GetEffectiveIdentityAsync(
    Guid cafeId, Guid branchId, CancellationToken ct)
{
    var cafe = await _db.CafeIdentities
        .FirstOrDefaultAsync(i => i.CafeId == cafeId, ct);

    var branch = await _db.BranchIdentities
        .FirstOrDefaultAsync(i => i.BranchId == branchId, ct);

    return new BranchEffectiveIdentityDto
    {
        PrimaryColor   = cafe?.PrimaryColor ?? "#C47B2B",  // NEVER branch override
        SecondaryColor = cafe?.SecondaryColor ?? "#F5F0E8",
        FontFamily     = cafe?.FontFamily,
        LogoUrl        = branch?.LogoUrl ?? cafe?.LogoUrl, // branch photo first
        IconName       = cafe?.IconName ?? "coffee",
        WelcomeText    = branch?.WelcomeText ?? "خوش آمدید",
        WifiPassword   = branch?.WifiPassword,
        Address        = branch?.Address,
    };
}

STEP 4 — Endpoints

// Cafe identity (owner only):
GET   /api/cafes/{cafeId}/identity
PUT   /api/cafes/{cafeId}/identity
  Body: { primaryColor, secondaryColor, fontFamily, iconName, logoUrl, faviconUrl }

// Branch identity (owner or branch manager):
GET   /api/cafes/{cafeId}/branches/{branchId}/identity
PUT   /api/cafes/{cafeId}/branches/{branchId}/identity
  Body: { logoUrl, welcomeText, wifiPassword, address }
  // Note: primaryColor NOT in body — branch cannot change it

// Public (used by QR menu):
GET   /api/public/{cafeId}/branches/{branchId}/identity
  → Returns BranchEffectiveIdentityDto

STEP 5 — Dashboard: Visual Identity Settings Page

File: web/dashboard/src/components/settings/identity-settings.tsx (new)

Owner view (cafe-level):

Brand Color: [■ #C47B2B] color picker
Secondary Color: [■ #F5F0E8]
Logo: [upload] 
Icon: icon picker (grid of Tabler icon names: coffee, cup, utensils, store, ...)
Font: dropdown [پیش‌فرض | Vazir | IRANSans]
Preview: shows QR menu header with current settings

Branch manager view:

رنگ برند: ■ #C47B2B  🔒 (تعریف شده توسط مالک — قابل تغییر نیست)
لوگو شعبه: [upload]   ← branch-specific photo
متن خوش‌آمدگویی: [___________]
رمز WiFi: [___________]
آدرس: [___________]

STEP 6 — Apply identity to QR menu

Update the QR resolver endpoint (STEP 1 of PR-1) to also call GetEffectiveIdentityAsync and include the color/logo in QrResolveDto. The guest menu page already uses branch.primaryColor for all buttons and accents — it will automatically reflect the brand color.


Migration

dotnet ef migrations add BranchVisualIdentity \
  --project src/Meezi.Infrastructure \
  --startup-project src/Meezi.API

Execution Order

PR-1  feature/qr-guest-menu          ← fix the broken flow FIRST (2 day)
PR-2  feature/tax-inheritance        ← simple, 4 hours
PR-3  feature/visual-identity        ← after PR-1 (QR menu uses the colors), 1 day

New Error Codes

Code Meaning
INVALID_MENU_ITEMS Guest submitted item not in branch menu
TAX_OVERRIDE_NOT_ALLOWED Branch tried to set tax but parent locked it
BRANCH_IDENTITY_NOT_FOUND Identity record missing (auto-create on first save)

New i18n Strings

// fa.json additions:
"qrMenu": {
  "welcome": "خوش آمدید",
  "tableLabel": "میز",
  "addToCart": "افزودن",
  "viewCart": "مشاهده سبد خرید",
  "placeOrder": "ثبت سفارش",
  "orderPlaced": "سفارش ثبت شد!",
  "orderNumber": "شماره سفارش",
  "guestName": "نام شما (اختیاری)",
  "guestPhone": "شماره موبایل (اختیاری)",
  "addMoreItems": "افزودن آیتم دیگر",
  "tableNotFound": "میز یافت نشد",
  "loadError": "خطا در بارگذاری",
  "orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
  "subtotal": "جمع کل",
  "guestQrBadge": "سفارش QR مهمان"
},
"identity": {
  "brandColor": "رنگ برند",
  "secondaryColor": "رنگ ثانویه",
  "logo": "لوگو",
  "icon": "آیکون",
  "font": "فونت",
  "branchLogo": "لوگو شعبه",
  "welcomeText": "متن خوش‌آمدگویی",
  "wifiPassword": "رمز WiFi",
  "lockedByOwner": "تعریف شده توسط مالک — قابل تغییر نیست",
  "preview": "پیش‌نمایش"
},
"tax": {
  "defaultTaxRate": "نرخ مالیات پیش‌فرض",
  "allowBranchOverride": "اجازه تغییر نرخ مالیات به مدیران شعبه",
  "lockedByOwner": "نرخ مالیات توسط مالک قفل شده است",
  "overrideNotAllowed": "تغییر نرخ مالیات شعبه توسط مالک غیرفعال شده است"
}

Start with PR-1 — the QR menu fix. The other two are independent and can run in parallel after PR-1 merges.