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:
- Independent layouts — the platform shell (marketing navbar, platform-admin sidebar) never accidentally leaks into a tenant page's layout tree, and vice versa.
- Independent code-splitting — Next.js automatically chunks each route segment; a customer loading
/tablenever downloads platform-admin JS. - A single physical place to reason about "what does a tenant see" — everything under
tenants/[tenant]/is the entire tenant product surface. - 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)
| File | Catches | Notes |
|---|---|---|
app/not-found.tsx | A URL matching no route at all | No 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.tsx | notFound() from any matched route | The normal case — full design system, translated. A client component reading ErrorCopyProvider, for the same rendering reason as above. |
app/[locale]/tenants/not-found.tsx | An unknown tenant slug | Says the restaurant is gone rather than the page, because the visitor is usually standing at a table holding a scanned QR. |
app/[locale]/error.tsx | A render error under the locale root | Client component (React requires it), with a retry. |
app/[locale]/global-error.tsx | A failure in the root layout itself | Last resort: no providers, no theme script, so it inlines its own styles and prints both languages. |
.../table/loading.tsx, .../dashboard/loading.tsx | Suspense while the segment resolves | Shaped 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]) usetitle: { absolute }so the restaurant's page is not titled after the platform, and setopenGraph.siteNameto 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) setrobots: { index: false, follow: false }, androbots.txtdisallows the same paths. Both, because they fail differently: metadata only applies once a crawler has already fetched the page, andSessionGuard— 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. /docsis 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.
/tableemitsRestaurant+ fullMenuJSON-LD and/promo/[promoId]anOffer(src/lib/structured-data.ts), and each has anopengraph-image.tsxthat 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 declareshreflangfor both locales. - Every dashboard and platform-admin page exports
generateMetadatareturning its sidebar label from the dictionary (dict.dashboard.sidebarX,dict.platformAdmin.sidebarX; a tenant's detail page uses the tenant's name). Both layouts declaretitle: { 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:
| Shape | Example | How |
|---|---|---|
| Subdomain | swadh-ghar.restaurant-os.com/dashboard/menu | Proxy rewrites to the long form; the address bar keeps the clean path |
| Custom domain | order.swadhghar.com/dashboard/menu | Same rewrite, matched on Tenant.customDomain |
| Path-based | restaurant-os.com/en/tenants/swadh-ghar/dashboard/menu | The 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)
| Surface | Path (relative to tenant/platform root) | File |
|---|---|---|
| Marketing landing | / | (platform)/(home)/page.tsx |
| In-app docs | /docs, /docs/[slug] | (platform)/docs/** |
| Platform Admin overview | /platform-admin | platform-admin/page.tsx |
| Tenants list | /platform-admin/tenants | platform-admin/tenants/page.tsx |
| Tenant detail | /platform-admin/tenants/[tenantSlug] | platform-admin/tenants/[tenantSlug]/page.tsx |
| Subscriptions & Plan Builder | /platform-admin/subscriptions | platform-admin/subscriptions/page.tsx |
| Feature flags | /platform-admin/feature-flags | platform-admin/feature-flags/page.tsx |
| Platform users | /platform-admin/users | platform-admin/users/page.tsx |
| Audit log | /platform-admin/audit-log | platform-admin/audit-log/page.tsx |
| Notifications | /platform-admin/notifications | platform-admin/notifications/page.tsx |
| Support inbox | /platform-admin/support | platform-admin/support/page.tsx |
| Roadmap showcase | /platform-admin/roadmap | platform-admin/roadmap/page.tsx |
| Platform settings | /platform-admin/settings | platform-admin/settings/page.tsx |
Tenant root (redirects to /table) | / on a tenant domain | tenants/[tenant]/page.tsx |
| Owner dashboard | /dashboard | tenants/[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 | /table | tenants/[tenant]/table/page.tsx |
| Kitchen Display System | /kitchen | tenants/[tenant]/kitchen/page.tsx |
| Pickup token TV screen | /pickup-screen | tenants/[tenant]/pickup-screen/page.tsx |
| Waiter app | /waiter | tenants/[tenant]/waiter/page.tsx |
| Rider app | /rider | tenants/[tenant]/rider/page.tsx |
| Promo index | /promo | tenants/[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 surface | Intended path | Status |
|---|---|---|
| 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
| Hook | Purpose |
|---|---|
useLocalStorage | useSyncExternalStore-based generic localStorage hook — the foundation every override/mock-persistence hook below is built on. Cross-tab reactive via the storage event. |
useBillingOverrides | Plan overrides, VIP billing overrides, invoice generation + email tracking. |
usePlanBuilder | CRUD for subscription plans (create/update/archive/restore), merges custom + edited plans over the seed array. |
useTenantFeatureOverrides | Per-tenant feature flag override map (getEffective/setOverride/clearOverride). |
usePlatformAuditLog | Appends platform-admin actions (impersonation, plan changes, flag toggles) to a live audit feed. |
useLiveOrders | Simulated real-time order stream (interval-based) merged with seed orders — stand-in for a future Supabase Realtime subscription. |
useMyOrders | Customer-facing "my orders" tracking for the current table session. |
useAllergyFilter | Active allergen exclusion set + safe/warning/unsafe classification per menu item. |
useAbandonedCart | Detects a stale cart/session and triggers a re-engagement prompt. |
useGuestName | Per-guest display name for shared/group carts. |
useTableCalls | "Call waiter" / "request bill" signal queue per table. |
useTableTokens | Dynamic per-table QR token issuance + validation/expiry. |
usePromoStore / usePromoDesign | Promo campaign CRUD + drag-and-drop landing page layout persistence. |
useLoyalty | Points/cashback balance and redemption. |
useSupportTickets | Support ticket CRUD (both tenant + platform-admin views). |
useToast | Global toast notification dispatcher. |
useDebounce / useMediaQuery / useIsMobile / useSwipe / useClickOutside / useElapsedTime / useCountIncreaseEffect | Generic UI utility hooks. |
useBrowserNotifications | Native Notification API wrapper for staff-app alerts. |
useStorefrontLayout | Persisted 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.tstoproxy.ts(samematcher-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
- Extract the Host header (
swadh-ghar.restaurant-os.com,menu.kacchibhai.com, orrestaurant-os.com). - Local dev bypass —
localhost/127.0.0.1bare host is treated as the main platform (no tenant). - Local dev subdomains —
swadh-ghar.localhost:3000strips the.localhostsuffix and treats the label as a tenant slug (so engineers can test multi-tenancy without/etc/hostsedits beyond a wildcard*.localhostDNS entry, which resolves automatically on modern OSes). - Root domain /
www— exact match onrestaurant-os.comorwww.restaurant-os.com→ main platform, no tenant. - Subdomain of the root —
{label}.restaurant-os.com→labelis the tenant slug, unlesslabelis inRESERVED_SLUGS(app,admin,api,docs,support, ... — seeconfig/reserved-slugs.ts), which protects platform-level subdomains from ever being hijacked by a tenant signup. - Anything else → custom domain lookup — the full hostname is looked up against
Tenant.customDomainin 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. - 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}, wherecleanPathdefaults to/tablewhen the tenant's root/is requested (soswadh-ghar.restaurant-os.com/shows the digital menu directly, not a 404). - If no tenant was resolved (main platform traffic) → the locale is injected into the visible URL via a redirect (so
restaurant-os.com/pricingbecomes a bookmarkablerestaurant-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. - In both branches, the resolved locale is persisted to a
NEXT_LOCALEcookie for one year, so the next visit skipsAccept-Languagesniffing.
🟢 বাংলা সারাংশ: 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)
- Create
src/app/[locale]/tenants/[tenant]/{route}/page.tsx. - Read
tenantfromparams(it's the slug resolved by the proxy) and fetch that tenant's data (getTenantBySlugtoday; a Prisma query once the DB lands — see 07). - Place any client-interactive UI in
src/components/tenant/{surface}/and export it via that folder'sindex.tsbarrel. - Add every customer/public-facing string to both
src/config/dictionaries/en.jsonandbn.jsonin 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 — seeCLAUDE.mdRule 3, which is authoritative on this. - If the route needs to appear in a nav/sidebar, wire it into the relevant
sidebar.tsx(components/dashboard/sidebar.tsxfor the owner dashboard,components/platform-admin/sidebar.tsxfor platform admin). - Run
npx tsc --noEmit,npm run lint, andnpm run buildbefore 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 — তিনটাই ক্লিন পাস করতে হবে।