Skip to content

02 — Folder Structure & Routing

Status: ✅ Verified against source. The tree in §2 and the route table in §3 list only files that actually exist; anything designed but not yet built is called out explicitly as such (see §3.1) rather than shown as if it shipped.

🟢 বাংলা ভূমিকা: এই ডকুমেন্টে প্রজেক্টের সম্পূর্ণ ফোল্ডার স্ট্রাকচার, "AntiGravity" রাউটিং প্যাটার্ন, এবং সাবডোমেইন/কাস্টম-ডোমেইন রিরাইট করার middleware (Next.js 16-এ নাম বদলে হয়েছে src/proxy.ts) নিয়ে বিস্তারিত আলোচনা আছে।


1. Why "AntiGravity" Architecture

Traditional multi-tenant Next.js apps often put tenant logic behind a single [tenant] param at the root, mixing marketing/admin routes with tenant routes in one flat tree. AntiGravity here means the opposite: tenant-facing surfaces are pulled up and out into their own dedicated route subtree (app/[locale]/tenants/[tenant]/...), completely separate from the main-platform route group (app/[locale]/(platform)/...). This gives us:

  1. Independent layouts — the platform shell (marketing navbar, platform-admin sidebar) never accidentally leaks into a tenant page's layout tree, and vice versa.
  2. Independent code-splitting — Next.js automatically chunks each route segment; a customer loading /table never downloads platform-admin JS.
  3. A single physical place to reason about "what does a tenant see" — everything under tenants/[tenant]/ is the entire tenant product surface.
  4. Trivial to reason about proxy rewrites — the proxy only ever needs to rewrite to one prefix (/tenants/{slug}), never conditionally choose between multiple trees.

🟢 বাংলা সারাংশ: সাধারণ মাল্টি-টেন্যান্ট Next.js অ্যাপে সব রুট একসাথে মিশে থাকে। এখানে ইচ্ছাকৃতভাবে tenant-এর রুটগুলো (tenants/[tenant]/...) মূল প্ল্যাটফর্ম রুট ((platform)/...) থেকে সম্পূর্ণ আলাদা রাখা হয়েছে — এতে লেআউট মিক্স হয় না, কোড-স্প্লিটিং স্বয়ংক্রিয়ভাবে ভালো হয়, এবং প্রক্সি রিরাইট লজিক সহজ থাকে।


2. Full src/ Tree

