Compare commits

..

24 Commits

Author SHA1 Message Date
soroush.asadi eb165db182 feat(offline): make every dashboard write durable offline (P2–P5)
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 38s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m11s
Builds on the outbox engine to take the whole dashboard offline in one place
instead of wiring 114 mutation sites individually.

Frontend (single chokepoint = the API client):
- offline-write: any write auto-queues to the outbox on offline/network failure
  and returns an optimistic value; the online path is unchanged apart from an
  Idempotency-Key header (so even online retries de-dup). entityType is derived
  from the URL; POSTs get a remappable local id.
- client.doWrite unifies POST/PUT/PATCH/DELETE through this path. WriteOptions
  gains `offline: "queue" | "reject" | "manual"`.
- Guardrails: auth / billing / payments / SMS / exports are online-only and throw
  OFFLINE_UNAVAILABLE offline rather than queueing (no queued double-charges or
  surprise SMS blasts). use-api-error resolves the friendly localized message
  (fa/en/ar).
- submit-order opts out ("manual") to keep its richer local-Order mock; shared
  helpers de-duplicated into offline-write.
- Request persistent storage on mount so unsynced writes survive eviction.

Backend:
- IdempotencyCleanupJob: daily purge of idempotency records older than 7 days
  (the table now gets a row per keyed write). Registered in Hangfire. No migration.

86 API tests pass; dashboard tsc + build clean.
2026-06-02 18:34:54 +03:30
soroush.asadi 3b468b48d9 feat(dashboard/offline): generic idempotent outbox + ID remapping
CI/CD / CI · API (dotnet build + test) (push) Successful in 48s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 53s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m12s
Completes offline Phase 1 (frontend). Generalises the POS-orders-only queue into
a reusable write engine and fixes the two correctness bugs in the old path.

- offline-db: generic `outbox` store (DB v3, order_queue/kv preserved) with
  enqueue/list/update/remove + a persisted client→server id map.
- outbox.ts: drains in causal order — remaps local_* ids to server ids (blocking
  an op until its creator syncs), sends each op with its idempotency key, and
  classifies failures (offline → stop; 5xx / in-progress → retry; 4xx → poison
  after 5 attempts). remap/blocked logic validated against representative cases.
- client: apiPost/Put/Patch/Delete take an optional idempotencyKey →
  `Idempotency-Key` header; ApiClientError now carries HTTP status.
- submit-order: generates ONE idempotency key per submit, used for both the
  online attempt and the queued replay → server de-dups (no more double-create);
  offline create carries createsClientId so a later add-items remaps onto the
  real order instead of spawning a second order.
- use-offline-sync: drains the outbox, one-time migrates legacy order_queue
  items, invalidates queries after a successful sync.

tsc + production build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:19:29 +03:30
soroush.asadi f4583f5169 feat(api/offline): idempotency-key middleware for safe write retries
Backend half of offline Phase 1. Lets the offline outbox replay a write after a
lost response without executing it twice (e.g. an order whose POST reached the
server but whose reply never came back).

- IdempotencyRecord entity + table (unique index on (Scope, Key)); migration
  AddIdempotencyRecords. Standalone POCO — no tenant/soft-delete filters.
- IdempotencyMiddleware (after TenantMiddleware, before plan-limit/controllers):
  opt-in via `Idempotency-Key` header on POST/PUT/PATCH/DELETE.
    * Completed key → replays stored status+body with `Idempotent-Replay: true`.
    * In-progress key → 409 IDEMPOTENCY_IN_PROGRESS; the unique index serializes
      racing first requests; stale (>60s) reservations are recovered after a crash.
    * Only <500 responses are cached; 5xx is released so the client can retry.
  Bookkeeping runs in isolated DI scopes so it never contaminates the controller's
  unit of work. Keys are scoped per café — no cross-tenant collisions.
- 5 middleware tests (replay/execute-once, distinct key, pass-through, tenant
  isolation, 5xx-not-cached). Full suite 86 passing.

