Menu (room-service) integration
Porting the hotel-menu project (in-room QR ordering) into Bronit as a new
Menu module for hotels. Guests scan a room QR → browse a translated menu →
order → staff get a Telegram notification in a dedicated forum topic → manage
orders from the dashboard.
Source project: D:\work\tv-samples\hotel-menu (Next 14, React 18, Tailwind 3,
Prisma → MongoDB, AI translation, QR/PDF, SSE order stream).
Strategy
Reuse Bronit’s data store, tenancy, auth, Telegram, i18n and UI. Rewrite the
source’s Prisma models as Mongoose models (same MongoDB store, different
ORM). Menu data hangs off Bronit’s existing Hotel._id.
Decisions
- ✅ Database: MongoDB via Mongoose (rewrite Prisma models; no second ORM).
- ✅ Existing data: migrate from the hotel-menu MongoDB (both are Mongo — a one-off script maps each hotel-menu
hotelId→ the matching BronitHotel._idand copies records +nameI18n). Formalized in Phase 7. - ✅ Translation: menu content (category/product name + description) supports 10 languages — en, ru, uz, ar, zh, fr, es, de, kk, tr. Admin picks an input language per record, types the source text once, and Google Translate API (v2 REST,
GOOGLE_TRANSLATE_API_KEY) fills the other 9 on demand via a “Translate” button. Any language can be individually locked to the original (nameI18nLocked/descI18nLocked) when auto-translate gets it wrong — locked languages mirror the source text and are skipped on re-translate instead of being overwritten. Scope: menu content only — the guest page’s UI chrome (buttons, labels) stays en/ru/uz; guests pick the menu’s display language independently via a 10-option dropdown that doesn’t affect chrome. Falls back to manual entry with no error ifGOOGLE_TRANSLATE_API_KEYisn’t set (not Anthropic). - ✅ Guest page delivery:
https://hotel.bronit.uz/en/menu/safirhotel?room=101— fully public, no login. The hotel slug is a path segment (Hotel.slug, globally unique — resolves both hotel and company server-side),?room== room number,/en= locale. This URL is built in one place,publicHubUrlinsrc/services/menu/lib/menu.ts, so the QR a TV draws and the QR a printer prints are the same string — and it goes onto a card in a guest room, so treat it as fixed. Admin menu management (create/edit categories, products, orders) is athotel.bronit.uz/en/menu, which the proxy rewrites to the owner dashboard. The guest API is/menu/v1/*on the gateway — see api-contract.md. - ✅ Telegram: reuse Bronit’s
telegram.ts; menu orders get a new forum topic per hotel. - ✅ Auth/tenancy: drop
hotel-menu’s own passwords; use Bronit sessions + roles (owner/admin/superadmin), Company → Hotel. - ✅ Client data fetching: TanStack React Query, matching the source app’s own stack (“React Query for admin data management”).
QueryProvidermoved from the two dashboard layouts up to the app-root[locale]/layout.tsx(one client, available everywhere — guest routes, superadmin, login — not just the owner/admin dashboards). The menu module’s dashboard hooks (useMenuPage,useOrdersPage,RecommendationsModal,RoomQrModal) are converted; staff order polling now usesrefetchIntervalwith an optimistic status-update mutation instead of manualsetInterval/rollback state. Scope: menu module now, available app-wide for any new data-fetching code going forward — existing non-menu features are not being retrofitted.
Entity mapping (source → Bronit)
| hotel-menu (Prisma) | Bronit (Mongoose) | Notes |
|---|---|---|
Hotel | reuse Hotel (+ menu settings) | serviceFee, preorderEnabled, guest branding → on Hotel or HotelMenuSettings |
Room | reuse Room | menu orders reference roomId |
Category | MenuCategory | nameI18n {en,ru,uz}, sortOrder |
Product | MenuProduct | price (int UZS), nameI18n/descI18n, imageUrl, available |
Recommendation | MenuRecommendation | featured product per weekday |
Order | MenuOrder | status, serviceFee snapshot, total |
OrderItem | MenuOrderItem | name/price snapshot, qty |
ServiceRequest | GuestServiceRequest | taxi/reception/alarm from the in-room page |
HotelService | GuestService | manager-defined in-room services (transfer, pool…) |
Naming convention: menu models are prefixed (Menu* / Guest*) to avoid
confusion with Bronit’s existing Service (bookable services), Room, Booking.
Module layout
src/models/Menu*.ts,GuestService.ts,GuestServiceRequest.ts— Mongoose modelssrc/features/menu/— dashboard management UI (categories, products, orders)src/features/menu/guest/(orsrc/app/[locale]/hotel/[hotelHostname]/…) — public guest menu- Telegram: extend
src/lib/telegram.tswith a menu-orders topic helper
Phases
- Phase 0 — Scope & scaffolding. This doc + module scaffold; naming/conventions fixed; baseline green.
- Phase 1 — Data model. Mongoose models:
MenuCategory,MenuProduct,MenuRecommendation,GuestService,HotelMenuSettings(incl. globally-unique guestsubdomain) + sharedLocalizedText. Verified withscripts/menu-models-check.ts(DB-free) andtsc. - Phase 2 — Menu management (dashboard). “Menu” nav item (owner/admin) +
/menuroutes; session-scoped API (/api/menu/categories,/api/menu/products+[id]); CRUD categories & products with en/ru/uz (LocalizedInput) + price + image + availability, reusing theModal. Owner hotel picker. i18n in en/ru/uz.tsc+ eslint clean. Not yet run against a live DB. - Phase 3 — Guest menu (read-only). Proxy routes company-slug subdomains → public
/[locale]/menu(preserving?hotel=&room=); server-rendered guest page resolves company (Host) → hotel (?hotel) → menu, with EN/RU/UZ switch andlocalized()fallback.tsc+ eslint clean. Not yet run against a live DB/subdomain. - Phase 4 — Ordering + orders dashboard.
- 4a —
MenuOrder(embedded items, snapshotted name/price, service fee); public guest ordering API (/api/menu/guest/order, resolves company from Host, computes totals server-side); staff orders API (/api/menu/ordersGET +[id]PATCH; POST came later, see Phase 6); “Orders” nav +/ordersdashboard with status flow (pending→preparing→ready→delivered, cancel) and 15s polling. i18n in en/ru/uz.tsc+ eslint clean. - 4b — guest cart UI:
GuestMenuClient(add-to-cart stepper, cart drawer, place order) wired into the guest route in place of the old read-only page; cart persists tolocalStoragescoped per hotel+room; a new publicGET /api/menu/guest/order/[id]lets the guest poll their placed order and see a live pending→preparing→ready→delivered step tracker (or a cancelled notice), reusing the dashboard’sORDER_STATUS_META. Page now also gates onHotelMenuSettings.menuEnabledand applies the service fee. i18n in en/ru/uz.tsc+ eslint clean.
- 4a —
- Phase 5 — Telegram for orders.
MenuTelegramTopic(one “Menu orders” forum topic per hotel, separate from bookings’ per-(hotel, service) topics since an order isn’t tied to a Service).ensureTopicForMenuOrders/notifyNewMenuOrder/notifyMenuOrderUpdatedinsrc/lib/telegram.ts. Guest order POST posts a new message and storestgChatId/tgMessageId/tgThreadIdon theMenuOrder; staff PATCH edits that message in place on every status change (pending→preparing→ready→delivered, cancel) — never a duplicate. Both fire post-response viaafter(), best-effort.tsc+ eslint clean. - [~] Phase 6 — Extras. (Each independent.)
- Room QR + PDF — “Print QR codes” on the dashboard Menu page opens
RoomQrModal: a live preview grid (qrcode.react) of every room’s QR, each encodinghttps://<company-slug>.<baseDomain>/<locale>/menu?hotel=<hotel-slug>&room=<number>(company slug read from the dashboard’s own/secure/company/<slug>/...path, base domain fromwindow.location.hostsame as the existing app/admin/super link-building pattern), plus a “Download PDF” button (qrcode+jspdf, 4 codes/page on A4). Disabled with a hint if the hotel has no URL slug yet.tsc+ eslint clean. - Recommendations of the day —
MenuRecommendation(already scaffolded in Phase 1) now wired end to end:/api/menu/recommendationsGET/POST +[id]DELETE; “Recommendations” button on the dashboard Menu page opensRecommendationsModal(Monday-first per-weekday cards, a dropdown to feature a product, chips to unfeature, reusing the existingdowMon…dowSuni18n keys); the guest page fetches today’s picks (nowUZ().getDay(), skipping any whose product is no longer available) andGuestMenuClientshows them in an auto-rotating, swipeableRecommendationBanner(plain CSS scroll-snap, no new animation dependency) above the category list.tsc+ eslint clean. - Staff-entered and scheduled orders — the third way an order is
placed, after the guest’s QR menu and the in-room TV, for the telephone call
and the conversation at reception that used to end with somebody typing the
order into Telegram by hand.
POST /api/menu/orders(session-scoped, hotel fromwriteHotelId) goes through the sameplaceMenuOrderas the other two — same price snapshot, same service fee, one History entry, one Telegram message — and stampsMenuOrder.placedBy, so a staff order is an ordinary order that records who took it.scheduledForputs one in the future: it stayspending(deliberately not a sixth status), the Orders board and the kitchen wall keep it out of the queue until its time, and/api/cron/menu-scheduled-orderscalls it out in the hotel’s group when it comes due — that cron line is required, and its absence is silent, seedeployment.md. The dashboard/ordersboard is now grouped into rows (scheduled · new · preparing · ready · delivered · cancelled) with an “Add order” form, and “Orders” is a Bronit Menu nav child. i18n in en/ru/uz. - Guest service requests (taxi/reception/alarm)
- Currency FX (approx. USD under each UZS price)
- AI auto-translate (admin enters one language, others filled via Claude API)
- SSE stream (replace polling on the guest tracker / staff orders dashboard)
- Room QR + PDF — “Print QR codes” on the dashboard Menu page opens
- Phase 7 — Cleanup / migration. Consolidate i18n, tests, docs; optional Prisma→Mongoose data migration.
Dependencies (added per phase, not upfront)
- Phase 6:
qrcode,qrcode.react,jspdf(QR/PDF); optional@anthropic-ai/sdk(AI translate),framer-motion(guest animations). - Validation: reuse Bronit’s existing approach unless
zodis later adopted.