restaurant-os/
├── src/
│   ├── proxy.ts                         # Next.js 16 middleware (subdomain/domain + locale resolution)
│   │
│   ├── app/
│   │   ├── [locale]/                    # i18n root — "en" | "bn"
│   │   │   ├── layout.tsx               # Root layout: <html lang>, fonts, metadata, viewport,
│   │   │   │                            #   themeInitScript() in <head>, AppProviders, Toaster
│   │   │   │
│   │   │   ├── (platform)/              # Route group: main platform domain (restaurant-os.com)
│   │   │   │   ├── layout.tsx           # accentInitScript("platform") + <AccentProvider scope="platform">
│   │   │   │   ├── (home)/
│   │   │   │   │   └── page.tsx         # Marketing landing page
│   │   │   │   ├── docs/
│   │   │   │   │   ├── page.tsx         # /docs index (doc picker)
│   │   │   │   │   └── [slug]/page.tsx  # /docs/[slug] — renders docs/<slug>.md
│   │   │   │   └── platform-admin/
│   │   │   │       ├── layout.tsx               # Platform-admin shell (sidebar, global search)
│   │   │   │       ├── page.tsx                 # Overview dashboard
│   │   │   │       ├── tenants/
│   │   │   │       │   ├── page.tsx             # Tenants table
│   │   │   │       │   └── [tenantSlug]/page.tsx # Tenant detail (plan, VIP, invoices, impersonate)
│   │   │   │       ├── subscriptions/page.tsx    # Plan Builder + Invoices
│   │   │   │       ├── feature-flags/page.tsx    # Feature flag board (global + tenant scope)
│   │   │   │       ├── users/page.tsx            # Platform-admin team members + RBAC
│   │   │   │       ├── audit-log/page.tsx        # Platform-wide audit trail
│   │   │   │       ├── notifications/page.tsx    # Platform notification center
│   │   │   │       ├── support/page.tsx          # Support ticket inbox
│   │   │   │       ├── roadmap/page.tsx          # Public-facing roadmap showcase
│   │   │   │       └── settings/page.tsx         # Platform-wide settings
│   │   │   │
│   │   │   └── tenants/                 # Route group: tenant subdomains/custom domains
│   │   │       ├── not-found.tsx        # "This restaurant isn't available" — see note below on
│   │   │       │                        #   why it sits here and not inside [tenant]/
│   │   │       └── [tenant]/            # Dynamic tenant slug segment (e.g. "swadh-ghar")
│   │   │           ├── layout.tsx       # Validates the slug (404s unknown tenants), renders
│   │   │           │                    #   accentInitScript() + <AccentProvider scope={slug}>
│   │   │           ├── page.tsx         # Bare tenant root — redirects to ./table
│   │   │           ├── dashboard/
│   │   │           │   ├── layout.tsx           # Dashboard shell (sidebar, impersonation banner)
│   │   │           │   ├── page.tsx             # Owner dashboard overview
│   │   │           │   ├── menu/page.tsx        # Menu builder
│   │   │           │   ├── orders/page.tsx      # Live orders board
│   │   │           │   ├── tables/page.tsx      # Table + QR management
│   │   │           │   ├── pos/page.tsx         # Point of Sale / billing
│   │   │           │   ├── staff/page.tsx       # Staff + roles
│   │   │           │   ├── inventory/page.tsx   # Stock/ingredients
│   │   │           │   ├── crm/page.tsx         # Customer profiles/segments
│   │   │           │   ├── reservations/page.tsx# Table booking board
│   │   │           │   ├── promos/page.tsx      # Promo campaign builder + landing CMS
│   │   │           │   ├── analytics/page.tsx   # Revenue, BCG matrix, cohort charts
│   │   │           │   └── settings/page.tsx    # Branding, domain, hours, accent color
│   │   │           │
│   │   │           ├── table/
│   │   │           │   ├── page.tsx             # Customer digital menu (dine-in) + Restaurant/Menu JSON-LD
│   │   │           │   ├── opengraph-image.tsx  # 1200×630 share card in the restaurant's own colours
│   │   │           │   └── loading.tsx          # Menu-shaped skeleton while the menu resolves
│   │   │           ├── tables/                  # Public table picker — the way in for a guest
│   │   │           │   ├── page.tsx             #   who has no phone to scan the QR with
│   │   │           │   └── loading.tsx
│   │   │           ├── kitchen/                 # Kitchen Display System
│   │   │           │   ├── page.tsx
│   │   │           │   └── loading.tsx
│   │   │           ├── pickup-screen/           # Pickup-counter TV token display
│   │   │           │   ├── page.tsx
│   │   │           │   └── loading.tsx
│   │   │           ├── waiter/                  # Waiter app (requests inbox, assigned tables,
│   │   │           │   ├── page.tsx             #   per-item served marking — all one screen)
│   │   │           │   └── loading.tsx
│   │   │           ├── rider/                   # Rider delivery queue + active delivery detail
│   │   │           │   ├── page.tsx
│   │   │           │   └── loading.tsx
│   │   │           └── promo/
│   │   │               ├── page.tsx             # Promo campaign index for this tenant
│   │   │               ├── loading.tsx
│   │   │               └── [promoId]/
│   │   │                   ├── page.tsx         # One campaign's dedicated landing page + Offer JSON-LD
│   │   │                   └── opengraph-image.tsx  # The offer itself as the share card
│   │   │
│   │   ├── not-found.tsx                # Root 404: a URL that matched no route at all. Renders
│   │   │                                #   its own <html>/<body> — there is no app/layout.tsx
│   │   ├── robots.ts                     # Served on every host; disallows the staff/owner surfaces
│   │   ├── sitemap.ts                    # Marketing pages + every active tenant's menu and offers,
│   │   │                                #   listed under the tenant's own domain when it has one
│   │   ├── globals.css                  # Tailwind v4 entry + the semantic CSS custom properties
│   │   │                                #   (--accent, --glass-blur, ...) every component consumes
│   │   ├── manifest.ts                  # PWA manifest for the staff apps — Next serves it at
│   │   │                                #   /manifest.webmanifest (it is NOT a file in public/)
│   │   └── favicon.ico
│   │
│   ├── components/
│   │   ├── ui/                # Design-system primitives (Button, Card, Modal, Select, Switch, Tabs, Sheet, Toaster, ...)
│   │   ├── shared/             # Cross-surface pieces (PageHeader, EmptyState, ThemeToggle, LocaleSwitcher, AccentPicker, ...)
│   │   ├── marketing/          # Landing-page sections (Hero, PricingSection, FeaturesGrid, Testimonials, ...)
│   │   ├── platform-admin/     # Platform Admin feature components (PlanBuilder, FeatureFlagsBoard, TenantsTable, ...)
│   │   ├── dashboard/          # Owner Dashboard feature components (MenuManager, OrdersBoard, POSStation, AnalyticsBoard, ...)
│   │   └── tenant/             # Customer/staff-facing tenant surfaces
│   │       ├── table/          # Digital menu (MenuExperience, CartSheet, AllergyFilterSheet, SplitBillSheet, SpinWheelReward, ...)
│   │       ├── kitchen/        # KDSBoard, PickupScreenBoard
│   │       ├── waiter/         # WaiterBoard
│   │       ├── rider/          # RiderBoard
│   │       └── promo/          # PromoLanding
│   │
│   ├── config/
│   │   ├── i18n.ts             # locales, defaultLocale, getDictionary()
│   │   ├── site.ts             # siteConfig (rootDomain, accentPresets, footer links, ...)
│   │   ├── reserved-slugs.ts   # RESERVED_SLUGS — subdomains that can never be a tenant
│   │   └── dictionaries/       # en.json, bn.json — every user-facing string
│   │
│   ├── hooks/                  # 20+ hooks — see table below
│   ├── lib/
│   │   ├── data/               # THE read layer — every server component reads through here
│   │   ├── mock-data/          # Seed data behind it: tenants, menu, orders, staff, tables, promos, ...
│   │   ├── docs.ts             # Reads docs/*.md for the in-app /docs viewer
│   │   ├── invoice-pdf.ts      # jsPDF-based invoice PDF generator
│   │   ├── food-costing.ts     # COGS/margin calculations
│   │   ├── upsell.ts           # "frequently paired" suggestion logic
│   │   ├── status-styles.ts    # Badge variant lookups per order/tenant/invoice status
│   │   ├── visual-tokens.ts    # The gradient+icon "visual token" registry used instead of
│   │   │                       #   stock photography — getVisual(token) backs every VisualTile
│   │   ├── theme-script.ts     # Inline no-flash theme boot script
│   │   └── utils.ts            # cn(), formatCurrency(), formatRelativeTime(), generateId(), ...
│   │
│   ├── providers/              # ThemeProvider, AccentProvider, GlassModeProvider, CartProvider, AppProviders (composition root)
│   └── types/                  # One file per domain entity (tenant.ts, order.ts, menu.ts, subscription.ts, feature-flag.ts, ...)
│
├── docs/                       # This documentation set (docs/*.md), rendered live at /docs
├── public/                     # PWA/app icons only (icon-192, icon-512, icon-512-maskable,
│                               #   apple-touch-icon). The web manifest is NOT here — it is
│                               #   generated by src/app/manifest.ts.
├── CLAUDE.md / AGENTS.md       # Must-read conventions for AI coding agents working in this repo
└── README.md                   # Project root readme