Next in Phase 1: generalize the POS order queue into a generic client outbox that
sends these keys and remaps client→server ids.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 18:03:57 +03:30
soroush.asadi 132f0921e0 feat(dashboard/offline): persist React Query cache for offline reads
First slice of offline-first (Phase 1). Makes every dashboard area *viewable*
offline with last-synced data, instead of empty lists on an offline reload
(previously only next-pwa's 10-min API cache survived).

- offline-db: add a generic `kv` IndexedDB store (DB v2, preserves order_queue)
  with kvGet/kvSet/kvDelete; all degrade silently on quota/unavailable.
- query-persister: debounced snapshot of the React Query cache via
  dehydrate/hydrate (no new dependency). Restore is guarded by a schema buster,
  24h max-age, and a café scope so one tenant never hydrates another's data.
- providers: gcTime 24h so hydrated data isn't GC'd; restore on mount + persist
  on cache changes, re-scoped when the signed-in café changes.

No write-path changes; the existing POS order queue is untouched. Next in
Phase 1: generalize that queue into an idempotent outbox with client→server
ID remapping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:41:15 +03:30
soroush.asadi bb0be19dac feat(billing): queue subscriptions bought while active + cancel queued
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m1s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 49s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 3m9s
Before, buying a plan immediately switched the tier and stacked the duration.
Now a purchase made while the café still has paid coverage is QUEUED to start
when the current coverage ends, and the owner can cancel a queued one.

Model:
- SubscriptionPayment gains EffectiveFrom/EffectiveTo; status gains Scheduled
  (paid, queued) and Cancelled. EF migration AddSubscriptionScheduling (nullable).

BillingService:
- On payment completion, compute coverage end (latest of active expiry + furthest
  queued period). If it is in the future → Scheduled (queued, café tier/expiry
  untouched); else activate immediately as before. Periods chain correctly.
- GetStatusAsync lazily promotes any due queued period to active, and returns the
  queue (QueuedPlans).
- CancelQueuedAsync cancels a Scheduled period (owner-only) and re-packs the queue
  so later periods slide earlier. Active prepaid plan is never cut short; no
  automatic refund (manual, per product decision).
- Confirmation SMS distinguishes "activated until X" vs "queued, starts X".

API: BillingStatusDto.QueuedPlans + DELETE /api/billing/queued/{paymentId}.

Dashboard:
- Subscription screen shows a "Queued subscriptions" card (tier, window, cancel
  with confirm).
- Checkout shows "you already have an active subscription — this will start on
  {date}" when the café is still covered.
- i18n fa/en/ar.

81 API tests pass; dashboard typechecks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:44:32 +03:30
soroush.asadi 15def7ff1c feat: delete actions for warehouse/reservations/coupons/customers + Koja listing toggle
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m10s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 55s
CI/CD / Deploy · all services (push) Successful in 3m29s
Delete (every manageable entity that only had "add" now has delete):
- Ingredients (warehouse): new DELETE /inventory/ingredients/{id} (soft-delete via
  the global DeletedAt filter — no FK trouble with recipes/movements) + NoOp stub +
  trash button in the materials cards.
- Reservations: new DELETE /reservations/{id} (soft-delete) + per-card delete button.
- Coupons & Customers: backend DELETE already existed; wired delete buttons in the UI.
- Shared ConfirmDialog component used by all delete flows (RTL-aware).
- Audit result: tables/branches/taxes/kitchen-stations/expenses/menu/terminals already
  had delete; HR has no "add" so no delete needed; shifts intentionally excluded
  (financial open/close records, not add-style entities).

Koja visibility:
- New Cafe.ShowOnKoja flag, default TRUE (DB default true so existing cafés stay
  listed). Discover query now filters IsVerified && !Deleted && ShowOnKoja.
- public-profile GET/PUT expose showOnKoja; dashboard public-profile panel has an
  on-by-default toggle that persists immediately. Platform IsVerified gate unchanged.
- EF migration AddCafeShowOnKoja (defaultValue: true).

Also: added the missing errors.generic i18n key (fa/en/ar) so useApiError's fallback
resolves instead of rendering the literal "errors.generic". 81 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:14:40 +03:30
soroush.asadi 60e2ac1355 fix(auth): non-rotating, sliding refresh tokens to stop the OTP storm
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m53s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m37s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 1m8s
CI/CD / Deploy · all services (push) Successful in 1m40s
Login already issues a 7-day access token + 30-day refresh token, and the
dashboard persists the session and silently refreshes on 401 — so a session
should last well over a week. The real cause of "re-login every time / massive
OTP" was single-use refresh-token rotation: RefreshAsync revoked the presented
token and minted a new one, so when a café runs POS + KDS + queue display at
once (or two tabs), the first refresh won the race and every other concurrent
refresh hit the now-revoked token -> INVALID_TOKEN -> forced logout -> OTP.

Make refresh idempotent and race-safe:
- IssueTokensAsync takes an optional existingRefreshToken; on refresh we reuse
  the presented token and re-store it (sliding the 30-day TTL) instead of
  minting a new one. Login still mints a fresh token.
- RefreshAsync no longer revokes the presented token.

Net effect: concurrent refreshes all succeed; an active session slides forward
and effectively never forces re-auth. Access stays 7 days, refresh 30 days.
All 81 API tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:09:25 +03:30
soroush.asadi a37d93f6cd fix(ui): force dir=ltr on remaining RTL pill toggles
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m1s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 5m43s
The branch-menu-overrides availability switch (dashboard) and the BlogToggle
(admin website editor) still moved their knob with translate-x while inheriting
RTL, so the knob escaped the track on the right. Pin both to dir="ltr" like the
other switches. All four role="switch" toggles in the codebase now share the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:24:20 +03:30
soroush.asadi 7122df57b2 feat(menu): delete category/item + fix RTL availability toggle
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 58s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m18s
- Add DELETE /api/cafes/{cafeId}/menu/items/{id} (DeleteItemAsync soft-delete,
  mirroring the existing category delete) — item delete had no backend route.
- Dashboard menu admin: destructive "delete" action in the item and category
  edit modals, behind a shared confirm dialog (AlertDialog). Deleting the
  selected category falls back to "all items".
- Fix the availability ToggleSwitch in RTL: force dir="ltr" so the knob's
  translate-x stays inside the track instead of escaping on the right
  (same fix as the admin-panel toggles).
- i18n: deleteItem/deleteCategory confirm + success strings (fa/en/ar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:24:09 +03:30
soroush.asadi 72f95aa0db fix(demo-seed): stop truncating ingredient/table ids to 36 chars
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m9s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 47s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 1m24s
BuildDemoIngredients/BuildDemoTables built ids as
"{cafeId}_ing_{guid}"[..36]. For a real cafe (32-char hex id) the
first 36 chars are just "{cafeId}_ing" — the unique guid is cut off,
so all 15 ingredients (and all 10 tables) get the SAME id, causing a
primary-key collision on SaveChanges -> 500. cafe_demo_001 has a short
id so the guid survived, which is why the bug only hit real cafes.

The Id columns are text (no length limit), so the truncation served no
purpose. Removed [..36] from both so the full unique id is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:55:06 +03:30
soroush.asadi bab3453e41 fix(auth): read role claim under mapped name so Owner/Manager gates work
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m27s
ROOT CAUSE of demo-seed/billing/etc. returning 403 for real owners: .NET's JWT
handler remaps the short "role" claim to ClaimTypes.Role on inbound, so
TenantMiddleware's FindFirst("role") returned null and tenant.Role (EmployeeRole?)
stayed null. EnsureManager/EnsureOwner then rejected even a valid Owner token with
MANAGER_REQUIRED / OWNER_REQUIRED, while reads (no role gate) worked and
[Authorize(Roles=...)] worked (it reads the remapped claim). Now reads the role
under both MeeziClaimTypes.Role ("role") and ClaimTypes.Role. Same fix applied to
the AuthController whoami role. Fixes demo seed, subscription billing, and every
other tenant.Role-gated action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 11:18:10 +03:30
soroush.asadi 24da1e0522 feat(orders): per-item kitchen/bar notes (POS + QR app + KDS)
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 57s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m43s
Lets the POS agent and the QR/app customer attach a free-text note to each
order line (e.g. "no tomato", "extra hot") that reaches the kitchen/bar.

- Backend already supported it (OrderItem.Notes persists; CreateOrderItemRequest
  and OrderItemDto carry Notes; LiveOrderDto items include it) — this wires the UI.
- cart.store: add setNotes(menuItemId, notes); notes already travel in
  getPendingLines and round-trip via hydrateFromOrder.
- POS pos-screen: a note input under each cart line.
- QR guest menu: a note input under each cart line (QrCartLine.note).
- KDS: render the note prominently under each item so kitchen/bar sees it.
- i18n: pos.itemNotePlaceholder + qrMenu.itemNote (fa/ar/en).

Note: notes are captured on items being added; editing a note on an
already-submitted line is out of scope (no pending delta to re-send).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:37:59 +03:30
soroush.asadi 2203ecbdaf fix(koja): remove over-broad short-URL redirect that 500'd the home page
CI/CD / CI · API (dotnet build + test) (push) Failing after 3m18s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 2m22s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Has been skipped
The redirect source "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])" matched single-segment
paths including the locale itself, so /fa redirected to /fa/cafe/fa (slug "fa")
and /en to /fa/cafe/en — non-existent cafés that returned Internal Server Error.
Visiting koja.meezi.ir (-> /fa) hit this. Removed the redirect so the home page
renders; short café URLs can be re-added via middleware with reserved-word guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:29:27 +03:30
soroush.asadi 1aaab6c593 fix(admin): integrations save uses rendered list (fixes dropped Zarinpal merchantId)
CI/CD / CI · API (dotnet build + test) (push) Successful in 58s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m42s
The integrations form rendered from  (gateways state, falling back to fetched data) but SAVED from the  state and edited via updateGateway on . If gateways hadn't hydrated, edits (e.g. Zarinpal merchantId) were written to an empty array and the save sent nothing. Now updateGateway seeds from fetched data on first edit, and the save maps over  — render, edit, and save share one source. NOTE: prod admin had also been stale because recent deploys aborted on the main-API crash before the admin containers restarted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 08:15:16 +03:30
soroush.asadi 09bba5f8cd fix(seed): count soft-deleted rows + make platform seeding non-fatal
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 1m20s
Root cause of the crash-loop: a soft-deleted Free plan still occupies its Tier in the unique index, but the existing-row check queried THROUGH the soft-delete global filter and missed it, so the seeder re-inserted Free and violated IX_PlatformPlanDefinitions_Tier on boot. Fixes: (1) IgnoreQueryFilters() on the plan/feature existing-checks so soft-deleted tiers/keys are counted; (2) wrap plan/feature/location seeding in try/catch so any seeding failure logs and startup continues — non-essential seeding must never crash-loop the API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:26:11 +03:30
soroush.asadi 3b8dcf3af6 fix(seed): dedupe plans by Tier and features by Key (hotfix crash-loop)
CI/CD / CI · API (dotnet build + test) (push) Successful in 2m39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m2s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m27s
The previous change deduped on Id, but the unique constraints are on PlatformPlanDefinitions.Tier and PlatformFeatures.Key. Prod's existing Free plan has a different Id, so seeding re-inserted a Free-tier row and crashed on IX_PlatformPlanDefinitions_Tier (23505), crash-looping the API. Now skips any tier/key that already exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:11:42 +03:30
soroush.asadi 087563bce7 feat(settings): use-my-current-location button; surface ticket-load error
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / Deploy · all services (push) Failing after 2m34s
Location card gets a 'موقعیت فعلی من' button that fills lat/lng from the browser's geolocation. Support ticket list now shows the resolved (localized) error instead of a generic message, so a failure is diagnosable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:52:29 +03:30
soroush.asadi e839db7331 fix(koja): default to fa (no browser locale guess); guard null discoverProfile
Koja auto-detected locale from the browser Accept-Language (en for many Persian users); set localeDetection:false so locale-less URLs default to fa. Also guarded cafe.discoverProfile across the cafe page, cafe card, and JSON-LD — a café without a discover profile crashed the page (500). The cafe page now resolves the café first and notFound()s an unknown slug before fetching menu/reviews.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 01:51:50 +03:30
soroush.asadi a83edf7667 fix: seed all plans/features in prod (upsert); fix admin toggle RTL knob
CI/CD / CI · API (dotnet build + test) (push) Successful in 50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 39s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Failing after 5m44s
Plan + feature seeding was dev-gated and all-or-nothing, so production only had the Free plan (admin Plans page showed one). Now runs in every environment and upserts missing rows (adds Pro/Business/Enterprise on top of the existing Free). Also force LTR on the admin toggle switch so the knob doesn't render off-track under the RTL page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:23:17 +03:30
soroush.asadi 75d5bbc84a fix(i18n): localize API error messages by code (no more raw English)
Error toasts surfaced the raw English backend message. Added an errors namespace (fa/ar/en) keyed by error code + a useApiError() resolver that maps ApiClientError.code to the localized message (fallback to a localized generic). Wired into menu, tables, demo banner, and subscription checkout; hardened getErrorMessage so it never returns the raw backend message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 00:04:48 +03:30
soroush.asadi 7519f474f3 fix(demo): allow Manager to seed demo data + surface seed errors
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m1s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 46s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m15s
The dashboard demo-data banner is shown to Owner and Manager, but the /demo/seed endpoint required strictly Owner, so a Manager clicking it got a silent 403 (the banner had no error handler) — appearing as 'nothing happens, no tables or items'. The endpoint now allows Owner or Manager, and the banner shows the error on failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:23:39 +03:30
soroush.asadi 35494d8b32 fix(i18n): keep locale on website->dashboard links; dashboard defaults to fa
Marketing-site login/register/dashboard links were locale-less (app.meezi.ir/login), so the dashboard auto-detected locale from the browser Accept-Language (en-US) and redirected Persian users to /en. Links now carry the current locale, and the dashboard sets localeDetection:false so any locale-less entry defaults to fa (Iran-first) instead of guessing from the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 23:23:09 +03:30
soroush.asadi 4c7783884c feat(map): backfill café coordinates from city on startup (prod-safe)
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 1m40s
Real cafés without a map pin now get approximate coordinates at their city centre (with a deterministic per-café offset) on every boot, in all environments, so the public Iran map lights up with merchant dots. Only fills rows where Latitude/Longitude is null and the city is recognised (20 major Iranian cities); never overwrites an owner-set pin. Owners can drop an exact pin from Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:38:28 +03:30
soroush.asadi 8ce0b3e3e8 feat(discover): seed showcase café coordinates so the map shows blinking lights
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 4m11s
Showcase cafés (dev/staging only) now get Latitude/Longitude scattered around their real city (Tehran/Karaj) with a deterministic per-id offset, so the homepage Iran map renders a realistic cluster of blinking merchant lights. Backfills existing rows where coords are null. Production cafés get coordinates when owners set their location in dashboard Settings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:00:14 +03:30
80 changed files with 12801 additions and 347 deletions
+4 -1
View File
@@ -198,7 +198,10 @@ public class AuthController : ControllerBase
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
UserId: userId, UserId: userId,
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty, CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty, // .NET remaps the short "role" claim to ClaimTypes.Role on inbound; read both.
Role: User.FindFirstValue(MeeziClaimTypes.Role)
?? User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)
?? string.Empty,
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty, PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty, Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant, Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
return Ok(new ApiResponse<BillingStatusDto>(true, data)); return Ok(new ApiResponse<BillingStatusDto>(true, data));
} }
[Authorize]
[HttpDelete("api/billing/queued/{paymentId}")]
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
{
if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized();
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
if (!ok)
{
return code == "NOT_FOUND"
? NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found.")))
: BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<object>(true, new { id = paymentId }));
}
} }
@@ -57,7 +57,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery, gallery,
cafe.InstagramHandle, cafe.InstagramHandle,
cafe.WebsiteUrl, cafe.WebsiteUrl,
ToHoursDto(hours)))); ToHoursDto(hours),
cafe.ShowOnKoja)));
} }
// ── PUT (description / social / hours) ─────────────────────────────────── // ── PUT (description / social / hours) ───────────────────────────────────
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
if (request.WorkingHours is not null) if (request.WorkingHours is not null)
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts); cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
// Koja (public discovery) listing preference
if (request.ShowOnKoja.HasValue)
cafe.ShowOnKoja = request.ShowOnKoja.Value;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? []; var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery, gallery,
cafe.InstagramHandle, cafe.InstagramHandle,
cafe.WebsiteUrl, cafe.WebsiteUrl,
ToHoursDto(hours)))); ToHoursDto(hours),
cafe.ShowOnKoja)));
} }
// ── POST gallery/upload ─────────────────────────────────────────────────── // ── POST gallery/upload ───────────────────────────────────────────────────
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
string? Description, string? Description,
string? InstagramHandle, string? InstagramHandle,
string? WebsiteUrl, string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours); WorkingHoursPublicDto? WorkingHours,
bool? ShowOnKoja = null);
public record CafeProfileEditDto( public record CafeProfileEditDto(
string? Description, string? Description,
IReadOnlyList<string> GalleryUrls, IReadOnlyList<string> GalleryUrls,
string? InstagramHandle, string? InstagramHandle,
string? WebsiteUrl, string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours); WorkingHoursPublicDto? WorkingHours,
bool ShowOnKoja);
public record GalleryDto(IReadOnlyList<string> GalleryUrls); public record GalleryDto(IReadOnlyList<string> GalleryUrls);
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied; // Demo data is a setup helper; Owner or Manager may run it (matches the
// dashboard banner, which is shown to both roles).
if (EnsureManager(tenant) is { } managerDenied) return managerDenied;
var result = await _demoSeed.SeedAsync(cafeId, ct); var result = await _demoSeed.SeedAsync(cafeId, ct);
return Ok(new ApiResponse<DemoSeedResult>(true, result)); return Ok(new ApiResponse<DemoSeedResult>(true, result));
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
return Ok(new ApiResponse<object>(true, updated)); return Ok(new ApiResponse<object>(true, updated));
} }
[HttpDelete("ingredients/{ingredientId}")]
public async Task<IActionResult> Delete(
string cafeId,
string ingredientId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
}
[HttpPost("ingredients/{ingredientId}/adjust")] [HttpPost("ingredients/{ingredientId}/adjust")]
public async Task<IActionResult> Adjust( public async Task<IActionResult> Adjust(
string cafeId, string cafeId,
@@ -163,6 +163,15 @@ public class MenuController : CafeApiControllerBase
return Ok(new ApiResponse<MenuItemDto>(true, data)); return Ok(new ApiResponse<MenuItemDto>(true, data));
} }
[HttpDelete("items/{id}")]
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
[HttpGet("ai-3d/usage")] [HttpGet("ai-3d/usage")]
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{ {
@@ -66,6 +66,19 @@ public class ReservationsController : CafeApiControllerBase
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<ReservationDto>(true, data)); return Ok(new ApiResponse<ReservationDto>(true, data));
} }
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
} }
public record UpdateReservationStatusRequest(ReservationStatus Status); public record UpdateReservationStatusRequest(ReservationStatus Status);
@@ -215,6 +215,9 @@ public static class ServiceCollectionExtensions
app.UseMeeziSecurity(); app.UseMeeziSecurity();
app.UseAuthentication(); app.UseAuthentication();
app.UseMiddleware<Middleware.TenantMiddleware>(); app.UseMiddleware<Middleware.TenantMiddleware>();
// After tenant context (keys are scoped per café), before plan-limit + controllers
// so a replayed write short-circuits without re-consuming limits or re-executing.
app.UseMiddleware<Middleware.IdempotencyMiddleware>();
app.UseMiddleware<Middleware.PlanLimitMiddleware>(); app.UseMiddleware<Middleware.PlanLimitMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
@@ -242,6 +245,11 @@ public static class ServiceCollectionExtensions
"branch-permanent-delete", "branch-permanent-delete",
job => job.ExecuteAsync(), job => job.ExecuteAsync(),
Cron.Hourly); Cron.Hourly);
RecurringJob.AddOrUpdate<IdempotencyCleanupJob>(
"idempotency-cleanup",
job => job.ExecuteAsync(),
Cron.Daily(4));
} }
return app; return app;
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Jobs;
/// <summary>
/// Purges old idempotency records. Keys only need to outlive realistic offline
/// gaps and client retries, so a short retention keeps the table small.
/// </summary>
public class IdempotencyCleanupJob
{
private static readonly TimeSpan Retention = TimeSpan.FromDays(7);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<IdempotencyCleanupJob> _logger;
public IdempotencyCleanupJob(
IServiceScopeFactory scopeFactory,
ILogger<IdempotencyCleanupJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
public async Task ExecuteAsync()
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var cutoff = DateTime.UtcNow - Retention;
var removed = await db.IdempotencyRecords
.Where(r => r.CreatedAt < cutoff)
.ExecuteDeleteAsync();
if (removed > 0)
_logger.LogInformation("Purged {Count} idempotency records older than {Days}d", removed, Retention.TotalDays);
}
}
@@ -0,0 +1,188 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Middleware;
/// <summary>
/// Makes mutating requests safe to retry. A client (e.g. the offline outbox)
/// attaches an <c>Idempotency-Key</c> header; if the same key is seen again, the
/// original response is replayed instead of executing the write twice.
///
/// Bookkeeping runs in isolated DI scopes so it never mixes with the controller's
/// own DbContext unit of work. Opt-in via header → non-idempotent and binary/file
/// endpoints are unaffected unless the client explicitly sends a key.
/// </summary>
public class IdempotencyMiddleware
{
private const string HeaderName = "Idempotency-Key";
private const int MaxKeyLength = 200;
private const int MaxStoredBodyBytes = 256 * 1024;
/// <summary>An InProgress record older than this is assumed crashed mid-flight and re-run.</summary>
private static readonly TimeSpan StaleInProgress = TimeSpan.FromSeconds(60);
private readonly RequestDelegate _next;
private readonly ILogger<IdempotencyMiddleware> _logger;
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, IServiceScopeFactory scopeFactory)
{
var method = context.Request.Method;
var isMutating = HttpMethods.IsPost(method) || HttpMethods.IsPut(method)
|| HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method);
if (!isMutating || !context.Request.Headers.TryGetValue(HeaderName, out var headerValues))
{
await _next(context);
return;
}
var key = headerValues.ToString();
if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength)
{
// Unusable key — behave as if it wasn't sent rather than reject the write.
await _next(context);
return;
}
var scope = string.IsNullOrEmpty(tenant.CafeId) ? "global" : tenant.CafeId;
var path = context.Request.Path.Value ?? string.Empty;
// 1) Look for an existing record for this (tenant, key).
await using (var lookupScope = scopeFactory.CreateAsyncScope())
{
var db = lookupScope.ServiceProvider.GetRequiredService<AppDbContext>();
var existing = await db.IdempotencyRecords.AsNoTracking()
.FirstOrDefaultAsync(r => r.Scope == scope && r.Key == key, context.RequestAborted);
if (existing is not null)
{
if (existing.Status == IdempotencyStatus.Completed)
{
await ReplayAsync(context, existing);
return;
}
if (DateTime.UtcNow - existing.CreatedAt < StaleInProgress)
{
await WriteConflictAsync(context); // genuine concurrent duplicate
return;
}
// Stale reservation (process likely crashed mid-flight) — drop and re-run.
_logger.LogWarning("Recovering stale idempotency reservation {Key} for scope {Scope}", key, scope);
var stale = await db.IdempotencyRecords
.FirstOrDefaultAsync(r => r.Id == existing.Id, context.RequestAborted);
if (stale is not null)
{
db.IdempotencyRecords.Remove(stale);
await db.SaveChangesAsync(context.RequestAborted);
}
}
}
// 2) Reserve the key. The unique (Scope, Key) index serializes racing first requests.
var record = new IdempotencyRecord
{
Scope = scope,
Key = key,
Method = method,
Path = path,
Status = IdempotencyStatus.InProgress,
};
try
{
await using var reserveScope = scopeFactory.CreateAsyncScope();
var db = reserveScope.ServiceProvider.GetRequiredService<AppDbContext>();
db.IdempotencyRecords.Add(record);
await db.SaveChangesAsync(context.RequestAborted);
}
catch (DbUpdateException)
{
await WriteConflictAsync(context); // another request won the reservation race
return;
}
// 3) Run the real request, capturing its response.
var originalBody = context.Response.Body;
await using var buffer = new MemoryStream();
context.Response.Body = buffer;
try
{
await _next(context);
}
catch
{
context.Response.Body = originalBody;
await DeleteAsync(scopeFactory, record.Id);
throw;
}
var statusCode = context.Response.StatusCode;
buffer.Position = 0;
var bytes = buffer.ToArray();
context.Response.Body = originalBody;
if (bytes.Length > 0)
await originalBody.WriteAsync(bytes, context.RequestAborted);
// 4) Persist the result so retries replay it — except 5xx, which is transient and
// released so the client can retry the same key.
if (statusCode is >= 200 and < 500)
{
var storedBody = bytes.Length is > 0 and <= MaxStoredBodyBytes
? Encoding.UTF8.GetString(bytes)
: null;
await CompleteAsync(scopeFactory, record.Id, statusCode, storedBody);
}
else
{
await DeleteAsync(scopeFactory, record.Id);
}
}
private static async Task ReplayAsync(HttpContext context, IdempotencyRecord record)
{
context.Response.StatusCode = record.ResponseStatusCode;
context.Response.ContentType = "application/json; charset=utf-8";
context.Response.Headers["Idempotent-Replay"] = "true";
if (!string.IsNullOrEmpty(record.ResponseBody))
await context.Response.WriteAsync(record.ResponseBody);
}
private static async Task WriteConflictAsync(HttpContext context)
{
context.Response.StatusCode = StatusCodes.Status409Conflict;
context.Response.ContentType = "application/json; charset=utf-8";
await context.Response.WriteAsync(
"{\"success\":false,\"data\":null,\"error\":{\"code\":\"IDEMPOTENCY_IN_PROGRESS\",\"message\":\"A request with this key is still being processed.\"}}");
}
private static async Task CompleteAsync(IServiceScopeFactory f, string id, int status, string? body)
{
await using var s = f.CreateAsyncScope();
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
if (rec is null) return;
rec.Status = IdempotencyStatus.Completed;
rec.ResponseStatusCode = status;
rec.ResponseBody = body;
rec.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
}
private static async Task DeleteAsync(IServiceScopeFactory f, string id)
{
await using var s = f.CreateAsyncScope();
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
if (rec is null) return;
db.IdempotencyRecords.Remove(rec);
await db.SaveChangesAsync();
}
}
+6 -1
View File
@@ -92,7 +92,12 @@ public class TenantMiddleware
{ {
scopedMerchant.CafeId = cafeId; scopedMerchant.CafeId = cafeId;
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value; // .NET's JWT handler remaps the short "role" claim to ClaimTypes.Role
// on inbound, so FindFirst("role") returns null and tenant.Role would
// stay null — making EnsureManager/EnsureOwner reject even a real owner.
// Read both the raw claim and the mapped one.
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role)) if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
scopedMerchant.Role = role; scopedMerchant.Role = role;
+10 -1
View File
@@ -22,6 +22,15 @@ public record BillingStatusDto(
int MenuAi3dUsedThisMonth, int MenuAi3dUsedThisMonth,
int MenuAi3dMonthlyLimit, int MenuAi3dMonthlyLimit,
bool DiscoverProfileEnabled, bool DiscoverProfileEnabled,
bool IsPlanExpired); bool IsPlanExpired,
IReadOnlyList<QueuedPlanDto> QueuedPlans);
public record QueuedPlanDto(
string PaymentId,
PlanTier PlanTier,
int Months,
DateTime EffectiveFrom,
DateTime EffectiveTo,
decimal AmountToman);
public record BillingVerifyResult(bool Success, string RedirectUrl); public record BillingVerifyResult(bool Success, string RedirectUrl);
+14 -4
View File
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
if (employee?.Cafe is null) if (employee?.Cafe is null)
return (false, null, "NOT_FOUND", "User no longer exists."); return (false, null, "NOT_FOUND", "User no longer exists.");
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken); // Note: we intentionally do NOT revoke the presented refresh token here.
// It is reused (with a slid TTL) so concurrent refreshes from multiple
// tabs/devices stay valid instead of racing each other into a logout.
var allMemberships = await _db.Employees var allMemberships = await _db.Employees
.Include(e => e.Cafe) .Include(e => e.Cafe)
@@ -265,7 +267,9 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken); var tokens = await IssueTokensAsync(
employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken,
existingRefreshToken: request.RefreshToken);
return (true, tokens, null, null); return (true, tokens, null, null);
} }
@@ -510,12 +514,18 @@ public class AuthService : IAuthService
Core.Entities.Cafe cafe, Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships, List<CafeMembershipDto>? memberships,
string? requestedBranchId, string? requestedBranchId,
CancellationToken cancellationToken) CancellationToken cancellationToken,
string? existingRefreshToken = null)
{ {
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken); var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId); var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
var refreshToken = _jwtTokenService.CreateRefreshToken(); // On refresh, reuse the caller's refresh token (and slide its TTL below) instead
// of minting a new one. A café often runs POS + KDS + queue display at once; if
// refresh rotated the token, the first refresh would revoke it and every other
// concurrent refresh would get INVALID_TOKEN → forced logout → OTP storm.
// Mint a fresh token only on a real login (existingRefreshToken == null).
var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
await _refreshTokenStore.StoreAsync( await _refreshTokenStore.StoreAsync(
+148 -10
View File
@@ -35,6 +35,11 @@ public interface IBillingService
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default); Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
string cafeId,
string paymentId,
CancellationToken cancellationToken = default);
} }
public class BillingService : IBillingService public class BillingService : IBillingService
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
return new BillingVerifyResult(false, failUrl); return new BillingVerifyResult(false, failUrl);
} }
payment.Status = SubscriptionPaymentStatus.Completed;
payment.RefId = verify.RefId; payment.RefId = verify.RefId;
var cafe = payment.Cafe; var cafe = payment.Cafe;
cafe.PlanTier = payment.PlanTier; var now = DateTime.UtcNow;
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
? cafe.PlanExpiresAt.Value // Where does the current paid coverage end? = the latest of the active plan's expiry
: DateTime.UtcNow; // and the furthest-out already-queued period. A new purchase is appended to that.
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months); var coverageEnd = await ComputeCoverageEndAsync(cafe, payment.Id, now, cancellationToken);
payment.EffectiveFrom = coverageEnd;
payment.EffectiveTo = coverageEnd.AddMonths(payment.Months);
var queued = coverageEnd > now;
if (queued)
{
// The owner already has active/queued coverage → book this one after it.
payment.Status = SubscriptionPaymentStatus.Scheduled;
}
else
{
// No active coverage → activate immediately.
payment.Status = SubscriptionPaymentStatus.Completed;
cafe.PlanTier = payment.PlanTier;
cafe.PlanExpiresAt = payment.EffectiveTo;
}
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken); await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
return new BillingVerifyResult(true, successUrl); return new BillingVerifyResult(true, successUrl);
} }
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
/// and the furthest-out scheduled (queued) period. Returns <paramref name="now"/> if neither
/// extends past now (i.e. nothing active/queued).</summary>
private async Task<DateTime> ComputeCoverageEndAsync(
Cafe cafe, string? excludePaymentId, DateTime now, CancellationToken ct)
{
var end = now;
if (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > end)
end = cafe.PlanExpiresAt.Value;
var lastScheduledEnd = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafe.Id
&& p.Status == SubscriptionPaymentStatus.Scheduled
&& (excludePaymentId == null || p.Id != excludePaymentId)
&& p.EffectiveTo != null)
.OrderByDescending(p => p.EffectiveTo)
.Select(p => p.EffectiveTo)
.FirstOrDefaultAsync(ct);
if (lastScheduledEnd.HasValue && lastScheduledEnd.Value > end)
end = lastScheduledEnd.Value;
return end;
}
/// <summary>When the active plan has lapsed, promote due queued periods to active.
/// Loops so a fully-elapsed short queued period doesn't strand the next one.</summary>
private async Task PromoteDueScheduledAsync(string cafeId, CancellationToken ct)
{
var now = DateTime.UtcNow;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return;
var changed = false;
while (!(cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now))
{
var next = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafeId
&& p.Status == SubscriptionPaymentStatus.Scheduled
&& p.EffectiveFrom != null && p.EffectiveFrom <= now)
.OrderBy(p => p.EffectiveFrom)
.FirstOrDefaultAsync(ct);
if (next is null) break;
cafe.PlanTier = next.PlanTier;
cafe.PlanExpiresAt = next.EffectiveTo;
next.Status = SubscriptionPaymentStatus.Completed;
changed = true;
}
if (changed) await _db.SaveChangesAsync(ct);
}
public async Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
string cafeId,
string paymentId,
CancellationToken cancellationToken = default)
{
var payment = await _db.SubscriptionPayments
.FirstOrDefaultAsync(p => p.Id == paymentId && p.CafeId == cafeId, cancellationToken);
if (payment is null)
return (false, "NOT_FOUND", "Subscription not found.");
// Only a queued (not-yet-started) subscription can be cancelled. The active prepaid
// plan keeps running until its paid time ends.
if (payment.Status != SubscriptionPaymentStatus.Scheduled)
return (false, "NOT_CANCELLABLE", "Only a queued subscription can be cancelled.");
payment.Status = SubscriptionPaymentStatus.Cancelled;
await _db.SaveChangesAsync(cancellationToken);
// Re-pack the remaining queue so later periods slide earlier to fill the gap.
await RecomputeQueueAsync(cafeId, cancellationToken);
return (true, null, null);
}
/// <summary>Re-sequences the remaining queued periods contiguously after the active plan
/// (purchase order preserved), so cancelling one in the middle doesn't leave a gap.</summary>
private async Task RecomputeQueueAsync(string cafeId, CancellationToken ct)
{
var now = DateTime.UtcNow;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return;
var anchor = (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now)
? cafe.PlanExpiresAt.Value
: now;
var scheduled = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled)
.OrderBy(p => p.CreatedAt)
.ToListAsync(ct);
foreach (var s in scheduled)
{
s.EffectiveFrom = anchor;
s.EffectiveTo = anchor.AddMonths(s.Months);
anchor = s.EffectiveTo.Value;
}
if (scheduled.Count > 0) await _db.SaveChangesAsync(ct);
}
public async Task<BillingStatusDto?> GetStatusAsync( public async Task<BillingStatusDto?> GetStatusAsync(
string cafeId, string cafeId,
PlanTier currentTier, PlanTier currentTier,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Lazily activate any queued plan whose start date has passed before reading status.
await PromoteDueScheduledAsync(cafeId, cancellationToken);
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken); var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null) return null; if (cafe is null) return null;
var queuedPlans = await _db.SubscriptionPayments.AsNoTracking()
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled
&& p.EffectiveFrom != null && p.EffectiveTo != null)
.OrderBy(p => p.EffectiveFrom)
.Select(p => new QueuedPlanDto(
p.Id, p.PlanTier, p.Months, p.EffectiveFrom!.Value, p.EffectiveTo!.Value, p.AmountToman))
.ToListAsync(cancellationToken);
var todayStart = DateTime.UtcNow.Date; var todayStart = DateTime.UtcNow.Date;
var ordersToday = await _db.Orders.CountAsync( var ordersToday = await _db.Orders.CountAsync(
o => o.CafeId == cafeId && o.CreatedAt >= todayStart, o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
@@ -278,12 +413,14 @@ public class BillingService : IBillingService
ai3dUsedCount, ai3dUsedCount,
ai3dLimit, ai3dLimit,
discoverProfile, discoverProfile,
isExpired); isExpired,
queuedPlans);
} }
private async Task TrySendConfirmationSmsAsync( private async Task TrySendConfirmationSmsAsync(
Cafe cafe, Cafe cafe,
SubscriptionPayment payment, SubscriptionPayment payment,
bool queued,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var ownerPhone = await _db.Employees var ownerPhone = await _db.Employees
@@ -293,8 +430,9 @@ public class BillingService : IBillingService
if (string.IsNullOrEmpty(ownerPhone)) return; if (string.IsNullOrEmpty(ownerPhone)) return;
var message = var message = queued
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت"; ? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز می‌شود. مبلغ: {payment.AmountToman:N0} ت"
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
try try
{ {
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken); await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
+7 -2
View File
@@ -130,7 +130,10 @@ public class DemoSeedService : IDemoSeedService
decimal qty, decimal reorder, decimal cost, decimal par) => decimal qty, decimal reorder, decimal cost, decimal par) =>
new() new()
{ {
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36], // No [..36] truncation: Id is a text column, and truncating to 36 chars
// cuts off the unique guid for real (32-char) café ids → every row gets
// the same id → PK collision → 500. Keep the full unique id.
Id = $"{cafeId}_ing_{Guid.NewGuid():N}",
CafeId = cafeId, CafeId = cafeId,
Name = name, Name = name,
Unit = unit, Unit = unit,
@@ -160,7 +163,9 @@ public class DemoSeedService : IDemoSeedService
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) => string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
new() new()
{ {
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36], // No [..36] truncation (see Ingredient above): truncating cuts the guid
// for real 32-char café ids → identical ids → PK collision → 500.
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}",
CafeId = cafeId, CafeId = cafeId,
BranchId = branchId, BranchId = branchId,
Number = number, Number = number,
@@ -89,6 +89,7 @@ public interface IInventoryService
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default); Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default); Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default); Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
Task<IngredientDto?> AdjustAsync( Task<IngredientDto?> AdjustAsync(
string cafeId, string cafeId,
string ingredientId, string ingredientId,
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
return ToDto(entity); return ToDto(entity);
} }
public async Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default)
{
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
if (entity is null) return false;
// Soft delete: Ingredient has a global DeletedAt query filter, so it (and its
// recipe lines / stock movements) drop out of every query without FK trouble.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return true;
}
public async Task<IngredientDto?> AdjustAsync( public async Task<IngredientDto?> AdjustAsync(
string cafeId, string cafeId,
string ingredientId, string ingredientId,
+11
View File
@@ -16,6 +16,7 @@ public interface IMenuService
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default); Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default); Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default); Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
} }
public class MenuService : IMenuService public class MenuService : IMenuService
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
return ToItemDto(entity); return ToItemDto(entity);
} }
public async Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default)
{
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
if (entity is null) return false;
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return true;
}
private static string? NormalizeOptionalText(string? value) => private static string? NormalizeOptionalText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -24,6 +24,11 @@ public interface IReservationService
string reservationId, string reservationId,
ReservationStatus status, ReservationStatus status,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default);
} }
public class ReservationService : IReservationService public class ReservationService : IReservationService
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
return Map(entity); return Map(entity);
} }
public async Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default)
{
var entity = await _db.TableReservations
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
if (entity is null) return false;
// Soft delete: TableReservation has a global DeletedAt query filter.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (!string.IsNullOrEmpty(entity.TableId))
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
return true;
}
internal static ReservationDto Map(TableReservation r) => new( internal static ReservationDto Map(TableReservation r) => new(
r.Id, r.Id,
r.CafeId, r.CafeId,
+1 -1
View File
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
DiscoverFilterParams filters, DiscoverFilterParams filters,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null); var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null && c.ShowOnKoja);
if (!string.IsNullOrWhiteSpace(filters.City)) if (!string.IsNullOrWhiteSpace(filters.City))
query = query.Where(c => c.City != null && c.City.Contains(filters.City)); query = query.Where(c => c.City != null && c.City.Contains(filters.City));
+3
View File
@@ -17,6 +17,9 @@ public class Cafe : BaseEntity
public PlanTier PlanTier { get; set; } = PlanTier.Free; public PlanTier PlanTier { get; set; } = PlanTier.Free;
public DateTime? PlanExpiresAt { get; set; } public DateTime? PlanExpiresAt { get; set; }
public bool IsVerified { get; set; } public bool IsVerified { get; set; }
/// <summary>Owner preference: list this café on Koja (public discovery). Defaults true so a
/// verified café is discoverable out of the box; the owner can opt out from settings.</summary>
public bool ShowOnKoja { get; set; } = true;
/// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary> /// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary>
public bool IsSuspended { get; set; } public bool IsSuspended { get; set; }
public string? SnappfoodVendorId { get; set; } public string? SnappfoodVendorId { get; set; }
@@ -0,0 +1,31 @@
using Meezi.Core.Enums;
namespace Meezi.Core.Entities;
/// <summary>
/// Records a client-supplied Idempotency-Key so a retried write (e.g. an order
/// replayed from the offline outbox after a lost response) returns the original
/// result instead of executing twice. Standalone POCO — deliberately not a
/// TenantEntity, to avoid soft-delete/tenant query filters.
/// </summary>
public class IdempotencyRecord
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
/// <summary>Tenant scope (CafeId), or "global" for non-tenant requests.</summary>
public string Scope { get; set; } = "global";
/// <summary>The client-supplied Idempotency-Key header value.</summary>
public string Key { get; set; } = string.Empty;
public string Method { get; set; } = string.Empty;
public string Path { get; set; } = string.Empty;
public IdempotencyStatus Status { get; set; } = IdempotencyStatus.InProgress;
public int ResponseStatusCode { get; set; }
public string? ResponseBody { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
@@ -13,5 +13,13 @@ public class SubscriptionPayment : TenantEntity
public string? RefId { get; set; } public string? RefId { get; set; }
public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending; public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending;
/// <summary>When this paid period starts. For an immediately-activated purchase this is
/// (around) the payment time; for a queued (Scheduled) purchase it is the end of the
/// current coverage. Null until the payment completes.</summary>
public DateTime? EffectiveFrom { get; set; }
/// <summary>When this paid period ends (EffectiveFrom + Months). Null until completed.</summary>
public DateTime? EffectiveTo { get; set; }
public Cafe Cafe { get; set; } = null!; public Cafe Cafe { get; set; } = null!;
} }
@@ -0,0 +1,9 @@
namespace Meezi.Core.Enums;
public enum IdempotencyStatus
{
/// <summary>Reserved; the original request is still executing.</summary>
InProgress = 0,
/// <summary>Finished; the stored response is replayed on duplicate keys.</summary>
Completed = 1
}
@@ -4,5 +4,9 @@ public enum SubscriptionPaymentStatus
{ {
Pending = 0, Pending = 0,
Completed = 1, Completed = 1,
Failed = 2 Failed = 2,
/// <summary>Paid, but queued to start after the current coverage ends.</summary>
Scheduled = 3,
/// <summary>A queued (Scheduled) subscription the owner cancelled before it started.</summary>
Cancelled = 4
} }
@@ -82,10 +82,25 @@ public class AppDbContext : DbContext
// Immutable audit trail of sensitive POS / management actions. // Immutable audit trail of sensitive POS / management actions.
public DbSet<AuditLog> AuditLogs => Set<AuditLog>(); public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
// Idempotency keys for safe retry of offline-replayed writes.
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.Entity<IdempotencyRecord>(e =>
{
e.HasKey(x => x.Id);
// One result per (tenant, key). The unique index also serializes
// concurrent first-time requests carrying the same key.
e.HasIndex(x => new { x.Scope, x.Key }).IsUnique();
e.Property(x => x.Scope).HasMaxLength(64).IsRequired();
e.Property(x => x.Key).HasMaxLength(200).IsRequired();
e.Property(x => x.Method).HasMaxLength(10).IsRequired();
e.Property(x => x.Path).HasMaxLength(512).IsRequired();
});
modelBuilder.Entity<PushDevice>(e => modelBuilder.Entity<PushDevice>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
@@ -111,6 +126,9 @@ public class AppDbContext : DbContext
e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000); e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000);
e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000); e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000);
e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2); e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2);
// Default true at the DB level so existing cafés stay listed on Koja after
// the column is added (EF doesn't read the C# initializer for the SQL default).
e.Property(x => x.ShowOnKoja).HasDefaultValue(true);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null);
}); });
@@ -11,6 +11,28 @@ namespace Meezi.Infrastructure.Data;
/// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary> /// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary>
public static class DiscoverShowcaseSeeder public static class DiscoverShowcaseSeeder
{ {
// Approximate city centres. Each café is scattered around its city with a
// small deterministic offset (derived from its id) so the marketing map
// shows a realistic cluster of blinking lights instead of one stacked dot.
private static readonly Dictionary<string, (double Lat, double Lng, double Spread)> CityGeo = new()
{
["تهران"] = (35.70, 51.39, 0.13),
["کرج"] = (35.83, 50.99, 0.07),
};
private static (double Lat, double Lng) GeoFor(string id, string city)
{
var (lat, lng, spread) = CityGeo.TryGetValue(city, out var g) ? g : (35.70, 51.39, 0.13);
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"]; private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"];
private static readonly string[] ReviewComments = private static readonly string[] ReviewComments =
[ [
@@ -27,6 +49,7 @@ public static class DiscoverShowcaseSeeder
foreach (var spec in DiscoverShowcaseCatalog.Cafes) foreach (var spec in DiscoverShowcaseCatalog.Cafes)
{ {
var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id); var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id);
var (geoLat, geoLng) = GeoFor(spec.Id, spec.City);
if (cafe is null) if (cafe is null)
{ {
cafe = new Cafe cafe = new Cafe
@@ -37,6 +60,8 @@ public static class DiscoverShowcaseSeeder
Slug = spec.Slug, Slug = spec.Slug,
City = spec.City, City = spec.City,
Address = spec.Address, Address = spec.Address,
Latitude = geoLat,
Longitude = geoLng,
Description = spec.Description, Description = spec.Description,
PlanTier = spec.PlanTier, PlanTier = spec.PlanTier,
PreferredLanguage = "fa", PreferredLanguage = "fa",
@@ -100,6 +125,12 @@ public static class DiscoverShowcaseSeeder
cafe.IsVerified = true; cafe.IsVerified = true;
changed = true; changed = true;
} }
if (cafe.Latitude is null || cafe.Longitude is null)
{
cafe.Latitude = geoLat;
cafe.Longitude = geoLng;
changed = true;
}
if (changed) if (changed)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddCafeShowOnKoja : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ShowOnKoja",
table: "Cafes",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShowOnKoja",
table: "Cafes");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddSubscriptionScheduling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "EffectiveFrom",
table: "SubscriptionPayments",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EffectiveTo",
table: "SubscriptionPayments",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EffectiveFrom",
table: "SubscriptionPayments");
migrationBuilder.DropColumn(
name: "EffectiveTo",
table: "SubscriptionPayments");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddIdempotencyRecords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "IdempotencyRecords",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
Scope = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Path = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
ResponseStatusCode = table.Column<int>(type: "integer", nullable: false),
ResponseBody = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_IdempotencyRecords", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_IdempotencyRecords_Scope_Key",
table: "IdempotencyRecords",
columns: new[] { "Scope", "Key" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "IdempotencyRecords");
}
}
}
@@ -360,6 +360,11 @@ namespace Meezi.Infrastructure.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("ShowOnKoja")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -1124,6 +1129,54 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("Expenses"); b.ToTable("Expenses");
}); });
modelBuilder.Entity("Meezi.Core.Entities.IdempotencyRecord", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Method")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("ResponseBody")
.HasColumnType("text");
b.Property<int>("ResponseStatusCode")
.HasColumnType("integer");
b.Property<string>("Scope")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Scope", "Key")
.IsUnique();
b.ToTable("IdempotencyRecords");
});
modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b => modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -2006,6 +2059,12 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EffectiveFrom")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EffectiveTo")
.HasColumnType("timestamp with time zone");
b.Property<int>("Months") b.Property<int>("Months")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -29,6 +29,25 @@ public static class PlatformDataSeeder
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone". // fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger); await EnsureOwnerAdminAsync(db, config, logger);
// Best-effort, NON-FATAL seeding. These steps populate convenience data
// (map pins, plan/feature catalog) and must never crash-loop the API on
// boot — a failure is logged and startup continues so the service serves.
try
{
// Give cafés without a map pin an approximate location from their
// city so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger);
// Subscription plans + feature flags the admin panel reads in every
// environment. Idempotent: adds any tiers/keys that are missing.
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Non-fatal platform seeding step failed; continuing startup");
}
if (!env.IsDevelopment()) if (!env.IsDevelopment())
{ {
// Production: also ensure integration settings (Kavenegar enabled/template, // Production: also ensure integration settings (Kavenegar enabled/template,
@@ -39,12 +58,83 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger); await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger); await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
await SeedSettingsAsync(db, logger); await SeedSettingsAsync(db, logger);
await EnsureIntegrationSettingsAsync(db, logger); await EnsureIntegrationSettingsAsync(db, logger);
} }
// Approximate centres for the major Iranian cities cafés sign up from.
private static readonly Dictionary<string, (double Lat, double Lng)> CityCentres = new(StringComparer.Ordinal)
{
["تهران"] = (35.70, 51.39),
["کرج"] = (35.84, 50.99),
["مشهد"] = (36.30, 59.61),
["اصفهان"] = (32.66, 51.67),
["شیراز"] = (29.59, 52.53),
["تبریز"] = (38.08, 46.29),
["قم"] = (34.64, 50.88),
["اهواز"] = (31.32, 48.67),
["کرمانشاه"] = (34.31, 47.07),
["رشت"] = (37.28, 49.58),
["ارومیه"] = (37.55, 45.07),
["همدان"] = (34.80, 48.52),
["یزد"] = (31.90, 54.37),
["اراک"] = (34.09, 49.69),
["کرمان"] = (30.28, 57.08),
["بندرعباس"] = (27.18, 56.27),
["قزوین"] = (36.28, 50.00),
["ساری"] = (36.57, 53.06),
["گرگان"] = (36.84, 54.44),
["زنجان"] = (36.68, 48.49),
["کیش"] = (26.56, 53.98),
};
/// <summary>
/// Gives cafés that have no map pin an approximate location at their city
/// centre (plus a small deterministic per-café offset so multiple cafés in
/// one city don't stack on a single point). Only fills rows where Latitude or
/// Longitude is null and the city is recognised; owners can drop an exact pin
/// later from Settings. Idempotent — never overwrites an existing pin.
/// </summary>
private static async Task BackfillCafeLocationsAsync(AppDbContext db, ILogger logger)
{
var cafes = await db.Cafes
.Where(c => c.DeletedAt == null
&& (c.Latitude == null || c.Longitude == null)
&& c.City != null)
.ToListAsync();
if (cafes.Count == 0) return;
var updated = 0;
foreach (var cafe in cafes)
{
var city = cafe.City!.Trim();
if (!CityCentres.TryGetValue(city, out var centre)) continue;
var (lat, lng) = ScatterAround(cafe.Id, centre.Lat, centre.Lng, 0.05);
cafe.Latitude = lat;
cafe.Longitude = lng;
updated++;
}
if (updated > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Cafe location backfill: set approximate coordinates for {Count} café(s) from city centre", updated);
}
}
private static (double Lat, double Lng) ScatterAround(string id, double lat, double lng, double spread)
{
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
/// <summary> /// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment /// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy. /// (including production), so the admin panel is reachable on a fresh deploy.
@@ -280,9 +370,6 @@ public static class PlatformDataSeeder
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger) private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformPlanDefinitions.AnyAsync())
return;
var plans = new[] var plans = new[]
{ {
new PlatformPlanDefinition new PlatformPlanDefinition
@@ -344,16 +431,26 @@ public static class PlatformDataSeeder
} }
}; };
db.PlatformPlanDefinitions.AddRange(plans); // Tier (not Id) carries the unique constraint, so dedupe on Tier — an
// existing Free plan may have a different Id, and inserting another
// Free-tier row would violate IX_PlatformPlanDefinitions_Tier.
// IgnoreQueryFilters: a SOFT-DELETED plan still occupies its Tier in the
// unique index, so it must be counted or the insert collides on boot.
var existingTiers = (await db.PlatformPlanDefinitions
.IgnoreQueryFilters()
.Select(p => p.Tier)
.ToListAsync())
.ToHashSet();
var missing = plans.Where(p => !existingTiers.Contains(p.Tier)).ToArray();
if (missing.Length == 0) return;
db.PlatformPlanDefinitions.AddRange(missing);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} subscription plans", plans.Length); logger.LogInformation("Platform seed: +{Count} subscription plans", missing.Length);
} }
private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger) private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformFeatures.AnyAsync())
return;
var features = new[] var features = new[]
{ {
F("pos", "صندوق", "POS", "core"), F("pos", "صندوق", "POS", "core"),
@@ -379,9 +476,19 @@ public static class PlatformDataSeeder
F("discover_profile", "پروفایل کشف", "Discover profile", "growth") F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
}; };
db.PlatformFeatures.AddRange(features); // Key carries the unique constraint, so dedupe on Key (not Id).
// IgnoreQueryFilters so a soft-deleted feature's Key is still counted.
var existingKeys = (await db.PlatformFeatures
.IgnoreQueryFilters()
.Select(f => f.Key)
.ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = features.Where(f => !existingKeys.Contains(f.Key)).ToArray();
if (missing.Length == 0) return;
db.PlatformFeatures.AddRange(missing);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} feature flags", features.Length); logger.LogInformation("Platform seed: +{Count} feature flags", missing.Length);
} }
private static PlatformFeature F(string key, string fa, string en, string group) => new() private static PlatformFeature F(string key, string fa, string en, string group) => new()
@@ -0,0 +1,162 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Meezi.API.Middleware;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Xunit;
namespace Meezi.API.Tests;
public class IdempotencyMiddlewareTests
{
private sealed class TestTenant(string? cafeId) : ITenantContext
{
public string? UserId => "user-1";
public string? CafeId => cafeId;
public EmployeeRole? Role => EmployeeRole.Owner;
public PlanTier? PlanTier => Core.Enums.PlanTier.Pro;
public string? Language => "fa";
public string? BranchId => null;
public bool IsSystemAdmin => false;
public bool IsAuthenticated => true;
}
/// <summary>A scope factory whose scopes share one in-memory database, mirroring how the
/// middleware opens isolated DI scopes against the same store in production.</summary>
private static IServiceScopeFactory BuildScopeFactory()
{
var dbName = Guid.NewGuid().ToString();
var services = new ServiceCollection();
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase(dbName));
services.AddLogging();
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
}
private static DefaultHttpContext NewPost(string? key)
{
var ctx = new DefaultHttpContext();
ctx.Request.Method = "POST";
ctx.Request.Path = "/api/test";
if (key is not null) ctx.Request.Headers["Idempotency-Key"] = key;
ctx.Response.Body = new MemoryStream();
return ctx;
}
private static string ReadBody(HttpContext ctx)
{
ctx.Response.Body.Position = 0;
return new StreamReader(ctx.Response.Body).ReadToEnd();
}
[Fact]
public async Task SameKey_ExecutesOnce_AndReplaysStoredResponse()
{
var scopeFactory = BuildScopeFactory();
var tenant = new TestTenant("cafe-1");
var calls = 0;
RequestDelegate next = async ctx =>
{
calls++;
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync($"{{\"v\":\"{Guid.NewGuid():N}\"}}");
};
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
var c1 = NewPost("KEY-1");
await mw.InvokeAsync(c1, tenant, scopeFactory);
var body1 = ReadBody(c1);
var c2 = NewPost("KEY-1");
await mw.InvokeAsync(c2, tenant, scopeFactory);
var body2 = ReadBody(c2);
Assert.Equal(1, calls); // executed exactly once
Assert.Equal(body1, body2); // second call replays the stored body verbatim
Assert.Equal(200, c2.Response.StatusCode);
Assert.Equal("true", c2.Response.Headers["Idempotent-Replay"].ToString());
}
[Fact]
public async Task DifferentKey_ExecutesAgain()
{
var scopeFactory = BuildScopeFactory();
var tenant = new TestTenant("cafe-1");
var calls = 0;
RequestDelegate next = async ctx =>
{
calls++;
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync("{\"ok\":true}");
};
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
await mw.InvokeAsync(NewPost("A"), tenant, scopeFactory);
await mw.InvokeAsync(NewPost("B"), tenant, scopeFactory);
Assert.Equal(2, calls);
}
[Fact]
public async Task NoKey_PassesThrough_NoIdempotency()
{
var scopeFactory = BuildScopeFactory();
var tenant = new TestTenant("cafe-1");
var calls = 0;
RequestDelegate next = ctx =>
{
calls++;
ctx.Response.StatusCode = 200;
return Task.CompletedTask;
};
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
Assert.Equal(2, calls);
}
[Fact]
public async Task SameKey_DifferentTenant_IsNotReplayed()
{
var scopeFactory = BuildScopeFactory();
var calls = 0;
RequestDelegate next = async ctx =>
{
calls++;
ctx.Response.StatusCode = 200;
await ctx.Response.WriteAsync("{\"ok\":true}");
};
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-A"), scopeFactory);
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-B"), scopeFactory);
Assert.Equal(2, calls); // keys are scoped per café — no cross-tenant collision
}
[Fact]
public async Task ServerError_IsNotCached_SoRetryReexecutes()
{
var scopeFactory = BuildScopeFactory();
var tenant = new TestTenant("cafe-1");
var calls = 0;
RequestDelegate next = async ctx =>
{
calls++;
ctx.Response.StatusCode = 500;
await ctx.Response.WriteAsync("{\"error\":\"boom\"}");
};
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
await mw.InvokeAsync(NewPost("KEY-5XX"), tenant, scopeFactory);
var c2 = NewPost("KEY-5XX");
await mw.InvokeAsync(c2, tenant, scopeFactory);
Assert.Equal(2, calls); // 5xx is transient → reservation released, retry runs again
Assert.NotEqual("true", c2.Response.Headers["Idempotent-Replay"].ToString());
}
}
@@ -16,6 +16,9 @@ internal sealed class NoOpInventoryService : IInventoryService
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) => public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
public Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default) =>
Task.FromResult(false);
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) => public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
@@ -44,6 +44,7 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
<button <button
type="button" type="button"
role="switch" role="switch"
dir="ltr"
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
@@ -604,11 +605,18 @@ export function AdminIntegrationsScreen() {
}); });
}, [data]); }, [data]);
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: () =>
adminPut<PlatformIntegrations>("/api/admin/integrations", { adminPut<PlatformIntegrations>("/api/admin/integrations", {
activePaymentGateway: activeGateway, activePaymentGateway: activeGateway,
paymentGateways: gateways.map((g) => ({ // Save from `list` (what's rendered/edited), not `gateways` — if the
// gateways state hasn't hydrated, `list` falls back to the fetched data,
// and edits go through updateGateway which seeds it. This keeps the
// rendered, edited, and saved arrays the same source (was dropping
// edits like the Zarinpal merchantId when gateways was empty).
paymentGateways: list.map((g) => ({
id: g.id, id: g.id,
isEnabled: g.isEnabled, isEnabled: g.isEnabled,
merchantId: g.id === "zarinpal" ? g.merchantId : undefined, merchantId: g.id === "zarinpal" ? g.merchantId : undefined,
@@ -637,11 +645,14 @@ export function AdminIntegrationsScreen() {
}); });
const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => { const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => {
setGateways((prev) => prev.map((g) => (g.id === id ? { ...g, ...patch } : g))); setGateways((prev) => {
// Seed from fetched data on the first edit so an edit is never dropped
// because the state hadn't hydrated yet.
const base = prev.length > 0 ? prev : data?.paymentGateways?.map((g) => ({ ...g })) ?? [];
return base.map((g) => (g.id === id ? { ...g, ...patch } : g));
});
}; };
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -162,6 +162,7 @@ function BlogToggle({
<button <button
type="button" type="button"
role="switch" role="switch"
dir="ltr"
aria-checked={checked} aria-checked={checked}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={cn( className={cn(
+49 -12
View File
@@ -20,6 +20,14 @@
"saved": "تم الحفظ", "saved": "تم الحفظ",
"errorGeneric": "حدث خطأ. حاول مرة أخرى." "errorGeneric": "حدث خطأ. حاول مرة أخرى."
}, },
"errors": {
"planLimit": "وصلت إلى حد الخطة",
"notFound": "غير موجود",
"unauthorized": "غير مصرح",
"network": "خطأ في الاتصال",
"generic": "حدث خطأ. حاول مرة أخرى.",
"OFFLINE_UNAVAILABLE": "يتطلب هذا الإجراء اتصالاً بالإنترنت. يرجى المحاولة بعد عودة الاتصال."
},
"brand": { "brand": {
"name": "ميزي" "name": "ميزي"
}, },
@@ -243,6 +251,7 @@
"void": "إلغاء", "void": "إلغاء",
"voidItem": "إلغاء الصنف", "voidItem": "إلغاء الصنف",
"voided": "ملغى", "voided": "ملغى",
"itemNotePlaceholder": "ملاحظة للمطبخ/البار (اختياري)",
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟", "confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
"voidError": "تعذر إلغاء الصنف", "voidError": "تعذر إلغاء الصنف",
"transferTable": "نقل الطاولة", "transferTable": "نقل الطاولة",
@@ -372,7 +381,10 @@
"duplicatePhone": "رقم الجوال مسجل مسبقاً.", "duplicatePhone": "رقم الجوال مسجل مسبقاً.",
"generic": "تعذر الحفظ. حاول مرة أخرى." "generic": "تعذر الحفظ. حاول مرة أخرى."
} }
} },
"deleted": "تم حذف العميل",
"deleteConfirmTitle": "حذف العميل",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟"
}, },
"coupons": { "coupons": {
"title": "القسائم", "title": "القسائم",
@@ -388,7 +400,10 @@
"FixedAmount": "مبلغ ثابت", "FixedAmount": "مبلغ ثابت",
"FreeItem": "عنصر مجاني" "FreeItem": "عنصر مجاني"
}, },
"noCoupons": "لا توجد قسائم" "noCoupons": "لا توجد قسائم",
"deleted": "تم حذف القسيمة",
"deleteConfirmTitle": "حذف القسيمة",
"deleteConfirmDesc": "هل أنت متأكد من حذف القسيمة «{code}»؟"
}, },
"hr": { "hr": {
"title": "الموارد البشرية", "title": "الموارد البشرية",
@@ -735,7 +750,13 @@
"addItemSuccess": "تمت إضافة الصنف", "addItemSuccess": "تمت إضافة الصنف",
"updateItemSuccess": "تم تحديث الصنف", "updateItemSuccess": "تم تحديث الصنف",
"addCategorySuccess": "تمت إضافة الفئة", "addCategorySuccess": "تمت إضافة الفئة",
"updateCategorySuccess": "تم تحديث الفئة" "updateCategorySuccess": "تم تحديث الفئة",
"deleteItemConfirmTitle": "حذف الصنف",
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteItemSuccess": "تم حذف الصنف",
"deleteCategoryConfirmTitle": "حذف الفئة",
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
"deleteCategorySuccess": "تم حذف الفئة"
}, },
"branchMenu": { "branchMenu": {
"title": "قائمة الفرع", "title": "قائمة الفرع",
@@ -829,7 +850,10 @@
"purchasesThisMonth": "مشتريات المواد هذا الشهر", "purchasesThisMonth": "مشتريات المواد هذا الشهر",
"purchaseCount": "{count} عملية شراء", "purchaseCount": "{count} عملية شراء",
"viewInExpenses": "عرض في المصروفات", "viewInExpenses": "عرض في المصروفات",
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع." "selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع.",
"deleted": "تم حذف المادة",
"deleteConfirmTitle": "حذف المادة",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع."
}, },
"qr": { "qr": {
"brand": "ميزي", "brand": "ميزي",
@@ -856,6 +880,7 @@
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً", "orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
"guestName": "اسمك (اختياري)", "guestName": "اسمك (اختياري)",
"guestPhone": "الجوال (اختياري)", "guestPhone": "الجوال (اختياري)",
"itemNote": "ملاحظة (مثلاً بدون طماطم، سكر أقل)",
"addMoreItems": "إضافة المزيد", "addMoreItems": "إضافة المزيد",
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.", "orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق", "rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
@@ -943,7 +968,10 @@
"Cancelled": "ملغى", "Cancelled": "ملغى",
"Seated": "جالس", "Seated": "جالس",
"Completed": "مكتمل" "Completed": "مكتمل"
} },
"deleted": "تم حذف الحجز",
"deleteConfirmTitle": "حذف الحجز",
"deleteConfirmDesc": "هل أنت متأكد من حذف حجز «{name}»؟"
}, },
"branchesPage": { "branchesPage": {
"title": "الفروع", "title": "الفروع",
@@ -1020,7 +1048,18 @@
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.", "secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
"payTotal": "ادفع {total}", "payTotal": "ادفع {total}",
"redirecting": "جارٍ التحويل إلى البوابة...", "redirecting": "جارٍ التحويل إلى البوابة...",
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى." "paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى.",
"queuedNotice": "لديك اشتراك نشط بالفعل. ستتم إضافة هذا الشراء إلى قائمة الانتظار وسيبدأ في {date}."
},
"queued": {
"title": "الاشتراكات في قائمة الانتظار",
"subtitle": "تبدأ تلقائيًا عند انتهاء اشتراكك الحالي.",
"months": "{count} أشهر",
"window": "من {from} إلى {to}",
"cancel": "إلغاء",
"cancelled": "تم إلغاء الاشتراك في قائمة الانتظار",
"cancelConfirmTitle": "إلغاء الاشتراك المجدول",
"cancelConfirmDesc": "إلغاء اشتراك {plan} المقرر أن يبدأ في {from}؟ لن يتأثر اشتراكك الحالي."
} }
}, },
"settings": { "settings": {
@@ -1359,12 +1398,6 @@
} }
} }
}, },
"errors": {
"planLimit": "وصلت إلى حد الخطة",
"notFound": "غير موجود",
"unauthorized": "غير مصرح",
"network": "خطأ في الاتصال"
},
"discoverPublic": { "discoverPublic": {
"brand": "ميزي", "brand": "ميزي",
"title": "اكتشاف المقاهي", "title": "اكتشاف المقاهي",
@@ -1511,5 +1544,9 @@
"mid": "میانه", "mid": "میانه",
"premium": "پریمیوم" "premium": "پریمیوم"
} }
},
"cafePublicProfile": {
"showOnKoja": "العرض على كوجا",
"showOnKojaHint": "إدراج مقهاك في دليل كوجا العام (koja.meezi.ir). مفعّل افتراضيًا."
} }
} }
+48 -13
View File
@@ -20,6 +20,14 @@
"saved": "Saved", "saved": "Saved",
"errorGeneric": "Something went wrong. Please try again." "errorGeneric": "Something went wrong. Please try again."
}, },
"errors": {
"planLimit": "Plan limit reached. Please upgrade.",
"notFound": "Not found",
"unauthorized": "Unauthorized",
"network": "Network error",
"generic": "Something went wrong. Please try again.",
"OFFLINE_UNAVAILABLE": "This action needs an internet connection. Please try again once you are back online."
},
"brand": { "brand": {
"name": "Meezi" "name": "Meezi"
}, },
@@ -262,6 +270,7 @@
"void": "Void", "void": "Void",
"voidItem": "Void item", "voidItem": "Void item",
"voided": "Voided", "voided": "Voided",
"itemNotePlaceholder": "Note for kitchen/bar (optional)",
"confirmVoid": "Are you sure you want to void this item?", "confirmVoid": "Are you sure you want to void this item?",
"voidError": "Could not void item", "voidError": "Could not void item",
"transferTable": "Transfer table", "transferTable": "Transfer table",
@@ -391,7 +400,10 @@
"duplicatePhone": "This phone number is already registered.", "duplicatePhone": "This phone number is already registered.",
"generic": "Could not save. Please try again." "generic": "Could not save. Please try again."
} }
} },
"deleted": "Customer deleted",
"deleteConfirmTitle": "Delete customer",
"deleteConfirmDesc": "Delete “{name}”?"
}, },
"coupons": { "coupons": {
"title": "Coupons", "title": "Coupons",
@@ -407,7 +419,10 @@
"FixedAmount": "Fixed amount", "FixedAmount": "Fixed amount",
"FreeItem": "Free item" "FreeItem": "Free item"
}, },
"noCoupons": "No coupons yet" "noCoupons": "No coupons yet",
"deleted": "Coupon deleted",
"deleteConfirmTitle": "Delete coupon",
"deleteConfirmDesc": "Delete coupon “{code}”?"
}, },
"hr": { "hr": {
"title": "Human resources", "title": "Human resources",
@@ -778,7 +793,13 @@
"addItemSuccess": "Item added", "addItemSuccess": "Item added",
"updateItemSuccess": "Item updated", "updateItemSuccess": "Item updated",
"addCategorySuccess": "Category added", "addCategorySuccess": "Category added",
"updateCategorySuccess": "Category updated" "updateCategorySuccess": "Category updated",
"deleteItemConfirmTitle": "Delete item",
"deleteItemConfirmDesc": "Are you sure you want to delete “{name}”? This can't be undone.",
"deleteItemSuccess": "Item deleted",
"deleteCategoryConfirmTitle": "Delete category",
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
"deleteCategorySuccess": "Category deleted"
}, },
"branchMenu": { "branchMenu": {
"title": "Branch Menu", "title": "Branch Menu",
@@ -898,7 +919,10 @@
"purchasesThisMonth": "Material purchases this month", "purchasesThisMonth": "Material purchases this month",
"purchaseCount": "{count} purchases", "purchaseCount": "{count} purchases",
"viewInExpenses": "View in expenses", "viewInExpenses": "View in expenses",
"selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases." "selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases.",
"deleted": "Material deleted",
"deleteConfirmTitle": "Delete material",
"deleteConfirmDesc": "Delete “{name}”? This cant be undone."
}, },
"qr": { "qr": {
"brand": "Meezi", "brand": "Meezi",
@@ -925,6 +949,7 @@
"orderHint": "Staff will prepare your order shortly", "orderHint": "Staff will prepare your order shortly",
"guestName": "Your name (optional)", "guestName": "Your name (optional)",
"guestPhone": "Mobile (optional)", "guestPhone": "Mobile (optional)",
"itemNote": "Note (e.g. no tomato, less sugar)",
"addMoreItems": "Add more items", "addMoreItems": "Add more items",
"orderError": "Could not place order. Try again.", "orderError": "Could not place order. Try again.",
"rateLimited": "Too many requests — please wait a few minutes", "rateLimited": "Too many requests — please wait a few minutes",
@@ -1013,7 +1038,10 @@
"Cancelled": "Cancelled", "Cancelled": "Cancelled",
"Seated": "Seated", "Seated": "Seated",
"Completed": "Completed" "Completed": "Completed"
} },
"deleted": "Reservation deleted",
"deleteConfirmTitle": "Delete reservation",
"deleteConfirmDesc": "Delete the reservation for “{name}”?"
}, },
"branchesPage": { "branchesPage": {
"title": "Branches", "title": "Branches",
@@ -1092,7 +1120,18 @@
"secureNote": "Payment is processed through a secure bank gateway.", "secureNote": "Payment is processed through a secure bank gateway.",
"payTotal": "Pay {total}", "payTotal": "Pay {total}",
"redirecting": "Redirecting to gateway...", "redirecting": "Redirecting to gateway...",
"paymentFailed": "Payment failed. Please try again." "paymentFailed": "Payment failed. Please try again.",
"queuedNotice": "You already have an active subscription. This purchase will be queued and start on {date}."
},
"queued": {
"title": "Queued subscriptions",
"subtitle": "These start automatically when your current subscription ends.",
"months": "{count} months",
"window": "From {from} to {to}",
"cancel": "Cancel",
"cancelled": "Queued subscription cancelled",
"cancelConfirmTitle": "Cancel queued subscription",
"cancelConfirmDesc": "Cancel the {plan} subscription scheduled to start on {from}? Your current subscription is unaffected."
} }
}, },
"settings": { "settings": {
@@ -1441,12 +1480,6 @@
} }
} }
}, },
"errors": {
"planLimit": "Plan limit reached. Please upgrade.",
"notFound": "Not found",
"unauthorized": "Unauthorized",
"network": "Network error"
},
"discoverPublic": { "discoverPublic": {
"brand": "Meezi", "brand": "Meezi",
"title": "Discover cafés", "title": "Discover cafés",
@@ -1551,7 +1584,9 @@
"save": "Save", "save": "Save",
"saved": "Saved", "saved": "Saved",
"saveFailed": "Save failed", "saveFailed": "Save failed",
"loading": "Loading…" "loading": "Loading…",
"showOnKoja": "Show on Koja",
"showOnKojaHint": "List your café in the public Koja directory (koja.meezi.ir). On by default."
}, },
"discoverProfile": { "discoverProfile": {
"sections": { "sections": {
+48 -13
View File
@@ -20,6 +20,14 @@
"saved": "ذخیره شد", "saved": "ذخیره شد",
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید." "errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
}, },
"errors": {
"planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound": "یافت نشد",
"unauthorized": "دسترسی ندارید",
"network": "خطای ارتباط با سرور",
"generic": "خطایی رخ داد. دوباره تلاش کنید.",
"OFFLINE_UNAVAILABLE": "برای این کار به اینترنت نیاز است. لطفاً پس از اتصال دوباره تلاش کنید."
},
"brand": { "brand": {
"name": "میزی" "name": "میزی"
}, },
@@ -262,6 +270,7 @@
"void": "ابطال", "void": "ابطال",
"voidItem": "ابطال آیتم", "voidItem": "ابطال آیتم",
"voided": "ابطال شده", "voided": "ابطال شده",
"itemNotePlaceholder": "یادداشت برای آشپزخانه/بار (اختیاری)",
"confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟", "confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟",
"voidError": "خطا در ابطال آیتم", "voidError": "خطا در ابطال آیتم",
"transferTable": "انتقال میز", "transferTable": "انتقال میز",
@@ -391,7 +400,10 @@
"duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.", "duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.",
"generic": "ذخیره انجام نشد. دوباره تلاش کنید." "generic": "ذخیره انجام نشد. دوباره تلاش کنید."
} }
} },
"deleted": "مشتری حذف شد",
"deleteConfirmTitle": "حذف مشتری",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟"
}, },
"coupons": { "coupons": {
"title": "کوپن‌ها", "title": "کوپن‌ها",
@@ -407,7 +419,10 @@
"FixedAmount": "مبلغ ثابت", "FixedAmount": "مبلغ ثابت",
"FreeItem": "آیتم رایگان" "FreeItem": "آیتم رایگان"
}, },
"noCoupons": "کوپنی ثبت نشده" "noCoupons": "کوپنی ثبت نشده",
"deleted": "کوپن حذف شد",
"deleteConfirmTitle": "حذف کوپن",
"deleteConfirmDesc": "آیا از حذف کوپن «{code}» مطمئن هستید؟"
}, },
"hr": { "hr": {
"title": "منابع انسانی", "title": "منابع انسانی",
@@ -778,7 +793,13 @@
"addItemSuccess": "آیتم اضافه شد", "addItemSuccess": "آیتم اضافه شد",
"updateItemSuccess": "آیتم به‌روز شد", "updateItemSuccess": "آیتم به‌روز شد",
"addCategorySuccess": "دسته اضافه شد", "addCategorySuccess": "دسته اضافه شد",
"updateCategorySuccess": "دسته به‌روز شد" "updateCategorySuccess": "دسته به‌روز شد",
"deleteItemConfirmTitle": "حذف آیتم",
"deleteItemConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.",
"deleteItemSuccess": "آیتم حذف شد",
"deleteCategoryConfirmTitle": "حذف دسته‌بندی",
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
"deleteCategorySuccess": "دسته حذف شد"
}, },
"branchMenu": { "branchMenu": {
"title": "منوی شعبه", "title": "منوی شعبه",
@@ -898,7 +919,10 @@
"purchasesThisMonth": "خرید مواد این ماه", "purchasesThisMonth": "خرید مواد این ماه",
"purchaseCount": "{count} خرید", "purchaseCount": "{count} خرید",
"viewInExpenses": "مشاهده در هزینه‌ها", "viewInExpenses": "مشاهده در هزینه‌ها",
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید." "selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید.",
"deleted": "ماده حذف شد",
"deleteConfirmTitle": "حذف ماده",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست."
}, },
"qr": { "qr": {
"brand": "میزی", "brand": "میزی",
@@ -925,6 +949,7 @@
"orderHint": "کارکنان به زودی سفارش شما را آماده می‌کنند", "orderHint": "کارکنان به زودی سفارش شما را آماده می‌کنند",
"guestName": "نام شما (اختیاری)", "guestName": "نام شما (اختیاری)",
"guestPhone": "شماره موبایل (اختیاری)", "guestPhone": "شماره موبایل (اختیاری)",
"itemNote": "یادداشت (مثلاً بدون گوجه، کم‌شکر)",
"addMoreItems": "افزودن آیتم دیگر", "addMoreItems": "افزودن آیتم دیگر",
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید", "orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.", "orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
@@ -1014,7 +1039,10 @@
"Cancelled": "لغو شده", "Cancelled": "لغو شده",
"Seated": "نشسته", "Seated": "نشسته",
"Completed": "انجام شده" "Completed": "انجام شده"
} },
"deleted": "رزرو حذف شد",
"deleteConfirmTitle": "حذف رزرو",
"deleteConfirmDesc": "آیا از حذف رزرو «{name}» مطمئن هستید؟"
}, },
"branchesPage": { "branchesPage": {
"title": "شعب", "title": "شعب",
@@ -1093,7 +1121,18 @@
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.", "secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.",
"payTotal": "پرداخت {total}", "payTotal": "پرداخت {total}",
"redirecting": "در حال انتقال به درگاه...", "redirecting": "در حال انتقال به درگاه...",
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید." "paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید.",
"queuedNotice": "شما اشتراک فعالی دارید. این خرید در صف قرار می‌گیرد و از {date} آغاز می‌شود."
},
"queued": {
"title": "اشتراک‌های در صف",
"subtitle": "این اشتراک‌ها پس از پایان اشتراک فعلی به‌صورت خودکار فعال می‌شوند.",
"months": "{count} ماه",
"window": "از {from} تا {to}",
"cancel": "لغو",
"cancelled": "اشتراک در صف لغو شد",
"cancelConfirmTitle": "لغو اشتراک در صف",
"cancelConfirmDesc": "اشتراک {plan} که قرار بود از {from} آغاز شود لغو شود؟ اشتراک فعلی شما دست‌نخورده می‌ماند."
} }
}, },
"settings": { "settings": {
@@ -1442,12 +1481,6 @@
} }
} }
}, },
"errors": {
"planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound": "یافت نشد",
"unauthorized": "دسترسی ندارید",
"network": "خطای ارتباط با سرور"
},
"discoverPublic": { "discoverPublic": {
"brand": "میزی", "brand": "میزی",
"title": "کافه‌یاب", "title": "کافه‌یاب",
@@ -1552,7 +1585,9 @@
"save": "ذخیره", "save": "ذخیره",
"saved": "ذخیره شد", "saved": "ذخیره شد",
"saveFailed": "ذخیره ناموفق بود", "saveFailed": "ذخیره ناموفق بود",
"loading": "در حال بارگذاری…" "loading": "در حال بارگذاری…",
"showOnKoja": "نمایش در کوجا",
"showOnKojaHint": "کافه شما در فهرست عمومی کوجا (koja.meezi.ir) نمایش داده شود. پیش‌فرض روشن است."
}, },
"discoverProfile": { "discoverProfile": {
"sections": { "sections": {
@@ -3,24 +3,29 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Plus } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
import type { Coupon, CouponType } from "@/lib/api/types"; import type { Coupon, CouponType } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export function CouponsScreen() { export function CouponsScreen() {
const t = useTranslations("coupons"); const t = useTranslations("coupons");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Coupon | null>(null);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [type, setType] = useState<CouponType>("Percentage"); const [type, setType] = useState<CouponType>("Percentage");
const [value, setValue] = useState("10"); const [value, setValue] = useState("10");
@@ -47,6 +52,16 @@ export function CouponsScreen() {
}, },
}); });
const deleteCoupon = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/coupons/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
return ( return (
@@ -132,11 +147,34 @@ export function CouponsScreen() {
{t("usage")}: {formatNumber(c.usedCount)} {t("usage")}: {formatNumber(c.usedCount)}
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""} {c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
</p> </p>
<div className="mt-2 flex justify-end">
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { code: deleteTarget.code }) : undefined}
busy={deleteCoupon.isPending}
onConfirm={() => deleteTarget && deleteCoupon.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
+49 -12
View File
@@ -1,23 +1,27 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Plus, Pencil, Search } from "lucide-react"; import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { apiGet } from "@/lib/api/client"; import { apiDelete, apiGet } from "@/lib/api/client";
import type { Customer } from "@/lib/api/types"; import type { Customer } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard"; import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
export function CrmScreen() { export function CrmScreen() {
const t = useTranslations("crm"); const t = useTranslations("crm");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -26,6 +30,7 @@ export function CrmScreen() {
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create"); const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null); const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
const { data: customers = [], isLoading } = useQuery({ const { data: customers = [], isLoading } = useQuery({
queryKey: ["customers", cafeId, debouncedSearch], queryKey: ["customers", cafeId, debouncedSearch],
@@ -46,6 +51,16 @@ export function CrmScreen() {
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] }); queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
}; };
const deleteCustomer = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/customers/${id}`),
onSuccess: () => {
refreshCustomers();
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
return ( return (
@@ -104,21 +119,43 @@ export function CrmScreen() {
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)} {t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
</p> </p>
</div> </div>
<Button <div className="flex gap-2">
size="sm" <Button
variant="outline" size="sm"
className="w-full" variant="outline"
onClick={() => openWizard("edit", c)} className="flex-1"
> onClick={() => openWizard("edit", c)}
<Pencil className="me-1 h-3.5 w-3.5" /> >
{tCommon("edit")} <Pencil className="me-1 h-3.5 w-3.5" />
</Button> {tCommon("edit")}
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
busy={deleteCustomer.isPending}
onConfirm={() => deleteTarget && deleteCustomer.mutate(deleteTarget.id)}
/>
<CustomerWizard <CustomerWizard
open={wizardOpen} open={wizardOpen}
mode={wizardMode} mode={wizardMode}
@@ -4,6 +4,8 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react"; import { Sparkles, Loader2 } from "lucide-react";
import { apiPost } from "@/lib/api/client"; import { apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -26,6 +28,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const qc = useQueryClient(); const qc = useQueryClient();
const apiError = useApiError();
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [summary, setSummary] = useState<DemoSeedResult | null>(null); const [summary, setSummary] = useState<DemoSeedResult | null>(null);
@@ -39,6 +42,9 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
qc.invalidateQueries({ queryKey: key }); qc.invalidateQueries({ queryKey: key });
} }
}, },
onError: (err) => {
notify.error(apiError(err));
},
}); });
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null; if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
@@ -9,6 +9,7 @@ import {
updateCafePublicProfile, updateCafePublicProfile,
uploadGalleryPhoto, uploadGalleryPhoto,
type CafeProfileEdit, type CafeProfileEdit,
type UpdateCafeProfilePayload,
} from "@/lib/api/cafe-public-profile"; } from "@/lib/api/cafe-public-profile";
import type { WorkingHours } from "@/lib/api/public-discover"; import type { WorkingHours } from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client"; import { resolveMediaUrl } from "@/lib/api/client";
@@ -42,6 +43,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
const [instagram, setInstagram] = useState<string>(""); const [instagram, setInstagram] = useState<string>("");
const [website, setWebsite] = useState<string>(""); const [website, setWebsite] = useState<string>("");
const [hours, setHours] = useState<WorkingHours>(emptyHours()); const [hours, setHours] = useState<WorkingHours>(emptyHours());
const [showOnKoja, setShowOnKoja] = useState(true);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
// Populate local state once we get server data // Populate local state once we get server data
@@ -50,17 +52,20 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
setInstagram(profile.instagramHandle ?? ""); setInstagram(profile.instagramHandle ?? "");
setWebsite(profile.websiteUrl ?? ""); setWebsite(profile.websiteUrl ?? "");
setHours(profile.workingHours ?? emptyHours()); setHours(profile.workingHours ?? emptyHours());
setShowOnKoja(profile.showOnKoja ?? true);
setInitialized(true); setInitialized(true);
} }
// ── Save info/social/hours ──────────────────────────────────────────────── // ── Save info/social/hours ────────────────────────────────────────────────
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => mutationFn: (override?: Partial<UpdateCafeProfilePayload>) =>
updateCafePublicProfile(cafeId, { updateCafePublicProfile(cafeId, {
description, description,
instagramHandle: instagram || null, instagramHandle: instagram || null,
websiteUrl: website || null, websiteUrl: website || null,
workingHours: hours, workingHours: hours,
showOnKoja,
...override,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
qc.setQueryData(["cafe-public-profile", cafeId], data); qc.setQueryData(["cafe-public-profile", cafeId], data);
@@ -157,6 +162,23 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
{tab === "info" && ( {tab === "info" && (
<Card className="rounded-xl border border-border/80"> <Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4"> <CardContent className="space-y-4 p-4">
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/40 px-3 py-2.5">
<span className="min-w-0">
<span className="block text-sm font-medium">{t("showOnKoja")}</span>
<span className="block text-xs text-muted-foreground">{t("showOnKojaHint")}</span>
</span>
<input
type="checkbox"
checked={showOnKoja}
onChange={(e) => {
const v = e.target.checked;
setShowOnKoja(v);
// Persist immediately (pass the new value to avoid stale state).
saveMutation.mutate({ showOnKoja: v });
}}
className="h-5 w-5 shrink-0 cursor-pointer accent-[#0F6E56]"
/>
</label>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t("description")}</Label> <Label>{t("description")}</Label>
<textarea <textarea
@@ -167,7 +189,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]" className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
/> />
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -276,7 +298,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
); );
})} })}
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -307,7 +329,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
dir="ltr" dir="ltr"
/> />
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { Pencil } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field"; import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store"; import { useBranchStore } from "@/lib/stores/branch.store";
@@ -19,6 +20,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
type Ingredient = { type Ingredient = {
id: string; id: string;
@@ -67,6 +69,7 @@ type PurchasesSummary = {
export function InventoryScreen() { export function InventoryScreen() {
const t = useTranslations("inventory"); const t = useTranslations("inventory");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const locale = useLocale(); const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR"; const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
@@ -95,6 +98,7 @@ export function InventoryScreen() {
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({}); const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({}); const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Ingredient | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editUnit, setEditUnit] = useState("گرم"); const [editUnit, setEditUnit] = useState("گرم");
const [editReorder, setEditReorder] = useState("0"); const [editReorder, setEditReorder] = useState("0");
@@ -198,6 +202,17 @@ export function InventoryScreen() {
}, },
}); });
const deleteIngredient = useMutation({
mutationFn: (id: string) =>
apiDelete(`/api/cafes/${cafeId}/inventory/ingredients/${id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
const adjustStock = useMutation({ const adjustStock = useMutation({
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) => mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, { apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
@@ -478,6 +493,16 @@ export function InventoryScreen() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(ing)}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</div> </div>
<p className="text-sm font-medium text-[#0F6E56]"> <p className="text-sm font-medium text-[#0F6E56]">
@@ -661,6 +686,17 @@ export function InventoryScreen() {
) : null} ) : null}
</Card> </Card>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
busy={deleteIngredient.isPending}
onConfirm={() => deleteTarget && deleteIngredient.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
@@ -178,6 +178,11 @@ export function KdsScreen() {
<li key={item.id}> <li key={item.id}>
{formatNumber(item.quantity, numberLocale)}×{" "} {formatNumber(item.quantity, numberLocale)}×{" "}
{item.menuItemName} {item.menuItemName}
{item.notes ? (
<span className="mt-0.5 block rounded bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
{item.notes}
</span>
) : null}
</li> </li>
))} ))}
</ul> </ul>
@@ -193,6 +193,8 @@ export function BranchMenuOverrides({
<button <button
type="button" type="button"
role="switch" role="switch"
// Force LTR so the knob's translate-x stays inside the track in RTL.
dir="ltr"
aria-checked={row.isAvailable} aria-checked={row.isAvailable}
className={cn( className={cn(
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors", "relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl"; import { useIsRtl } from "@/lib/use-is-rtl";
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react"; import { Box, Pencil, Plus, Search, Trash2, Video, X } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { Menu3dUpload } from "@/components/media/menu-3d-upload"; import { Menu3dUpload } from "@/components/media/menu-3d-upload";
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate"; import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
@@ -12,8 +12,19 @@ import { CategoryVisual } from "@/components/menu/category-visual";
import { CategoryMediaFields } from "@/components/menu/category-media-fields"; import { CategoryMediaFields } from "@/components/menu/category-media-fields";
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker"; import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets"; import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store"; import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format"; import { formatCurrency, formatNumber } from "@/lib/format";
@@ -126,6 +137,9 @@ function ToggleSwitch({
aria-checked={checked} aria-checked={checked}
aria-label={label} aria-label={label}
type="button" type="button"
// Force LTR so the knob's translate-x stays inside the track; in RTL the
// flex start sits on the right and translate-x-4 would push it out.
dir="ltr"
onClick={() => !disabled && onChange(!checked)} onClick={() => !disabled && onChange(!checked)}
className={cn( className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200", "relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
@@ -184,11 +198,8 @@ function Modal({
export function MenuAdminScreen() { export function MenuAdminScreen() {
const t = useTranslations("menuAdmin"); const t = useTranslations("menuAdmin");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const tNotify = useTranslations("notify"); const apiError = useApiError();
const showError = (err: unknown) => const showError = (err: unknown) => notify.error(apiError(err));
notify.error(
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
);
const isRtl = useIsRtl(); const isRtl = useIsRtl();
const locale = useLocale(); const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR"; const numberLocale = locale === "en" ? "en-US" : "fa-IR";
@@ -211,6 +222,11 @@ export function MenuAdminScreen() {
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null); const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm); const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
// Delete confirmation (shared dialog for items + categories)
const [confirmDelete, setConfirmDelete] = useState<
{ kind: "item" | "category"; id: string; name: string } | null
>(null);
// ── Data queries ─────────────────────────────────────────────────────────── // ── Data queries ───────────────────────────────────────────────────────────
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ["menu-categories", cafeId], queryKey: ["menu-categories", cafeId],
@@ -301,6 +317,30 @@ export function MenuAdminScreen() {
onError: showError, onError: showError,
}); });
const deleteItemMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/items/${id}`),
onSuccess: () => {
setConfirmDelete(null);
setItemModalOpen(false);
notify.success(t("deleteItemSuccess"));
invalidateMenu();
},
onError: showError,
});
const deleteCategoryMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/categories/${id}`),
onSuccess: (_data, id) => {
setConfirmDelete(null);
setCatModalOpen(false);
// If the deleted category was selected, fall back to "all items".
setSelectedCategoryId((prev) => (prev === id ? "all" : prev));
notify.success(t("deleteCategorySuccess"));
invalidateMenu();
},
onError: showError,
});
const addCategoryMutation = useMutation({ const addCategoryMutation = useMutation({
mutationFn: () => mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/menu/categories`, { apiPost(`/api/cafes/${cafeId}/menu/categories`, {
@@ -893,20 +933,38 @@ export function MenuAdminScreen() {
</LabeledField> </LabeledField>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 border-t border-border pt-4"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
<Button {editingItem ? (
variant="ghost" <Button
onClick={() => setItemModalOpen(false)} type="button"
> variant="ghost"
{tCommon("cancel")} className="text-red-600 hover:bg-red-50 hover:text-red-700"
</Button> onClick={() =>
<Button setConfirmDelete({
className="bg-[#0F6E56] hover:bg-[#0c5a46]" kind: "item",
disabled={!itemFormValid || itemMutationBusy} id: editingItem.id,
onClick={handleItemSave} name: editingItem.name,
> })
{itemMutationBusy ? t("saving") : tCommon("save")} }
</Button> >
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setItemModalOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!itemFormValid || itemMutationBusy}
onClick={handleItemSave}
>
{itemMutationBusy ? t("saving") : tCommon("save")}
</Button>
</div>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -941,20 +999,84 @@ export function MenuAdminScreen() {
} }
/> />
<div className="flex justify-end gap-2 border-t border-border pt-4"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
<Button variant="ghost" onClick={() => setCatModalOpen(false)}> {editingCategory ? (
{tCommon("cancel")} <Button
</Button> type="button"
<Button variant="ghost"
className="bg-[#0F6E56] hover:bg-[#0c5a46]" className="text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={!catForm.name.trim() || catMutationBusy} onClick={() =>
onClick={handleCategorySave} setConfirmDelete({
> kind: "category",
{catMutationBusy ? t("saving") : tCommon("save")} id: editingCategory.id,
</Button> name: editingCategory.name,
})
}
>
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!catForm.name.trim() || catMutationBusy}
onClick={handleCategorySave}
>
{catMutationBusy ? t("saving") : tCommon("save")}
</Button>
</div>
</div> </div>
</div> </div>
</Modal> </Modal>
{/* ── Delete confirmation (items + categories) ──────────────────────── */}
<AlertDialog
open={!!confirmDelete}
onOpenChange={(open) => {
if (!open) setConfirmDelete(null);
}}
>
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{confirmDelete?.kind === "category"
? t("deleteCategoryConfirmTitle")
: t("deleteItemConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{confirmDelete?.kind === "category"
? t("deleteCategoryConfirmDesc", { name: confirmDelete?.name ?? "" })
: t("deleteItemConfirmDesc", { name: confirmDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 text-white hover:bg-red-700"
disabled={deleteItemMutation.isPending || deleteCategoryMutation.isPending}
onClick={(e) => {
e.preventDefault(); // keep dialog open until the mutation resolves
if (!confirmDelete) return;
if (confirmDelete.kind === "category") {
deleteCategoryMutation.mutate(confirmDelete.id);
} else {
deleteItemMutation.mutate(confirmDelete.id);
}
}}
>
{deleteItemMutation.isPending || deleteCategoryMutation.isPending
? t("saving")
: tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -255,6 +255,7 @@ export function PosScreen() {
addItem, addItem,
removeItem, removeItem,
updateQty, updateQty,
setNotes,
couponCode, couponCode,
appliedCoupon, appliedCoupon,
setCouponCode, setCouponCode,
@@ -1210,10 +1211,11 @@ export function PosScreen() {
<div <div
key={line.orderItemId ?? line.menuItem.id} key={line.orderItemId ?? line.menuItem.id}
className={cn( className={cn(
"flex items-center gap-2 rounded-lg border border-border p-2", "flex flex-col gap-1.5 rounded-lg border border-border p-2",
line.isVoided && "opacity-60" line.isVoided && "opacity-60"
)} )}
> >
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<MenuItemLabels <MenuItemLabels
item={line.menuItem} item={line.menuItem}
@@ -1291,6 +1293,18 @@ export function PosScreen() {
</> </>
) : null} ) : null}
</div> </div>
</div>
{!line.isVoided && (
<input
type="text"
value={line.notes ?? ""}
onChange={(e) => setNotes(line.menuItem.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
placeholder={t("itemNotePlaceholder")}
className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
)}
</div> </div>
)) ))
)} )}
+28 -2
View File
@@ -1,20 +1,46 @@
"use client"; "use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react"; import { useEffect, useState } from "react";
import { ConfirmProvider } from "@/components/providers/confirm-provider"; import { ConfirmProvider } from "@/components/providers/confirm-provider";
import { MeeziToaster } from "@/components/ui/meezi-toaster"; import { MeeziToaster } from "@/components/ui/meezi-toaster";
import { useAuthStore } from "@/lib/stores/auth.store";
import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(
() => () =>
new QueryClient({ new QueryClient({
defaultOptions: { defaultOptions: {
queries: { staleTime: 30_000, retry: 1 }, queries: {
staleTime: 30_000,
retry: 1,
// Keep data in memory long enough to back offline reads; it is also
// persisted to IndexedDB by the persister below.
gcTime: 24 * 60 * 60 * 1000,
},
}, },
}) })
); );
// Persist the query cache to IndexedDB so the dashboard is viewable offline.
// Scoped to the current café so a different tenant never hydrates this data.
const cafeId = useAuthStore((s) => s.user?.cafeId);
useEffect(() => {
const scope = cafeId ?? "anon";
let active = true;
let stop: () => void = () => {};
void (async () => {
await restoreQueryCache(queryClient, scope);
if (!active) return;
stop = startPersisting(queryClient, scope);
})();
return () => {
active = false;
stop();
};
}, [queryClient, cafeId]);
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ConfirmProvider> <ConfirmProvider>
@@ -407,29 +407,44 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
{cart.map((c) => ( {cart.map((c) => (
<div <div
key={c.item.id} key={c.item.id}
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0" className="flex flex-col gap-2 border-b px-3 py-3 last:border-0"
> >
<div className="min-w-0 flex-1"> <div className="flex items-center justify-between gap-3">
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" /> <div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: primary }}> <MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")} <p className="text-sm font-medium" style={{ color: primary }}>
</p> {formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
</div> </p>
<div className="flex items-center gap-2"> </div>
<QtyButton <div className="flex items-center gap-2">
label="" <QtyButton
onClick={() => removeFromCart(c.item.id)} label=""
variant="outline" onClick={() => removeFromCart(c.item.id)}
color={primary} variant="outline"
/> color={primary}
<span className="min-w-6 text-center font-semibold">{c.qty}</span> />
<QtyButton <span className="min-w-6 text-center font-semibold">{c.qty}</span>
label="+" <QtyButton
onClick={() => addToCart(c.item)} label="+"
variant="filled" onClick={() => addToCart(c.item)}
color={primary} variant="filled"
/> color={primary}
/>
</div>
</div> </div>
<input
type="text"
value={c.note ?? ""}
onChange={(e) =>
setCart((prev) =>
prev.map((l) =>
l.item.id === c.item.id ? { ...l, note: e.target.value } : l
)
)
}
placeholder={t("itemNote")}
className="w-full rounded-md border qr-border bg-transparent px-2 py-1.5 text-xs placeholder:opacity-60 focus:outline-none"
/>
</div> </div>
))} ))}
</div> </div>
@@ -4,7 +4,11 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -45,8 +49,11 @@ const statusStyle: Record<ReservationStatus, string> = {
export function ReservationsScreen() { export function ReservationsScreen() {
const t = useTranslations("reservations"); const t = useTranslations("reservations");
const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<Reservation | null>(null);
const [guestName, setGuestName] = useState(""); const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("09121234567"); const [guestPhone, setGuestPhone] = useState("09121234567");
@@ -92,6 +99,16 @@ export function ReservationsScreen() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }),
}); });
const deleteReservation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/reservations/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
const posHref = (r: Reservation) => { const posHref = (r: Reservation) => {
@@ -245,6 +262,15 @@ export function ReservationsScreen() {
{t("markCompleted")} {t("markCompleted")}
</Button> </Button>
)} )}
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(r)}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -252,6 +278,19 @@ export function ReservationsScreen() {
))} ))}
</ul> </ul>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={
deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.guestName }) : undefined
}
busy={deleteReservation.isPending}
onConfirm={() => deleteTarget && deleteReservation.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
@@ -366,6 +366,26 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
> >
ذخیره موقعیت ذخیره موقعیت
</Button> </Button>
<Button
variant="outline"
onClick={() => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
notify.error("مرورگر شما موقعیت‌یابی را پشتیبانی نمی‌کند");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatInput(pos.coords.latitude.toFixed(5));
setLngInput(pos.coords.longitude.toFixed(5));
setLocationError(null);
},
() => notify.error("دسترسی به موقعیت امکان‌پذیر نبود. لطفاً اجازه دسترسی بدهید."),
{ enableHighAccuracy: true, timeout: 10000 }
);
}}
>
موقعیت فعلی من
</Button>
{(latInput || lngInput) && ( {(latInput || lngInput) && (
<Button <Button
variant="ghost" variant="ghost"
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useApiError } from "@/lib/use-api-error";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react"; import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
@@ -34,6 +35,7 @@ export function CheckoutScreen() {
const t = useTranslations("subscription"); const t = useTranslations("subscription");
const tc = useTranslations("subscription.checkout"); const tc = useTranslations("subscription.checkout");
const tPlans = useTranslations("settings.plans"); const tPlans = useTranslations("settings.plans");
const apiError = useApiError();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -66,6 +68,37 @@ export function CheckoutScreen() {
enabled: !!cafeId && isCafeOwner(role), enabled: !!cafeId && isCafeOwner(role),
}); });
// If the owner is still covered (active plan and/or queued plans), this purchase will be
// queued to start when the current coverage ends rather than activating immediately.
const { data: billingStatus } = useQuery({
queryKey: ["billing-status", cafeId],
queryFn: () =>
apiGet<{
planTier: string;
planExpiresAt: string | null;
isPlanExpired: boolean;
queuedPlans: { effectiveTo: string }[];
}>("/api/billing/status"),
enabled: !!cafeId && isCafeOwner(role),
});
const coverageEnd = useMemo(() => {
if (!billingStatus) return null;
const now = Date.now();
let end = 0;
if (
billingStatus.planTier !== "Free" &&
billingStatus.planExpiresAt &&
!billingStatus.isPlanExpired
) {
end = Math.max(end, new Date(billingStatus.planExpiresAt).getTime());
}
for (const q of billingStatus.queuedPlans ?? []) {
end = Math.max(end, new Date(q.effectiveTo).getTime());
}
return end > now ? new Date(end) : null;
}, [billingStatus]);
useEffect(() => { useEffect(() => {
if (!paymentMethod && paymentMethods.length > 0) { if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0]; const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
@@ -81,8 +114,7 @@ export function CheckoutScreen() {
window.location.href = data.paymentUrl; window.location.href = data.paymentUrl;
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err); setPayError(apiError(err, tc("paymentFailed")));
setPayError(msg || tc("paymentFailed"));
}, },
}); });
@@ -139,6 +171,13 @@ export function CheckoutScreen() {
} }
/> />
{coverageEnd ? (
<div className="flex items-start gap-2 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/50 px-4 py-3 text-sm text-[#0F6E56]">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<p>{tc("queuedNotice", { date: coverageEnd.toLocaleDateString(numberLocale) })}</p>
</div>
) : null}
{/* Factor / invoice */} {/* Factor / invoice */}
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm"> <Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
{/* Invoice header */} {/* Invoice header */}
@@ -2,10 +2,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { CalendarClock, Trash2 } from "lucide-react";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions"; import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
@@ -14,9 +15,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { PlanComparison } from "@/components/settings/plan-comparison"; import { PlanComparison } from "@/components/settings/plan-comparison";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import type { AuthTokenResponse } from "@/lib/api/types"; import type { AuthTokenResponse } from "@/lib/api/types";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
type QueuedPlan = {
paymentId: string;
planTier: string;
months: number;
effectiveFrom: string;
effectiveTo: string;
amountToman: number;
};
type BillingStatus = { type BillingStatus = {
planTier: string; planTier: string;
@@ -30,6 +42,7 @@ type BillingStatus = {
menu3dEnabled: boolean; menu3dEnabled: boolean;
discoverProfileEnabled: boolean; discoverProfileEnabled: boolean;
isPlanExpired: boolean; isPlanExpired: boolean;
queuedPlans: QueuedPlan[];
}; };
export function SubscriptionScreen() { export function SubscriptionScreen() {
@@ -40,8 +53,11 @@ export function SubscriptionScreen() {
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const setAuth = useAuthStore((s) => s.setAuth); const setAuth = useAuthStore((s) => s.setAuth);
const apiError = useApiError();
const queryClient = useQueryClient();
const billingRefreshed = useRef(false); const billingRefreshed = useRef(false);
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null); const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
const [cancelTarget, setCancelTarget] = useState<QueuedPlan | null>(null);
useEffect(() => { useEffect(() => {
const billing = searchParams.get("billing"); const billing = searchParams.get("billing");
@@ -61,6 +77,18 @@ export function SubscriptionScreen() {
enabled: !!cafeId, enabled: !!cafeId,
}); });
const cancelQueued = useMutation({
mutationFn: (paymentId: string) => apiDelete(`/api/billing/queued/${paymentId}`),
onSuccess: () => {
setCancelTarget(null);
notify.success(t("queued.cancelled"));
queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
},
onError: (err) => notify.error(apiError(err)),
});
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString("fa-IR");
useEffect(() => { useEffect(() => {
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return; if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
const refresh = localStorage.getItem("meezi_refresh_token"); const refresh = localStorage.getItem("meezi_refresh_token");
@@ -155,12 +183,72 @@ export function SubscriptionScreen() {
</CardContent> </CardContent>
</Card> </Card>
{status?.queuedPlans && status.queuedPlans.length > 0 ? (
<Card className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/30 shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="size-4 text-[#0F6E56]" aria-hidden />
{t("queued.title")}
</CardTitle>
<p className="text-sm text-muted-foreground">{t("queued.subtitle")}</p>
</CardHeader>
<CardContent className="space-y-2">
{status.queuedPlans.map((q) => (
<div
key={q.paymentId}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card px-3 py-2.5"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge>{q.planTier}</Badge>
<span className="text-sm text-muted-foreground">
{t("queued.months", { count: formatNumber(q.months) })}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{t("queued.window", { from: fmtDate(q.effectiveFrom), to: fmtDate(q.effectiveTo) })}
</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setCancelTarget(q)}
>
<Trash2 className="me-1.5 size-4" />
{t("queued.cancel")}
</Button>
</div>
))}
</CardContent>
</Card>
) : null}
<PlanComparison <PlanComparison
currentPlan={status?.planTier ?? "Free"} currentPlan={status?.planTier ?? "Free"}
onSubscribe={(planTier) => onSubscribe={(planTier) =>
router.push(`/subscription/checkout?plan=${planTier}`) router.push(`/subscription/checkout?plan=${planTier}`)
} }
/> />
<ConfirmDialog
open={!!cancelTarget}
onOpenChange={(o) => {
if (!o) setCancelTarget(null);
}}
title={t("queued.cancelConfirmTitle")}
description={
cancelTarget
? t("queued.cancelConfirmDesc", {
plan: cancelTarget.planTier,
from: fmtDate(cancelTarget.effectiveFrom),
})
: undefined
}
confirmLabel={t("queued.cancel")}
busy={cancelQueued.isPending}
onConfirm={() => cancelTarget && cancelQueued.mutate(cancelTarget.paymentId)}
/>
</div> </div>
); );
} }
@@ -6,6 +6,7 @@ import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -52,6 +53,7 @@ function formatDate(iso: string) {
export function SupportScreen() { export function SupportScreen() {
const t = useTranslations("support"); const t = useTranslations("support");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
@@ -61,6 +63,7 @@ export function SupportScreen() {
data: tickets = [], data: tickets = [],
isLoading, isLoading,
isError, isError,
error,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["support", cafeId], queryKey: ["support", cafeId],
@@ -135,7 +138,7 @@ export function SupportScreen() {
</p> </p>
{isError ? ( {isError ? (
<Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive"> <Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive">
<p>{t("loadFailed")}</p> <p>{apiError(error, t("loadFailed"))}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}> <Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}>
{t("retry")} {t("retry")}
</Button> </Button>
@@ -7,6 +7,7 @@ import * as signalR from "@microsoft/signalr";
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react"; import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { MediaPairUpload } from "@/components/media/media-pair-upload"; import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { import {
@@ -53,6 +54,7 @@ export function TablesScreen() {
const branchId = useBranchStore((s) => s.branchId); const branchId = useBranchStore((s) => s.branchId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const confirmDialog = useConfirm(); const confirmDialog = useConfirm();
const apiError = useApiError();
const [actionMessage, setActionMessage] = useState<string | null>(null); const [actionMessage, setActionMessage] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [number, setNumber] = useState(""); const [number, setNumber] = useState("");
@@ -123,7 +125,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError"); const msg = apiError(err, t("createError"));
setActionMessage(msg); setActionMessage(msg);
notify.error(msg); notify.error(msg);
}, },
@@ -142,7 +144,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
setActionMessage(err instanceof ApiClientError ? err.message : t("cleaningError")); setActionMessage(apiError(err, t("cleaningError")));
}, },
}); });
@@ -158,7 +160,7 @@ export function TablesScreen() {
setActionMessage(t("tableHasOpenOrder")); setActionMessage(t("tableHasOpenOrder"));
return; return;
} }
setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError")); setActionMessage(apiError(err, t("deleteError")));
}, },
}); });
@@ -188,7 +190,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError"); const msg = apiError(err, t("createError"));
setActionMessage(msg); setActionMessage(msg);
notify.error(msg); notify.error(msg);
}, },
@@ -0,0 +1,68 @@
"use client";
import { useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
/**
* Shared confirmation dialog (used for destructive delete actions across screens).
* Keeps the dialog open while `busy` so the row stays until the mutation resolves;
* the caller closes it via onOpenChange(false) on success.
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
onConfirm,
busy = false,
destructive = true,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmLabel?: string;
onConfirm: () => void;
busy?: boolean;
destructive?: boolean;
}) {
const tCommon = useTranslations("common");
const isRtl = useIsRtl();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className={destructive ? "bg-red-600 text-white hover:bg-red-700" : ""}
disabled={busy}
onClick={(e) => {
e.preventDefault(); // keep open until the mutation resolves
onConfirm();
}}
>
{busy ? tCommon("loading") : confirmLabel ?? tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+5
View File
@@ -4,6 +4,11 @@ import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["fa", "ar", "en"], locales: ["fa", "ar", "en"],
defaultLocale: "fa", defaultLocale: "fa",
// Iran-first: don't auto-pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). A locale-less URL defaults to
// fa; the locale is otherwise taken from the URL prefix or the marketing-site
// link (e.g. app.meezi.ir/fa/login).
localeDetection: false,
}); });
export const { Link, redirect, usePathname, useRouter } = export const { Link, redirect, usePathname, useRouter } =
@@ -8,6 +8,7 @@ export type CafeProfileEdit = {
instagramHandle: string | null; instagramHandle: string | null;
websiteUrl: string | null; websiteUrl: string | null;
workingHours: WorkingHours | null; workingHours: WorkingHours | null;
showOnKoja: boolean;
}; };
export type UpdateCafeProfilePayload = { export type UpdateCafeProfilePayload = {
@@ -15,6 +16,7 @@ export type UpdateCafeProfilePayload = {
instagramHandle?: string | null; instagramHandle?: string | null;
websiteUrl?: string | null; websiteUrl?: string | null;
workingHours?: WorkingHours | null; workingHours?: WorkingHours | null;
showOnKoja?: boolean;
}; };
async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> { async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
+96 -25
View File
@@ -5,6 +5,13 @@ import axios, {
import type { ApiResponse, AuthTokenResponse } from "./types"; import type { ApiResponse, AuthTokenResponse } from "./types";
import { getOrCreateTerminalId } from "@/lib/terminal"; import { getOrCreateTerminalId } from "@/lib/terminal";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import {
isNetworkError,
isOnlineOnly,
newIdempotencyKey,
OfflineUnavailableError,
queueWrite,
} from "@/lib/offline/offline-write";
const baseURL = const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208"; process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
@@ -79,7 +86,7 @@ api.interceptors.response.use(
const apiError = error.response?.data?.error; const apiError = error.response?.data?.error;
if (apiError?.code) { if (apiError?.code) {
return Promise.reject(new ApiClientError(apiError.code, apiError.message)); return Promise.reject(new ApiClientError(apiError.code, apiError.message, undefined, status));
} }
if (status === 401 && typeof window !== "undefined") { if (status === 401 && typeof window !== "undefined") {
const path = window.location.pathname; const path = window.location.pathname;
@@ -131,46 +138,110 @@ export class ApiClientError extends Error {
public readonly code: string, public readonly code: string,
message: string, message: string,
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */ /** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
public readonly payload?: unknown public readonly payload?: unknown,
/** HTTP status, when known — lets callers (e.g. the outbox) tell 5xx (retry) from 4xx (give up). */
public readonly status?: number
) { ) {
super(message); super(message);
this.name = "ApiClientError"; this.name = "ApiClientError";
} }
} }
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> { /** Options for mutating requests. */
const { data } = await api.post<ApiResponse<T>>(url, body); export interface WriteOptions {
/** Reused as the `Idempotency-Key` header so the server de-duplicates retries. */
idempotencyKey?: string;
/**
* Offline behavior:
* - undefined / "queue": auto-queue on offline/network failure and return an
* optimistic value (unless the URL is online-only throws).
* - "reject": never queue throw OfflineUnavailableError when offline.
* - "manual": caller handles offline itself; never auto-queue (POS order submit).
*/
offline?: "queue" | "reject" | "manual";
}
async function rawWrite<T>(
method: "POST" | "PUT" | "PATCH" | "DELETE",
url: string,
body: unknown,
key: string
): Promise<T> {
const config = { headers: { "Idempotency-Key": key } };
let data: ApiResponse<T>;
switch (method) {
case "POST":
({ data } = await api.post<ApiResponse<T>>(url, body, config));
break;
case "PUT":
({ data } = await api.put<ApiResponse<T>>(url, body, config));
break;
case "PATCH":
({ data } = await api.patch<ApiResponse<T>>(url, body, config));
break;
case "DELETE":
({ data } = await api.delete<ApiResponse<T>>(url, config));
break;
}
if (method === "DELETE") {
if (!data.success) {
throw new ApiClientError(data.error?.code ?? "REQUEST_FAILED", data.error?.message ?? "Request failed");
}
return undefined as T;
}
if (!data.success || data.data === undefined) { if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED"; throw new ApiClientError(
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data); data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed",
data.data
);
} }
return data.data; return data.data;
} }
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> { async function doWrite<T>(
const { data } = await api.put<ApiResponse<T>>(url, body); method: "POST" | "PUT" | "PATCH" | "DELETE",
if (!data.success || data.data === undefined) { url: string,
const code = data.error?.code ?? "REQUEST_FAILED"; body: unknown,
throw new ApiClientError(code, data.error?.message ?? "Request failed"); opts?: WriteOptions
): Promise<T> {
const manual = opts?.offline === "manual";
const key = opts?.idempotencyKey ?? newIdempotencyKey();
const onlineOnly = opts?.offline === "reject" || isOnlineOnly(url);
const offline = typeof navigator !== "undefined" && !navigator.onLine;
// Already offline: queue (or reject online-only) without attempting the network.
if (offline && !manual) {
if (onlineOnly) throw new OfflineUnavailableError();
return (await queueWrite(method, url, body, key)) as T;
}
try {
return await rawWrite<T>(method, url, body, key);
} catch (err) {
// A genuine network failure (no response) → queue and return optimistic.
// Real server/validation errors and online-only endpoints still throw.
if (!manual && !onlineOnly && isNetworkError(err)) {
return (await queueWrite(method, url, body, key)) as T;
}
throw err;
} }
return data.data;
} }
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> { export async function apiPost<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
const { data } = await api.patch<ApiResponse<T>>(url, body); return doWrite<T>("POST", url, body, opts);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
return data.data;
} }
export async function apiDelete(url: string): Promise<void> { export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
const { data } = await api.delete<ApiResponse<unknown>>(url); return doWrite<T>("PUT", url, body, opts);
if (!data.success) { }
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed"); export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
} return doWrite<T>("PATCH", url, body, opts);
}
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
await doWrite<void>("DELETE", url, undefined, opts);
} }
/** GET binary response (QR PNG, Excel export, etc.) with auth headers. */ /** GET binary response (QR PNG, Excel export, etc.) with auth headers. */
+4 -1
View File
@@ -46,7 +46,10 @@ export const notify = {
}; };
export function getErrorMessage(err: unknown, fallback: string): string { export function getErrorMessage(err: unknown, fallback: string): string {
if (err instanceof ApiClientError) return err.message; // ApiClientError.message is the raw (usually English) backend message; prefer
// the caller's localized fallback. For code-specific localized text, use the
// useApiError() hook instead of this helper.
if (err instanceof ApiClientError) return fallback;
if (err instanceof Error && err.message) return err.message; if (err instanceof Error && err.message) return err.message;
return fallback; return fallback;
} }
+168 -1
View File
@@ -22,8 +22,13 @@ export type OfflineQueueItem = {
}; };
const DB_NAME = "meezi_pos_offline"; const DB_NAME = "meezi_pos_offline";
const DB_VERSION = 1; const DB_VERSION = 3;
/** Legacy POS-orders-only queue (kept for one-time migration into the outbox). */
const STORE = "order_queue"; const STORE = "order_queue";
/** Generic key-value store (used to persist the React Query cache for offline reads). */
const KV_STORE = "kv";
/** Generic write outbox: any mutating request, replayed with idempotency + id remap. */
const OUTBOX_STORE = "outbox";
let _db: IDBDatabase | null = null; let _db: IDBDatabase | null = null;
@@ -36,6 +41,12 @@ function openDb(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(STORE)) { if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "id" }); db.createObjectStore(STORE, { keyPath: "id" });
} }
if (!db.objectStoreNames.contains(KV_STORE)) {
db.createObjectStore(KV_STORE);
}
if (!db.objectStoreNames.contains(OUTBOX_STORE)) {
db.createObjectStore(OUTBOX_STORE, { keyPath: "id" });
}
}; };
req.onsuccess = () => { req.onsuccess = () => {
_db = req.result; _db = req.result;
@@ -109,3 +120,159 @@ export async function markQueueItemFailed(id: string): Promise<void> {
tx.onerror = () => reject(tx.error); tx.onerror = () => reject(tx.error);
}); });
} }
// ─── Generic key-value store (React Query cache persistence) ───────────────────
/** Store an arbitrary JSON-serializable value under a key. Never throws. */
export async function kvSet(key: string, value: unknown): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(KV_STORE, "readwrite");
tx.objectStore(KV_STORE).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// IndexedDB unavailable / quota exceeded / blocked — degrade silently.
}
}
/** Read a value previously stored with {@link kvSet}. Returns undefined on any failure. */
export async function kvGet<T>(key: string): Promise<T | undefined> {
try {
const db = await openDb();
return await new Promise<T | undefined>((resolve, reject) => {
const tx = db.transaction(KV_STORE, "readonly");
const req = tx.objectStore(KV_STORE).get(key);
req.onsuccess = () => resolve(req.result as T | undefined);
req.onerror = () => reject(req.error);
});
} catch {
return undefined;
}
}
/** Remove a persisted value (e.g. on logout, to avoid leaking another user's cache). */
export async function kvDelete(key: string): Promise<void> {
try {
const db = await openDb();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(KV_STORE, "readwrite");
tx.objectStore(KV_STORE).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
} catch {
// ignore
}
}
// ─── Generic write outbox ──────────────────────────────────────────────────────
export type OutboxMethod = "POST" | "PUT" | "PATCH" | "DELETE";
export type OutboxOp = {
/** Local op id (primary key). */
id: string;
/** Stable Idempotency-Key sent on every send attempt for this op. */
idempotencyKey: string;
method: OutboxMethod;
/** Request URL; may embed a local id (local_*) to be remapped after its creator syncs. */
url: string;
body?: unknown;
/** Coarse entity kind, for conflict policy + UI grouping (e.g. "order", "menu_item"). */
entityType: string;
/** The local id this op creates, if any — enables remapping later ops that reference it. */
createsClientId?: string;
/** Dotted path to the new server id in the response data (default "id"). */
idField?: string;
createdAt: number;
attempts: number;
status: "pending" | "failed";
lastError?: string;
};
export async function enqueueOutboxOp(
op: Omit<OutboxOp, "attempts" | "status">
): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readwrite");
tx.objectStore(OUTBOX_STORE).put({ ...op, attempts: 0, status: "pending" });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/** All queued ops, oldest first (insertion / causal order). */
export async function getOutboxOps(): Promise<OutboxOp[]> {
try {
const db = await openDb();
const ops = await new Promise<OutboxOp[]>((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readonly");
const req = tx.objectStore(OUTBOX_STORE).getAll();
req.onsuccess = () => resolve(req.result as OutboxOp[]);
req.onerror = () => reject(req.error);
});
return ops.sort((a, b) => a.createdAt - b.createdAt);
} catch {
return [];
}
}
export async function getOutboxCount(): Promise<number> {
try {
const db = await openDb();
return await new Promise<number>((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readonly");
const req = tx.objectStore(OUTBOX_STORE).count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
} catch {
return 0;
}
}
export async function removeOutboxOp(id: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readwrite");
tx.objectStore(OUTBOX_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function updateOutboxOp(
id: string,
patch: Partial<Pick<OutboxOp, "status" | "attempts" | "lastError">>
): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(OUTBOX_STORE, "readwrite");
const store = tx.objectStore(OUTBOX_STORE);
const getReq = store.get(id);
getReq.onsuccess = () => {
const op = getReq.result as OutboxOp | undefined;
if (op) store.put({ ...op, ...patch });
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ─── client→server id map (persisted across reloads) ───────────────────────────
const ID_MAP_KEY = "outbox_id_map";
export async function getIdMap(): Promise<Record<string, string>> {
return (await kvGet<Record<string, string>>(ID_MAP_KEY)) ?? {};
}
export async function setIdMapEntry(clientId: string, serverId: string): Promise<void> {
const map = await getIdMap();
map[clientId] = serverId;
await kvSet(ID_MAP_KEY, map);
}
@@ -0,0 +1,120 @@
/**
* Generic offline durability for the central API client. When a write happens
* while offline (or fails with a network error), it is enqueued in the outbox
* and an optimistic value is returned, so no write is ever lost instead of the
* mutation throwing. The online path is unchanged apart from an idempotency key.
*
* A small set of endpoints are *online-only* (payments, billing, auth, SMS): these
* must never be queued they throw {@link OfflineUnavailableError} when offline so
* the UI can tell the user to reconnect.
*/
import {
enqueueOutboxOp,
getOutboxCount,
getQueueCount,
type OutboxMethod,
} from "@/lib/offline/offline-db";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
/** Endpoints that require a live connection and must NOT be queued offline. */
const ONLINE_ONLY: RegExp[] = [
/\/api\/auth\//, // login / refresh / register / OTP
/\/api\/billing\b/, // checkout / verify / payment gateway
/\/payments?\b/, // taking payment against an order/shift
/\/api\/sms\b/, // sending SMS now / campaigns
/\/send-sms\b/,
/\/export\b/, // server-computed exports
];
export function isOnlineOnly(url: string): boolean {
return ONLINE_ONLY.some((re) => re.test(url));
}
export class OfflineUnavailableError extends Error {
readonly code = "OFFLINE_UNAVAILABLE";
constructor(message = "This action needs an internet connection.") {
super(message);
this.name = "OfflineUnavailableError";
}
}
export function isNetworkError(err: unknown): boolean {
if (err instanceof TypeError) {
const msg = err.message.toLowerCase();
return (
msg.includes("failed to fetch") ||
msg.includes("networkerror") ||
msg.includes("load failed") ||
msg.includes("network request failed")
);
}
const ax = err as { isAxiosError?: boolean; response?: unknown };
return !!ax?.isAxiosError && !ax.response;
}
export function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
}
export function newLocalId(): string {
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Best-effort entity kind from a URL (last non-id path segment). */
export function entityTypeFromUrl(url: string): string {
const path = (url.split("?")[0] ?? "").replace(/^\/api\//, "");
const segs = path.split("/").filter(Boolean);
for (let i = segs.length - 1; i >= 0; i--) {
const s = segs[i];
const looksLikeId = /^[0-9a-f]{16,}$/i.test(s) || s.startsWith("local_");
if (!looksLikeId) return s;
}
return segs[0] ?? "entity";
}
async function refreshBadge(): Promise<void> {
const n = (await getOutboxCount()) + (await getQueueCount());
useSyncQueueStore.getState().setQueueCount(n);
}
/**
* Enqueue a write to the outbox and synthesize an optimistic return value.
* POST treated as a create (local id, remappable later); PUT/PATCH echo the
* body; DELETE void.
*/
export async function queueWrite(
method: OutboxMethod,
url: string,
body: unknown,
idempotencyKey: string
): Promise<unknown> {
let createsClientId: string | undefined;
let optimistic: unknown;
if (method === "POST") {
createsClientId = newLocalId();
optimistic =
body && typeof body === "object"
? { id: createsClientId, ...(body as Record<string, unknown>) }
: { id: createsClientId };
} else if (method === "DELETE") {
optimistic = undefined;
} else {
optimistic = body && typeof body === "object" ? { ...(body as Record<string, unknown>) } : body;
}
await enqueueOutboxOp({
id: newLocalId(),
idempotencyKey,
method,
url,
body,
entityType: entityTypeFromUrl(url),
createsClientId,
idField: "id",
createdAt: Date.now(),
});
await refreshBadge();
return optimistic;
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Generic offline write engine.
*
* Every offline write is recorded as an {@link OutboxOp} carrying a stable
* idempotency key. On reconnect the outbox is drained in causal (insertion)
* order:
* - local ids (local_*) created by earlier ops are remapped to their real
* server ids before an op that references them is sent;
* - each op is sent with its idempotency key, so a replay after a lost response
* is de-duplicated by the server instead of creating a duplicate;
* - failures are classified: offline stop; server 5xx / in-progress
* retry next pass; client 4xx count an attempt and poison after MAX.
*/
import { isAxiosError } from "axios";
import {
apiDelete,
apiPatch,
apiPost,
apiPut,
ApiClientError,
type WriteOptions,
} from "@/lib/api/client";
import {
getIdMap,
getOutboxOps,
removeOutboxOp,
setIdMapEntry,
updateOutboxOp,
type OutboxOp,
} from "@/lib/offline/offline-db";
const MAX_ATTEMPTS = 5;
/** Matches local placeholder ids like `local_1717…_a1b2c3`. */
const LOCAL_ID_RE = /local_[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*/g;
function getByPath(obj: unknown, path: string): string | undefined {
let cur: unknown = obj;
for (const part of path.split(".")) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[part];
}
return typeof cur === "string" ? cur : undefined;
}
/**
* Replace known local ids in the op's url/body with their server ids. Returns
* `blocked: true` if it still references an unresolved local id (its creator
* hasn't synced yet) other than the id this op itself creates.
*/
export function remapReferences(
op: Pick<OutboxOp, "url" | "body" | "createsClientId">,
idMap: Record<string, string>
): { url: string; body: unknown; blocked: boolean } {
let url = op.url;
let bodyStr = op.body !== undefined ? JSON.stringify(op.body) : "";
for (const [clientId, serverId] of Object.entries(idMap)) {
if (url.includes(clientId)) url = url.split(clientId).join(serverId);
if (bodyStr && bodyStr.includes(clientId)) bodyStr = bodyStr.split(clientId).join(serverId);
}
const remaining = `${url} ${bodyStr}`.match(LOCAL_ID_RE) ?? [];
const unresolved = remaining.filter((id) => id !== op.createsClientId);
return {
url,
body: bodyStr !== "" ? JSON.parse(bodyStr) : op.body,
blocked: unresolved.length > 0,
};
}
async function sendOp(op: OutboxOp, url: string, body: unknown): Promise<unknown> {
const opts: WriteOptions = { idempotencyKey: op.idempotencyKey };
switch (op.method) {
case "POST":
return apiPost(url, body, opts);
case "PUT":
return apiPut(url, body, opts);
case "PATCH":
return apiPatch(url, body, opts);
case "DELETE":
await apiDelete(url, opts);
return undefined;
}
}
type Disposition = "offline" | "transient" | "permanent";
function classify(err: unknown): Disposition {
if (err instanceof ApiClientError) {
if (err.code === "IDEMPOTENCY_IN_PROGRESS") return "transient"; // another tab/pass owns it
if (err.status !== undefined && err.status >= 500) return "transient";
return "permanent"; // validation / 4xx — retrying the same payload won't help
}
if (isAxiosError(err)) {
if (!err.response) return "offline"; // network down
if (err.response.status >= 500) return "transient";
return "permanent";
}
return "permanent";
}
function errMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
export type DrainResult = { sent: number; remaining: number; ran: boolean };
let draining = false;
/** Drain the outbox once, in causal order. Never throws. */
export async function drainOutbox(): Promise<DrainResult> {
const isOffline = typeof navigator !== "undefined" && !navigator.onLine;
if (draining || isOffline) {
return { sent: 0, remaining: (await getOutboxOps()).length, ran: false };
}
draining = true;
let sent = 0;
try {
const idMap = await getIdMap();
const ops = await getOutboxOps();
for (const op of ops) {
if (op.status === "failed" && op.attempts >= MAX_ATTEMPTS) continue; // poisoned
const { url, body, blocked } = remapReferences(op, idMap);
if (blocked) continue; // a dependency hasn't synced yet; revisit next pass
try {
const result = await sendOp(op, url, body);
if (op.createsClientId) {
const serverId = getByPath(result, op.idField ?? "id");
if (serverId) {
idMap[op.createsClientId] = serverId;
await setIdMapEntry(op.createsClientId, serverId);
}
}
await removeOutboxOp(op.id);
sent++;
} catch (err) {
const disposition = classify(err);
if (disposition === "offline") break; // stop the whole pass; resume when online
if (disposition === "transient") {
await updateOutboxOp(op.id, { lastError: errMessage(err) }); // retry, don't burn an attempt
continue;
}
await updateOutboxOp(op.id, {
status: "failed",
attempts: op.attempts + 1,
lastError: errMessage(err),
});
}
}
} finally {
draining = false;
}
return { sent, remaining: (await getOutboxOps()).length, ran: true };
}
/** Ops that exhausted their retries and need user attention. */
export async function getPoisonedOps(): Promise<OutboxOp[]> {
const ops = await getOutboxOps();
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
}
@@ -0,0 +1,70 @@
/**
* Persists the React Query cache to IndexedDB so the dashboard is *viewable*
* offline (last-synced data) and survives a reload with no connection.
*
* Uses `dehydrate`/`hydrate` from @tanstack/react-query directly no extra
* dependency. Writes are debounced; reads are guarded by a schema buster, a
* max-age, and a tenant scope so one café never hydrates another's data.
*/
import { dehydrate, hydrate, type QueryClient } from "@tanstack/react-query";
import { kvGet, kvSet } from "@/lib/offline/offline-db";
const CACHE_KEY = "rq-cache";
/** Bump when cached shapes change so stale persisted data is dropped on deploy. */
const BUSTER = "v1";
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
const SAVE_DEBOUNCE_MS = 1000;
type PersistedCache = {
buster: string;
timestamp: number;
/** Tenant/user scope this cache belongs to (cafeId, or "anon"). */
scope: string;
state: unknown;
};
/**
* Hydrate the query cache from IndexedDB if a valid snapshot exists for this
* scope. Safe to call before or after queries mount.
*/
export async function restoreQueryCache(qc: QueryClient, scope: string): Promise<void> {
const saved = await kvGet<PersistedCache>(CACHE_KEY);
if (!saved) return;
if (saved.buster !== BUSTER) return; // schema changed
if (saved.scope !== scope) return; // different tenant/user — do not leak
if (Date.now() - saved.timestamp > MAX_AGE_MS) return; // too old
try {
hydrate(qc, saved.state as never);
} catch {
// corrupt snapshot — ignore, it will be overwritten on next save
}
}
/**
* Subscribe to cache changes and persist a debounced snapshot. Returns an
* unsubscribe function.
*/
export function startPersisting(qc: QueryClient, scope: string): () => void {
let timer: ReturnType<typeof setTimeout> | null = null;
const save = () => {
timer = null;
const snapshot: PersistedCache = {
buster: BUSTER,
timestamp: Date.now(),
scope,
state: dehydrate(qc),
};
void kvSet(CACHE_KEY, snapshot);
};
const unsubscribe = qc.getQueryCache().subscribe(() => {
if (timer) return; // a save is already scheduled
timer = setTimeout(save, SAVE_DEBOUNCE_MS);
});
return () => {
if (timer) clearTimeout(timer);
unsubscribe();
};
}
@@ -1,87 +1,125 @@
"use client"; "use client";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import { import {
enqueueOutboxOp,
getAllQueueItems, getAllQueueItems,
getOutboxCount,
getQueueCount, getQueueCount,
removeQueueItem, removeQueueItem,
markQueueItemFailed,
} from "@/lib/offline/offline-db"; } from "@/lib/offline/offline-db";
import { apiPost } from "@/lib/api/client"; import { drainOutbox } from "@/lib/offline/outbox";
function newId(prefix: string): string {
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
}
/** /**
* Processes one queued item and returns whether it succeeded. * One-time migration of any items left in the legacy POS `order_queue` into the
* generic outbox, so orders queued before this release still sync. Best-effort.
*/ */
async function processItem(item: Awaited<ReturnType<typeof getAllQueueItems>>[number]): Promise<boolean> { async function migrateLegacyQueue(): Promise<void> {
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
try { try {
if (item.type === "create_order") { legacy = await getAllQueueItems();
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await apiPost(
`/api/cafes/${cafeId}/orders/${orderId}/items`,
body as Record<string, unknown>
);
}
return true;
} catch { } catch {
return false; return;
}
for (const item of legacy) {
try {
if (item.type === "create_order") {
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
await enqueueOutboxOp({
id: newId("op"),
idempotencyKey: newId("idem"),
method: "POST",
url: `/api/cafes/${cafeId}/orders`,
body,
entityType: "order",
idField: "id",
createdAt: Date.parse(item.createdAt) || Date.now(),
});
} else if (item.type === "add_items") {
const { cafeId, orderId, body } = item.payload as {
cafeId: string;
orderId: string;
body: unknown;
};
await enqueueOutboxOp({
id: newId("op"),
idempotencyKey: newId("idem"),
method: "POST",
url: `/api/cafes/${cafeId}/orders/${orderId}/items`,
body,
entityType: "order_items",
createdAt: Date.parse(item.createdAt) || Date.now(),
});
}
await removeQueueItem(item.id);
} catch {
// leave the legacy item in place; we'll try again next mount
}
} }
} }
/** /**
* Call this hook once in the app shell to: * Mount once in the app shell to:
* - Load initial queue count from IndexedDB on mount * - migrate any legacy queued orders into the outbox,
* - Listen to online/offline events * - keep the pending-count badge and online flag in sync,
* - Auto-sync when back online or tab becomes visible * - drain the outbox when back online or the tab regains focus,
* - refresh server data once writes have synced.
*/ */
export function useOfflineSync() { export function useOfflineSync() {
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore(); const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
const queryClient = useQueryClient();
const syncLock = useRef(false); const syncLock = useRef(false);
const refreshCount = useCallback(async () => { const refreshCount = useCallback(async () => {
const n = await getQueueCount(); const n = (await getOutboxCount()) + (await getQueueCount());
setQueueCount(n); setQueueCount(n);
return n; return n;
}, [setQueueCount]); }, [setQueueCount]);
const syncQueue = useCallback(async () => { const syncQueue = useCallback(async () => {
if (syncLock.current) return; if (syncLock.current) return;
if (!navigator.onLine) return; if (typeof navigator !== "undefined" && !navigator.onLine) return;
const count = await refreshCount();
if (count === 0) return;
syncLock.current = true; syncLock.current = true;
setSyncing(true); setSyncing(true);
try { try {
const items = await getAllQueueItems(); const result = await drainOutbox();
for (const item of items) { if (result.sent > 0) {
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3 // Replace optimistic local data with the authoritative server state.
const ok = await processItem(item); await queryClient.invalidateQueries();
if (ok) {
await removeQueueItem(item.id);
} else {
await markQueueItemFailed(item.id);
}
} }
} finally { } finally {
syncLock.current = false; syncLock.current = false;
setSyncing(false); setSyncing(false);
await refreshCount(); await refreshCount();
} }
}, [refreshCount, setSyncing]); }, [refreshCount, setSyncing, queryClient]);
useEffect(() => { useEffect(() => {
// Load initial count // Ask the browser to keep our IndexedDB (outbox + cache) from being evicted
void refreshCount(); // under storage pressure, so unsynced writes survive.
if (typeof navigator !== "undefined" && navigator.storage?.persist) {
void navigator.storage.persisted().then((granted) => {
if (!granted) void navigator.storage.persist();
});
}
void (async () => {
await migrateLegacyQueue();
await refreshCount();
// Drain anything pending if we mounted already online.
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
})();
// Track online state
const handleOnline = () => { const handleOnline = () => {
setOnline(true); setOnline(true);
void syncQueue(); void syncQueue();
@@ -92,7 +130,6 @@ export function useOfflineSync() {
window.addEventListener("online", handleOnline); window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline); window.addEventListener("offline", handleOffline);
// Sync when tab regains focus
const handleVisibility = () => { const handleVisibility = () => {
if (document.visibilityState === "visible" && navigator.onLine) { if (document.visibilityState === "visible" && navigator.onLine) {
void syncQueue(); void syncQueue();
+93 -86
View File
@@ -2,7 +2,8 @@ import { apiPost } from "@/lib/api/client";
import type { Order, OrderItemLine } from "@/lib/api/types"; import type { Order, OrderItemLine } from "@/lib/api/types";
import type { CartItem } from "@/lib/stores/cart.store"; import type { CartItem } from "@/lib/stores/cart.store";
import { iranMobileForApi } from "@/lib/phone"; import { iranMobileForApi } from "@/lib/phone";
import { enqueueOfflineItem, getQueueCount } from "@/lib/offline/offline-db"; import { enqueueOutboxOp, getOutboxCount, getQueueCount } from "@/lib/offline/offline-db";
import { isNetworkError, newIdempotencyKey, newLocalId } from "@/lib/offline/offline-write";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store"; import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
export type SubmitOrderCart = { export type SubmitOrderCart = {
@@ -24,32 +25,32 @@ export type SubmitOrderParams = {
cartItems?: CartItem[]; cartItems?: CartItem[];
}; };
// ─── Offline helpers ────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
// isNetworkError / newLocalId / newIdempotencyKey are shared from offline-write.
function isNetworkError(err: unknown): boolean { /** Body for a create-order POST. */
if (err instanceof TypeError) { function buildCreateBody(
const msg = err.message.toLowerCase();
return (
msg.includes("failed to fetch") ||
msg.includes("networkerror") ||
msg.includes("load failed") ||
msg.includes("network request failed")
);
}
return false;
}
function newLocalId(): string {
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
/** Build a synthetic Order that keeps the POS cart functional while offline */
function buildLocalOrder(
params: SubmitOrderParams, params: SubmitOrderParams,
cartItems: CartItem[] pending: ReturnType<SubmitOrderCart["getPendingLines"]>
): Order { ) {
const { cart, orderBranchId, reservationId } = params;
return {
orderType: "DineIn",
branchId: orderBranchId,
tableId: cart.tableId ?? undefined,
reservationId: reservationId ?? undefined,
guestName: cart.guestName.trim() || undefined,
guestPhone: iranMobileForApi(cart.guestPhone),
customerId: cart.customerId ?? undefined,
couponId: cart.appliedCoupon?.id,
items: pending,
};
}
/** Build a synthetic Order so the POS stays usable offline. Uses the supplied
* id so it matches the outbox op's createsClientId (enabling later remap). */
function buildLocalOrder(params: SubmitOrderParams, cartItems: CartItem[], orderId: string): Order {
const pending = params.cart.getPendingLines(); const pending = params.cart.getPendingLines();
const localId = newLocalId();
const items: OrderItemLine[] = pending.map((p) => { const items: OrderItemLine[] = pending.map((p) => {
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId); const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
@@ -69,7 +70,7 @@ function buildLocalOrder(
const total = subtotal + taxTotal; const total = subtotal + taxTotal;
return { return {
id: localId, id: orderId,
cafeId: params.cafeId, cafeId: params.cafeId,
branchId: params.orderBranchId, branchId: params.orderBranchId,
tableId: params.cart.tableId ?? undefined, tableId: params.cart.tableId ?? undefined,
@@ -90,50 +91,58 @@ function buildLocalOrder(
}; };
} }
async function refreshQueueBadge(): Promise<void> {
const count = (await getOutboxCount()) + (await getQueueCount());
useSyncQueueStore.getState().setQueueCount(count);
}
/**
* Queue the write and return a local mock order. Two cases:
* - create: enqueue POST /orders with a fresh local id as createsClientId;
* - add items: enqueue POST /orders/{id}/items. {id} may be a local id the
* outbox blocks then remaps it once the create syncs.
*/
async function queueAndBuildLocalOrder( async function queueAndBuildLocalOrder(
params: SubmitOrderParams, params: SubmitOrderParams,
cartItems: CartItem[] cartItems: CartItem[],
idempotencyKey: string
): Promise<Order> { ): Promise<Order> {
const pending = params.cart.getPendingLines(); const { cafeId, cart } = params;
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending"); if (pending.length === 0) throw new Error("nothing pending");
const isAddToExisting = const activeId = cart.activeOrderId;
!!params.cart.activeOrderId &&
!params.cart.activeOrderId.startsWith("local_");
await enqueueOfflineItem({ if (activeId) {
// Add items to an existing order (real server id, or a not-yet-synced local id).
await enqueueOutboxOp({
id: newLocalId(),
idempotencyKey,
method: "POST",
url: `/api/cafes/${cafeId}/orders/${activeId}/items`,
body: { items: pending },
entityType: "order_items",
createdAt: Date.now(),
});
await refreshQueueBadge();
return buildLocalOrder(params, cartItems, activeId);
}
// Create a brand-new order. createsClientId lets later add-items ops remap.
const localOrderId = newLocalId();
await enqueueOutboxOp({
id: newLocalId(), id: newLocalId(),
type: isAddToExisting ? "add_items" : "create_order", idempotencyKey,
cafeId: params.cafeId, method: "POST",
targetOrderId: isAddToExisting ? params.cart.activeOrderId : null, url: `/api/cafes/${cafeId}/orders`,
payload: isAddToExisting body: buildCreateBody(params, pending),
? { entityType: "order",
cafeId: params.cafeId, createsClientId: localOrderId,
orderId: params.cart.activeOrderId!, idField: "id",
body: { items: pending }, createdAt: Date.now(),
}
: {
cafeId: params.cafeId,
body: {
orderType: "DineIn",
branchId: params.orderBranchId,
tableId: params.cart.tableId ?? undefined,
reservationId: params.reservationId ?? undefined,
guestName: params.cart.guestName.trim() || undefined,
guestPhone: iranMobileForApi(params.cart.guestPhone),
customerId: params.cart.customerId ?? undefined,
couponId: params.cart.appliedCoupon?.id,
items: pending,
},
},
createdAt: new Date().toISOString(),
}); });
await refreshQueueBadge();
// Update global queue count return buildLocalOrder(params, cartItems, localOrderId);
const count = await getQueueCount();
useSyncQueueStore.getState().setQueueCount(count);
return buildLocalOrder(params, cartItems);
} }
// ─── Main export ────────────────────────────────────────────────────────────── // ─── Main export ──────────────────────────────────────────────────────────────
@@ -145,47 +154,45 @@ export async function submitOrderToApi({
reservationId, reservationId,
cartItems = [], cartItems = [],
}: SubmitOrderParams): Promise<Order> { }: SubmitOrderParams): Promise<Order> {
const params: SubmitOrderParams = { cafeId, orderBranchId, cart, reservationId, cartItems };
const pending = cart.getPendingLines(); const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending"); if (pending.length === 0) throw new Error("nothing pending");
const tryOnline = async (): Promise<Order> => { const idempotencyKey = newIdempotencyKey();
if (cart.activeOrderId && !cart.activeOrderId.startsWith("local_")) { const addingToLocalOrder = isLocalOrder(cart.activeOrderId);
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, {
items: pending,
});
}
return apiPost<Order>(`/api/cafes/${cafeId}/orders`, {
orderType: "DineIn",
branchId: orderBranchId,
tableId: cart.tableId ?? undefined,
reservationId: reservationId ?? undefined,
guestName: cart.guestName.trim() || undefined,
guestPhone: iranMobileForApi(cart.guestPhone),
customerId: cart.customerId ?? undefined,
couponId: cart.appliedCoupon?.id,
items: pending,
});
};
// Try online first // Fast path: online, and either a new order or adding to a real server order.
if (navigator.onLine) { // (Adding to a still-local order must be queued so the outbox can remap its id.)
if (typeof navigator !== "undefined" && navigator.onLine && !addingToLocalOrder) {
try { try {
return await tryOnline(); if (cart.activeOrderId) {
return await apiPost<Order>(
`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`,
{ items: pending },
{ idempotencyKey, offline: "manual" }
);
}
return await apiPost<Order>(
`/api/cafes/${cafeId}/orders`,
buildCreateBody(params, pending),
{ idempotencyKey, offline: "manual" }
);
} catch (err) { } catch (err) {
// If it's a network error despite onLine flag, fall through to offline path // Only fall back to the offline queue on a genuine network failure; a real
// server/validation error must surface. The same idempotencyKey is reused
// so the server de-dups if the failed attempt actually reached it.
if (!isNetworkError(err)) throw err; if (!isNetworkError(err)) throw err;
} }
} }
// Offline path: queue and return a local mock order return queueAndBuildLocalOrder(params, cartItems, idempotencyKey);
return queueAndBuildLocalOrder({ cafeId, orderBranchId, cart, reservationId, cartItems }, cartItems);
} }
export function orderAmountDue(order: Order): number { export function orderAmountDue(order: Order): number {
return Math.max(0, order.total - (order.paidAmount ?? 0)); return Math.max(0, order.total - (order.paidAmount ?? 0));
} }
/** True when the order was created locally (offline) and not yet synced */ /** True when the order was created locally (offline) and not yet synced. */
export function isLocalOrder(orderId: string | null): boolean { export function isLocalOrder(orderId: string | null): boolean {
return !!orderId?.startsWith("local_"); return !!orderId?.startsWith("local_");
} }
@@ -34,6 +34,7 @@ interface CartState {
addItem: (item: MenuItem) => void; addItem: (item: MenuItem) => void;
removeItem: (menuItemId: string) => void; removeItem: (menuItemId: string) => void;
updateQty: (menuItemId: string, quantity: number) => void; updateQty: (menuItemId: string, quantity: number) => void;
setNotes: (menuItemId: string, notes: string) => void;
setCouponCode: (code: string) => void; setCouponCode: (code: string) => void;
setAppliedCoupon: (coupon: AppliedCoupon | null) => void; setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
clearCoupon: () => void; clearCoupon: () => void;
@@ -135,6 +136,13 @@ export const useCartStore = create<CartState>((set, get) => ({
}); });
}, },
setNotes: (menuItemId, notes) =>
set({
items: get().items.map((i) =>
i.menuItem.id === menuItemId ? { ...i, notes: notes.trim() || undefined } : i
),
}),
setCouponCode: (code) => set({ couponCode: code }), setCouponCode: (code) => set({ couponCode: code }),
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }), setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
clearCoupon: () => set(clearCouponState), clearCoupon: () => set(clearCouponState),
+27
View File
@@ -0,0 +1,27 @@
import { useTranslations } from "next-intl";
import { ApiClientError } from "@/lib/api/client";
/**
* Returns a resolver that turns any caught error into a localized, user-facing
* message using the "errors" namespace. Known ApiClientError codes map to their
* translated message; otherwise the provided fallback is used, then a generic
* localized message. Never surfaces the raw (English) backend message.
*
* const apiError = useApiError();
* onError: (err) => notify.error(apiError(err))
*/
export function useApiError() {
const t = useTranslations("errors");
return (err: unknown, fallback?: string): string => {
const code =
err instanceof ApiClientError
? err.code
: typeof err === "object" && err !== null && "code" in err
? String((err as { code: unknown }).code)
: undefined;
if (code && t.has(code)) {
return t(code);
}
return fallback ?? t("generic");
};
}
+5 -10
View File
@@ -50,16 +50,11 @@ const nextConfig: NextConfig = {
{ protocol: "http", hostname: "**" }, { protocol: "http", hostname: "**" },
], ],
}, },
async redirects() { // NOTE: the previous "short URL" redirect (/:slug → /fa/cafe/:slug) matched
return [ // single-segment paths INCLUDING the locale itself, so "/fa" redirected to
// Short URL: koja.meezi.ir/my-cafe → koja.meezi.ir/fa/cafe/my-cafe // "/fa/cafe/fa" (and "/en" → "/fa/cafe/en") — a non-existent slug that 500'd
{ // the home page. Removed; re-add via middleware with explicit reserved-word
source: "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])", // exclusions if short café URLs are needed.
destination: "/fa/cafe/:slug",
permanent: false,
},
];
},
}; };
export default withPWA(withNextIntl(nextConfig)); export default withPWA(withNextIntl(nextConfig));
+18 -5
View File
@@ -70,16 +70,29 @@ export default async function CafePage({
const t = await getTranslations({ locale, namespace: "cafe" }); const t = await getTranslations({ locale, namespace: "cafe" });
const isFa = locale === "fa"; const isFa = locale === "fa";
const [cafe, menu, reviews] = await Promise.all([ // Resolve the café first so an unknown slug 404s cleanly instead of doing
getCafe(slug), // (and potentially erroring on) the menu/review fetches.
const cafe = await getCafe(slug);
if (!cafe) notFound();
const [menu, reviews] = await Promise.all([
getCafeMenu(slug), getCafeMenu(slug),
getCafeReviews(slug), getCafeReviews(slug),
]); ]);
if (!cafe) notFound();
const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name); const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name);
const profile = cafe.discoverProfile; // discoverProfile may be absent for cafés that never filled it in — fall back
// to an empty profile so the page renders instead of throwing a 500.
const profile = cafe.discoverProfile ?? {
themes: [],
size: null,
floors: null,
vibes: [],
occasions: [],
spaceFeatures: [],
noiseLevel: null,
priceTier: null,
};
const priceTier = profile.priceTier; const priceTier = profile.priceTier;
// Similar cafes // Similar cafes
+4 -3
View File
@@ -11,7 +11,8 @@ interface Props {
export function CafeCard({ cafe, locale, href }: Props) { export function CafeCard({ cafe, locale, href }: Props) {
const isFa = locale === "fa"; const isFa = locale === "fa";
const name = isFa ? cafe.name : (cafe.name); const name = isFa ? cafe.name : (cafe.name);
const priceTier = cafe.discoverProfile.priceTier; const priceTier = cafe.discoverProfile?.priceTier ?? null;
const themes = cafe.discoverProfile?.themes ?? [];
const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null; const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null;
return ( return (
@@ -72,9 +73,9 @@ export function CafeCard({ cafe, locale, href }: Props) {
)} )}
{/* Tags */} {/* Tags */}
{cafe.discoverProfile.themes.length > 0 && ( {themes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{cafe.discoverProfile.themes.slice(0, 3).map((tag) => ( {themes.slice(0, 3).map((tag) => (
<span <span
key={tag} key={tag}
className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700" className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700"
+2 -2
View File
@@ -46,11 +46,11 @@ export function CafeJsonLd({ cafe, locale, baseUrl }: Props) {
worstRating: "1", worstRating: "1",
}, },
} : {}), } : {}),
...(cafe.discoverProfile.themes.length ? { ...(cafe.discoverProfile?.themes?.length ? {
servesCuisine: cafe.discoverProfile.themes, servesCuisine: cafe.discoverProfile.themes,
} : {}), } : {}),
priceRange: (() => { priceRange: (() => {
const tier = cafe.discoverProfile.priceTier; const tier = cafe.discoverProfile?.priceTier;
if (tier === "budget") return "﷼"; if (tier === "budget") return "﷼";
if (tier === "moderate") return "﷼﷼"; if (tier === "moderate") return "﷼﷼";
if (tier === "upscale") return "﷼﷼﷼"; if (tier === "upscale") return "﷼﷼﷼";
+3
View File
@@ -3,4 +3,7 @@ import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["fa", "en"], locales: ["fa", "en"],
defaultLocale: "fa", defaultLocale: "fa",
// Iran-first: don't pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). Locale-less URLs default to fa.
localeDetection: false,
}); });
@@ -41,7 +41,7 @@ const fa = {
desc: "از داشبورد میزی در دسترس است", desc: "از داشبورد میزی در دسترس است",
value: "چت زنده", value: "چت زنده",
cta: "ورود به داشبورد", cta: "ورود به داشبورد",
href: "https://app.meezi.ir", href: "https://app.meezi.ir/fa",
}, },
], ],
officeTitle: "دفتر مرکزی", officeTitle: "دفتر مرکزی",
@@ -79,7 +79,7 @@ const en = {
desc: "Available inside the Meezi dashboard", desc: "Available inside the Meezi dashboard",
value: "Live chat", value: "Live chat",
cta: "Go to dashboard", cta: "Go to dashboard",
href: "https://app.meezi.ir", href: "https://app.meezi.ir/en",
}, },
], ],
officeTitle: "Head Office", officeTitle: "Head Office",
+2 -2
View File
@@ -93,7 +93,7 @@ export function Navbar() {
{locale === "fa" ? "EN" : "فا"} {locale === "fa" ? "EN" : "فا"}
</button> </button>
<a <a
href="https://app.meezi.ir/login" href={`https://app.meezi.ir/${locale}/login`}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" className="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
> >
{t("login")} {t("login")}
@@ -143,7 +143,7 @@ export function Navbar() {
</ul> </ul>
<div className="mt-3 flex flex-col gap-2 border-t border-gray-100 pt-3"> <div className="mt-3 flex flex-col gap-2 border-t border-gray-100 pt-3">
<a <a
href="https://app.meezi.ir/login" href={`https://app.meezi.ir/${locale}/login`}
className="block rounded-lg px-3 py-2.5 text-center text-sm font-medium text-gray-600 hover:bg-gray-50" className="block rounded-lg px-3 py-2.5 text-center text-sm font-medium text-gray-600 hover:bg-gray-50"
> >
{t("login")} {t("login")}
@@ -101,7 +101,7 @@ export function LaunchCountdownSection() {
</div> </div>
<a <a
href="https://app.meezi.ir/register" href={`https://app.meezi.ir/${locale}/register`}
className={cn( className={cn(
"inline-flex items-center justify-center rounded-xl bg-brand-700 px-6 py-3 text-sm font-semibold text-white", "inline-flex items-center justify-center rounded-xl bg-brand-700 px-6 py-3 text-sm font-semibold text-white",
"transition hover:bg-brand-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-700 focus-visible:ring-offset-2" "transition hover:bg-brand-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-700 focus-visible:ring-offset-2"
@@ -34,7 +34,7 @@ export function PricingSection() {
priceNote: t("freePriceNote"), priceNote: t("freePriceNote"),
desc: t("freeDesc"), desc: t("freeDesc"),
cta: t("ctaFree"), cta: t("ctaFree"),
href: "https://app.meezi.ir/register", href: `https://app.meezi.ir/${locale}/register`,
features: [t("f1"), t("f2"), t("f3"), t("f4"), t("f5")], features: [t("f1"), t("f2"), t("f3"), t("f4"), t("f5")],
popular: false, popular: false,
variant: "outline", variant: "outline",