🟢 বাংলা সারাংশ: উপরে পুরো src/ ফোল্ডার ট্রি দেওয়া আছে হুবহু বর্তমান রিপোজিটরির সাথে মিলিয়ে। মূল ভাগগুলো: app/[locale]/(platform)/... (মার্কেটিং + প্ল্যাটফর্ম অ্যাডমিন), app/[locale]/tenants/[tenant]/... (প্রতিটি রেস্টুরেন্টের ড্যাশবোর্ড ও কাস্টমার-ফেসিং সব পেজ), এবং components/, config/, hooks/, lib/, providers/, types/ — প্রতিটির নির্দিষ্ট দায়িত্ব আছে যা টেবিলে ব্যাখ্যা করা হয়েছে।

Route boundaries (loading / error / not-found)

FileCatchesNotes
app/not-found.tsxA URL matching no route at allNo layout exists above it (the root layout is [locale]/layout.tsx), so it renders its own document. Ships both languages’ error copy and picks between them from the cookie on the client — reading it on the server made every route in the app dynamic (see 09 §0).
app/[locale]/not-found.tsxnotFound() from any matched routeThe normal case — full design system, translated. A client component reading ErrorCopyProvider, for the same rendering reason as above.
app/[locale]/tenants/not-found.tsxAn unknown tenant slugSays the restaurant is gone rather than the page, because the visitor is usually standing at a table holding a scanned QR.
app/[locale]/error.tsxA render error under the locale rootClient component (React requires it), with a retry.
app/[locale]/global-error.tsxA failure in the root layout itselfLast resort: no providers, no theme script, so it inlines its own styles and prints both languages.
.../table/loading.tsx, .../dashboard/loading.tsxSuspense while the segment resolvesShaped like the page they stand in for, not spinners.

The one that is easy to get wrong: the tenant 404 lives at tenants/, not at tenants/[tenant]/. The slug check is in [tenant]/layout.tsx, and a boundary inside a layout cannot catch that layout's own failure — Next walks up to the nearest segment that actually rendered. Placed beside the layout it looks correct, compiles, and silently falls through to the generic 404.

The second thing that is easy to get wrong: a loading.tsx opens a Suspense boundary, so the response starts streaming — and its 200 is committed — before the page below it runs. A notFound() after that point renders the 404 page with a 200 status. This was measured, not assumed: adding one at platform-admin/ turned /platform-admin/tenants/<unknown> from 404 into 200, and removing it restored the 404. So there is no loading.tsx above a route that calls notFound(). The two that exist are safe for different reasons — nothing under dashboard/ calls it, and for /table the unknown-tenant check happens in [tenant]/layout.tsx, above the boundary, so it still 404s.

These matter more than they look while the data is static: src/lib/data/ is already async, so loading.tsx is exactly what a visitor sees the moment those functions start doing real work.

🟢 বাংলা সারাংশ: প্রতিটি রুটের জন্য এখন loading/error/not-found সীমানা আছে। মূল সতর্কতা: টেন্যান্টের 404 ফাইলটি tenants/[tenant]/-এ নয়, tenants/-এ রাখতে হয় — কারণ স্লাগ যাচাই হয় [tenant]/layout.tsx-এ, আর কোনো লেআউটের ভেতরের সীমানা সেই লেআউটের নিজের ব্যর্থতা ধরতে পারে না; Next তখন উপরের সেগমেন্টে চলে যায়। ভুল জায়গায় রাখলে কোড কম্পাইল হয়, দেখতেও ঠিক লাগে, কিন্তু চুপচাপ সাধারণ 404 দেখায়। দ্বিতীয় সতর্কতা: loading.tsx একটি Suspense সীমানা তৈরি করে, ফলে 200 স্ট্যাটাস আগেই পাঠানো হয়ে যায় — তার পরে notFound() ডাকলে 404 পেজ দেখায় ঠিকই, কিন্তু স্ট্যাটাস থাকে 200। তাই যে রুট notFound() ডাকে তার উপরে কোনো loading.tsx রাখা হয়নি।

Page metadata

The root layout sets the defaults — metadataBase, the %s · RestaurantOS title template, and the OpenGraph/Twitter card shape. Individual routes override where the default would be wrong:

  • Tenant public pages (/table, /promo, /promo/[promoId]) use title: { absolute } so the restaurant's page is not titled after the platform, and set openGraph.siteName to the restaurant. /promo/[promoId] builds its card from the campaign's own title and discount label — that link exists to be shared and scanned, and a generic platform card is a link nobody taps. Titles and descriptions come from the dictionaries (Rule 3), so they render in Bengali too.
  • Staff and owner surfaces (/dashboard/*, /platform-admin/*, /kitchen, /waiter, /rider, /pickup-screen) set robots: { index: false, follow: false }, and robots.txt disallows the same paths. Both, because they fail differently: metadata only applies once a crawler has already fetched the page, and SessionGuard — which does send a signed-out person to /login — is client-side, so a crawler never runs it. There is still no auth in this phase to stop one reaching a page.
  • /docs is noindexed for the same reason — it publishes the schema, the threat model and the roadmap.
  • Public pages carry structured data and a generated share card. /table emits Restaurant + full Menu JSON-LD and /promo/[promoId] an Offer (src/lib/structured-data.ts), and each has an opengraph-image.tsx that draws the restaurant's name, tagline and accent at 1200×630 — a menu link forwarded on WhatsApp is how most guests meet this product. Canonical URLs point at the restaurant's own domain when it has one; the landing page declares hreflang for both locales.
  • Every dashboard and platform-admin page exports generateMetadata returning its sidebar label from the dictionary (dict.dashboard.sidebarX, dict.platformAdmin.sidebarX; a tenant's detail page uses the tenant's name). Both layouts declare title: { default, template: "%s · RestaurantOS" } explicitly — a plain-string title on a layout would cut its children off from the root template, and every tab would read the same word.

Two URL shapes, both real

A restaurant's screens are reachable two ways, and links have to work on both:

ShapeExampleHow
Subdomainswadh-ghar.restaurant-os.com/dashboard/menuProxy rewrites to the long form; the address bar keeps the clean path
Custom domainorder.swadhghar.com/dashboard/menuSame rewrite, matched on Tenant.customDomain
Path-basedrestaurant-os.com/en/tenants/swadh-ghar/dashboard/menuThe actual route; no rewrite involved

The path-based form is not a leftover — it is the route the other two rewrite to, so it necessarily exists, and it is how localhost, the docs and the tests reach the app. The dashboard sidebar was written for the subdomain alone and hardcoded the clean /dashboard space, so on the path-based form every one of its links resolved to /en/dashboard/... and 404'd, and no item was ever highlighted. src/lib/tenant-path.ts reads the prefix off the path being viewed instead. Any new tenant-scoped link must go through it rather than hardcoding either shape.

Owner-created campaigns live in localStorage, so the server cannot see them: those /promo/[promoId] pages fall back to the restaurant's name rather than the platform default, and are absent from the sitemap. Both gaps close when promos move server-side.

🟢 বাংলা সারাংশ: রুট লেআউটে ডিফল্ট মেটাডেটা আছে; যেখানে ডিফল্টটা ভুল হতো সেখানে আলাদা করে দেওয়া — রেস্টুরেন্টের পাবলিক পেজগুলোর টাইটেল প্ল্যাটফর্মের নামে নয়, রেস্টুরেন্টের নামে, আর /promo/[promoId]-এর শেয়ার কার্ড অফারটির নিজের নাম-ছাড় দেখায় (এই লিংকটাই তো শেয়ার আর স্ক্যান হওয়ার জন্য)। স্টাফ ও মালিকের স্ক্রিনগুলো noindex, এবং robots.txt-এও একই পাথ বন্ধ — দুই জায়গাতেই, কারণ মেটাডেটা কাজ করে পেজ ফেচ হওয়ার পরে, আর SessionGuard ক্লায়েন্ট-সাইড বলে ক্রলার সেটা চালায়ই না — এই ফেজে ক্রলার আটকানোর মতো কোনো auth নেই।


3. Route Table (every URL in the system)

SurfacePath (relative to tenant/platform root)File
Marketing landing/(platform)/(home)/page.tsx
In-app docs/docs, /docs/[slug](platform)/docs/**
Platform Admin overview/platform-adminplatform-admin/page.tsx
Tenants list/platform-admin/tenantsplatform-admin/tenants/page.tsx
Tenant detail/platform-admin/tenants/[tenantSlug]platform-admin/tenants/[tenantSlug]/page.tsx
Subscriptions & Plan Builder/platform-admin/subscriptionsplatform-admin/subscriptions/page.tsx
Feature flags/platform-admin/feature-flagsplatform-admin/feature-flags/page.tsx
Platform users/platform-admin/usersplatform-admin/users/page.tsx
Audit log/platform-admin/audit-logplatform-admin/audit-log/page.tsx
Notifications/platform-admin/notificationsplatform-admin/notifications/page.tsx
Support inbox/platform-admin/supportplatform-admin/support/page.tsx
Roadmap showcase/platform-admin/roadmapplatform-admin/roadmap/page.tsx
Platform settings/platform-admin/settingsplatform-admin/settings/page.tsx
Tenant root (redirects to /table)/ on a tenant domaintenants/[tenant]/page.tsx
Owner dashboard/dashboardtenants/[tenant]/dashboard/page.tsx
Menu builder/dashboard/menu.../dashboard/menu/page.tsx
Orders board/dashboard/orders.../dashboard/orders/page.tsx
Tables + QR/dashboard/tables.../dashboard/tables/page.tsx
POS/dashboard/pos.../dashboard/pos/page.tsx
Staff/dashboard/staff.../dashboard/staff/page.tsx
Inventory/dashboard/inventory.../dashboard/inventory/page.tsx
CRM/dashboard/crm.../dashboard/crm/page.tsx
Reservations/dashboard/reservations.../dashboard/reservations/page.tsx
Promos + landing CMS/dashboard/promos.../dashboard/promos/page.tsx
Analytics/dashboard/analytics.../dashboard/analytics/page.tsx
Settings/dashboard/settings.../dashboard/settings/page.tsx
Customer digital menu/tabletenants/[tenant]/table/page.tsx
Kitchen Display System/kitchentenants/[tenant]/kitchen/page.tsx
Pickup token TV screen/pickup-screentenants/[tenant]/pickup-screen/page.tsx
Waiter app/waitertenants/[tenant]/waiter/page.tsx
Rider app/ridertenants/[tenant]/rider/page.tsx
Promo index/promotenants/[tenant]/promo/page.tsx
Promo campaign page/promo/[promoId]tenants/[tenant]/promo/[promoId]/page.tsx

Every path above is prefixed with /{locale} in the actual URL (e.g. /en/dashboard/menu), and on tenant traffic additionally resolved through the proxy from a bare /dashboard/menu on the tenant's own domain — see §5.

🟢 বাংলা সারাংশ: উপরের টেবিলে প্ল্যাটফর্মের প্রতিটি URL এবং তার সাথে সংশ্লিষ্ট ফাইল দেখানো হয়েছে। প্রতিটি path-এর সামনে /en বা /bn locale prefix বসে, আর tenant-এর ক্ষেত্রে আসল ভিজিবল URL হয় তাদের নিজস্ব ডোমেইনে (যেমন swadh-ghar.restaurant-os.com/dashboard/menu), যেটা প্রক্সি ভেতরে ভেতরে সঠিক ফাইলে ম্যাপ করে দেয়।

3.1 Designed, but not built — do not assume these exist

Earlier revisions of this document listed the two routes below in §2 and §3 as if they had shipped. They do not exist and will 404. Both surfaces are currently single-screen: the waiter app handles table selection inline, and the rider app shows the active delivery's detail inside the queue screen (activeOrder in rider-board.tsx) rather than on its own route.

Planned surfaceIntended pathStatus
Waiter — single table detail/waiter/tables/[tableId]🚧 Not built — no page.tsx
Rider — single delivery detail/rider/[orderId]🚧 Not built — no page.tsx

A working tree may still contain empty waiter/tables/[tableId]/ and rider/[orderId]/ directories left over from that earlier plan. They hold no files, git cannot track empty directories, and a fresh clone will not have them — so their presence locally is not evidence the routes exist. If you build either route, add its page.tsx, then move its row from this table up into §3 and add it to the §2 tree.

🟢 বাংলা সারাংশ: এই দুইটি রুট আগে ডকুমেন্টে "আছে" বলে লেখা ছিল, কিন্তু বাস্তবে নেই — খুললে 404 দেবে। Waiter ও Rider দুটোই এখন এক-স্ক্রিনের অ্যাপ। লোকাল ফোল্ডারে খালি ডিরেক্টরি দেখলেও ধরে নেবেন না রুট আছে; git খালি ফোল্ডার রাখে না। বানালে page.tsx যোগ করে সারিটা §3-এ তুলে দেবেন এবং §2-এর ট্রি-তেও লিখবেন।


4. src/hooks/ Reference

HookPurpose
useLocalStorageuseSyncExternalStore-based generic localStorage hook — the foundation every override/mock-persistence hook below is built on. Cross-tab reactive via the storage event.
useBillingOverridesPlan overrides, VIP billing overrides, invoice generation + email tracking.
usePlanBuilderCRUD for subscription plans (create/update/archive/restore), merges custom + edited plans over the seed array.
useTenantFeatureOverridesPer-tenant feature flag override map (getEffective/setOverride/clearOverride).
usePlatformAuditLogAppends platform-admin actions (impersonation, plan changes, flag toggles) to a live audit feed.
useLiveOrdersSimulated real-time order stream (interval-based) merged with seed orders — stand-in for a future Supabase Realtime subscription.
useMyOrdersCustomer-facing "my orders" tracking for the current table session.
useAllergyFilterActive allergen exclusion set + safe/warning/unsafe classification per menu item.
useAbandonedCartDetects a stale cart/session and triggers a re-engagement prompt.
useGuestNamePer-guest display name for shared/group carts.
useTableCalls"Call waiter" / "request bill" signal queue per table.
useTableTokensDynamic per-table QR token issuance + validation/expiry.
usePromoStore / usePromoDesignPromo campaign CRUD + drag-and-drop landing page layout persistence.
useLoyaltyPoints/cashback balance and redemption.
useSupportTicketsSupport ticket CRUD (both tenant + platform-admin views).
useToastGlobal toast notification dispatcher.
useDebounce / useMediaQuery / useIsMobile / useSwipe / useClickOutside / useElapsedTime / useCountIncreaseEffectGeneric UI utility hooks.
useBrowserNotificationsNative Notification API wrapper for staff-app alerts.
useStorefrontLayoutPersisted layout/density preference for the customer menu.

🟢 বাংলা সারাংশ: src/hooks/ ফোল্ডারে ২০+ কাস্টম হুক আছে। এদের বেশিরভাগই useLocalStorage-এর উপর ভিত্তি করে বানানো — অর্থাৎ যেহেতু এখনো ব্যাকএন্ড নেই, এই হুকগুলো ব্রাউজারের localStorage-এ ডেটা রেখে state persist করে এবং cross-tab sync করে। ভবিষ্যতে ব্যাকএন্ড যুক্ত হলে এই হুকগুলোর ভেতরের ইমপ্লিমেন্টেশন বদলে Supabase কল দিয়ে replace হবে, কিন্তু বাইরের API (hook-এর return shape) একই থাকবে — তাই UI কম্পোনেন্টে কোনো পরিবর্তন লাগবে না।


5. The Proxy (src/proxy.ts) — Subdomain & Custom Domain Rewriting

⚠️ Next.js 16 renamed the middleware file convention from middleware.ts to proxy.ts (same matcher-based execution model, runs on the Edge runtime before any route is rendered). This project follows that convention.

5.1 Full source

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { defaultLocale, locales, type Locale } from "@/config/i18n";
import { isReservedSlug } from "@/config/reserved-slugs";
import { getTenantBySlug } from "@/lib/mock-data/tenants";
import { siteConfig } from "@/config/site";

const LOCALE_COOKIE = "NEXT_LOCALE";
const ONE_YEAR = 60 * 60 * 24 * 365;

function detectLocale(request: NextRequest): Locale {
  const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
  if (cookieLocale && (locales as readonly string[]).includes(cookieLocale)) {
    return cookieLocale as Locale;
  }
  const acceptLanguage = request.headers.get("accept-language") ?? "";
  const preferred = acceptLanguage.split(",")[0]?.split("-")[0]?.toLowerCase();
  if (preferred && (locales as readonly string[]).includes(preferred)) {
    return preferred as Locale;
  }
  return defaultLocale;
}

function resolveTenantSlug(hostname: string): string | null {
  const host = hostname.split(":")[0].toLowerCase();
  const root = siteConfig.rootDomain;

  if (host === "localhost" || host === "127.0.0.1") return null;
  if (host.endsWith(".localhost")) {
    const label = host.slice(0, -".localhost".length);
    return label && !isReservedSlug(label) ? label : null;
  }
  if (host === root || host === `www.${root}`) return null;
  if (host.endsWith(`.${root}`)) {
    const label = host.slice(0, -(`.${root}`.length));
    return label && !isReservedSlug(label) ? label : null;
  }

  // Custom domain (e.g. "order.swadhghar.com") — look it up directly.
  const tenant = getTenantBySlug(host);
  return tenant ? tenant.slug : null;
}

export function proxy(request: NextRequest) {
  const url = request.nextUrl;
  const hostname = request.headers.get("host") ?? url.hostname;
  const { pathname } = url;
  const pathnameHasLocale = locales.some(
    (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
  );
  const tenantSlug = resolveTenantSlug(hostname);

  if (tenantSlug) {
    const locale = detectLocale(request);
    const cleanPath = pathname === "/" ? "/table" : pathname;
    const rewrittenUrl = url.clone();
    rewrittenUrl.pathname = `/${locale}/tenants/${tenantSlug}${cleanPath}`;
    const response = NextResponse.rewrite(rewrittenUrl);
    response.cookies.set(LOCALE_COOKIE, locale, { path: "/", maxAge: ONE_YEAR });
    return response;
  }

  if (!pathnameHasLocale) {
    const locale = detectLocale(request);
    const redirectUrl = url.clone();
    redirectUrl.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;
    const response = NextResponse.redirect(redirectUrl);
    response.cookies.set(LOCALE_COOKIE, locale, { path: "/", maxAge: ONE_YEAR });
    return response;
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next|api|.*\\..*).*)"],
};

5.2 Resolution logic, step by step

  1. Extract the Host header (swadh-ghar.restaurant-os.com, menu.kacchibhai.com, or restaurant-os.com).
  2. Local dev bypass — localhost/127.0.0.1 bare host is treated as the main platform (no tenant).
  3. Local dev subdomains — swadh-ghar.localhost:3000 strips the .localhost suffix and treats the label as a tenant slug (so engineers can test multi-tenancy without /etc/hosts edits beyond a wildcard *.localhost DNS entry, which resolves automatically on modern OSes).
  4. Root domain / www — exact match on restaurant-os.com or www.restaurant-os.com → main platform, no tenant.
  5. Subdomain of the root — {label}.restaurant-os.com → label is the tenant slug, unless label is in RESERVED_SLUGS (app, admin, api, docs, support, ... — see config/reserved-slugs.ts), which protects platform-level subdomains from ever being hijacked by a tenant signup.
  6. Anything else → custom domain lookup — the full hostname is looked up against Tenant.customDomain in the tenant list (getTenantBySlug). This is where a tenant's own domain (menu.kacchibhai.com, CNAME'd to Vercel — see 08 / 10 for the Vercel Domains API automation) resolves.
  7. If a tenant was resolved → the request is rewritten (not redirected — the browser URL bar keeps showing the tenant's clean domain/path) to /{locale}/tenants/{slug}{cleanPath}, where cleanPath defaults to /table when the tenant's root / is requested (so swadh-ghar.restaurant-os.com/ shows the digital menu directly, not a 404).
  8. If no tenant was resolved (main platform traffic) → the locale is injected into the visible URL via a redirect (so restaurant-os.com/pricing becomes a bookmarkable restaurant-os.com/en/pricing), since the main platform's locale is a real, shareable part of the URL — unlike a tenant's, which is a silent preference.
  9. In both branches, the resolved locale is persisted to a NEXT_LOCALE cookie for one year, so the next visit skips Accept-Language sniffing.

🟢 বাংলা সারাংশ: proxy.ts প্রতিটি রিকোয়েস্টের Host header দেখে বুঝে নেয় এটা কোনো tenant-এর সাবডোমেইন/কাস্টম ডোমেইন কিনা। যদি হয়, তাহলে ভিজিবল URL অপরিবর্তিত রেখে ভেতরে ভেতরে /locale/tenants/slug/... পাথে rewrite করে দেয় (redirect না — তাই ব্রাউজারের ঠিকানা বার-এ tenant-এর নিজস্ব ডোমেইনই দেখা যায়)। মূল প্ল্যাটফর্ম ডোমেইনের ক্ষেত্রে locale সরাসরি URL-এ visible রাখা হয় (redirect দিয়ে), কারণ সেটা bookmarkable হওয়া দরকার। RESERVED_SLUGS লিস্টের মাধ্যমে admin, api, docs-এর মতো সাবডোমেইন কোনো tenant হিসেবে কখনো resolve হতে পারবে না — এটা একটা নিরাপত্তা স্তর।

5.3 config.matcher explained

export const config = { matcher: ["/((?!_next|api|.*\\..*).*)"] };

This regex excludes three things from ever hitting the proxy: Next.js internals (_next/*), API routes (/api/* — these already know their own tenant context from the request body/auth token, not the Host header), and any path containing a dot (static files like favicon.ico, manifest.webmanifest). Everything else — every page route — passes through tenant/locale resolution.

🟢 বাংলা সারাংশ: matcher রেজেক্স দিয়ে বলা আছে যে Next.js-এর নিজস্ব ইন্টারনাল ফাইল, /api/* রুট, এবং static ফাইল (যেমন favicon) কখনো proxy-তে যাবে না — বাকি সব পেজ রুট proxy দিয়ে filter হবে।


6. Adding a New Tenant-Facing Page (contributor checklist)

  1. Create src/app/[locale]/tenants/[tenant]/{route}/page.tsx.
  2. Read tenant from params (it's the slug resolved by the proxy) and fetch that tenant's data (getTenantBySlug today; a Prisma query once the DB lands — see 07).
  3. Place any client-interactive UI in src/components/tenant/{surface}/ and export it via that folder's index.ts barrel.
  4. Add every customer/public-facing string to both src/config/dictionaries/en.json and bn.json in the same change (with a real Bengali translation, not an English placeholder), and re-check key parity before you finish. The one deliberate exception is dashboard-internal / owner-only copy, which by established convention stays as hardcoded English literals — see CLAUDE.md Rule 3, which is authoritative on this.
  5. If the route needs to appear in a nav/sidebar, wire it into the relevant sidebar.tsx (components/dashboard/sidebar.tsx for the owner dashboard, components/platform-admin/sidebar.tsx for platform admin).
  6. Run npx tsc --noEmit, npm run lint, and npm run build before considering the change done — this repository holds all three to zero warnings/errors as a hard bar.

🟢 বাংলা সারাংশ: নতুন কোনো tenant-facing পেজ যোগ করতে হলে — সঠিক ফোল্ডারে page.tsx বানাতে হবে, UI কম্পোনেন্ট components/tenant/{surface}/-এ রাখতে হবে, প্রতিটি টেক্সট en.json ও bn.json দুই জায়গাতেই যোগ করতে হবে, দরকার হলে সাইডবারে লিংক যোগ করতে হবে, এবং সবশেষে tsc/lint/build — তিনটাই ক্লিন পাস করতে হবে।