Compare commits

...

33 Commits

Author SHA1 Message Date
soroush.asadi 2487f9e30f feat(plans): Stage 3b — DB-driven gates for reviews/styling/limits
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m0s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 38s
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 2m51s
Make more plan rules read the admin-editable catalog instead of hardcoded values:
- Review reply gated by the `review_reply` feature (Starter+) — 403 if not in plan.
- Custom menu styling gated by `custom_menu_styling` (Starter+): only blocks an
  actual theme change, so a normal settings save re-sending the current theme is fine.
- Menu categories/items limits now read catalog.GetLimitsAsync (Free categories
  editable; message no longer hardcodes a number).
- Terminals limit reads the catalog (enforcement in TerminalRegistryService +
  the displayed max in TerminalsController).

Remaining (small): menu watermark (Free shows it, `watermark_removed` removes it —
needs the public-menu render), report-history (static ReportPlanGate) and AI-3D
routing — these already enforce the correct matrix values, just not yet editable.

86 tests pass; build clean.
2026-06-03 01:40:00 +03:30
soroush.asadi 8f738f6469 feat(plans): Stage 4 — full admin plan/feature editor
The admin → Plans screen now edits EVERYTHING per plan (the backend already
accepted it; only the UI was partial):
- All limits (orders/day, tables, terminals, branches, menu categories, menu
  items, customers, report history, SMS, AI-3D) with an "unlimited (∞)" toggle.
- Display names (fa/en), monthly price, sort order, billable-online, active on/off.
- Per-plan feature checkboxes grouped by module, plus an "all features (*)" toggle
  (Enterprise). Sourced from the live feature catalog (/api/admin/features).
- Plans listed in sort order (Free·Starter·Pro·Business·Enterprise).
- i18n fa/en/ar.

Admin tsc + build clean.
2026-06-03 01:11:18 +03:30
soroush.asadi 7f52b2823f feat(plans): Stage 3a — enforce tables cap from the DB catalog
PlanLimitChecker already enforces orders/customers/branches/SMS from the
admin-editable catalog (GetLimitsAsync). Add the tables cap the same way
(POST /api/cafes/{cafeId}/tables → MaxTables), so Free's 6-table limit is both
enforced and admin-editable. Terminals/categories/report-history already enforce
the correct matrix values via PlanLimits defaults; routing them through the
catalog for editability + the watermark/styling/review-reply feature gates are
the remaining S3 items.

86 tests pass.
2026-06-03 00:58:49 +03:30
soroush.asadi c5d5a4006a feat(plans): Stage 2 — seed 5-tier matrix + feature catalog (editable defaults)
- CanonicalPlans(): single source for Free·Starter·Pro·Business·Enterprise with the
  locked feature sets (Free is broad: KDS/queue/Koja/offline/reviews/reservations/
  coupons/employees; Starter +watermark-removal/custom-styling/review-reply; Pro +CRM/
  reports/taxes/HR/delivery/expenses/branches; Business +3D/AI-3D; Enterprise *).
- Feature catalog: + offline, employees, watermark_removed, custom_menu_styling,
  review_reply, api, white_label.
- New Starter plan (690k Toman default, billable, sort 1).
- One-time, version-guarded matrix upgrade (catalog.planMatrixVersion=2): brings the
  existing (never-yet-admin-edited) prod plans to the canonical limits/features/order/
  price and inserts Starter. Runs once; won't clobber later admin edits.
- Replaced the additive feature-merge (which would wrongly re-add menu_3d to Pro).

Defaults only — admins will be able to change everything in S4. 86 tests pass.
2026-06-03 00:53:02 +03:30
soroush.asadi 4cb640814a feat(plans): Stage 1 — Starter tier + admin-editable limit model
Foundation for the configurable plan system (Free·Starter·Pro·Business·Enterprise).

- PlanTier: append Starter=4 (no renumber → no data migration; existing tier ints
  keep meaning). Ordering/display via PlanDefinition.SortOrder; gating uses explicit
  tier sets, never `tier >= X`.
- PlanLimits: locked 5-tier matrix — Free orders/day 50→30, tables (new) Free 6/
  Starter 15/Pro 40/∞, categories Free 3→10, menu items now unlimited, terminals/
  branches/report-history ladders incl. Starter; CRM/analytics = explicit Pro+; AI-3D
  = Business+. SMS quotas kept (Free/Starter 0, Pro 50, Business 200) until the
  pay-as-you-go credit system ships (don't break paid SMS).
- PlanLimitsData (LimitsJson shape): + MaxTables/MaxMenuCategories/MaxMenuItems/
  MaxMenuAi3dPerMonth; ForTier now derives from PlanLimits (single source of truth).

No migration. 86 tests pass. Next: S2 seed 5 plans + feature catalog (editable),
S3 wire enforcement to DB, S4 admin editor.
2026-06-03 00:40:37 +03:30
soroush.asadi 4c98c2cce1 feat(auth): extend token lifetimes for long offline gaps
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m24s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m1s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m16s
A user can be offline for months (offline-first dashboard) and must stay logged
in / be able to sync on reconnect. Access 7d→30d, refresh 30d→365d, so a ~3-month
offline gap still has a valid refresh token on reconnect (queued writes sync, no
forced logout). Client only logs out on a server 401, never while offline.
2026-06-02 23:47:06 +03:30
soroush.asadi db0c3a4a02 feat(hr): add employees from the dashboard
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m10s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m40s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 1m32s
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 9m24s
Previously the only Employee records were the Owner (created at café signup) and
one Manager per branch — there was no way to add a waiter/cashier/chef. Adds it.

Backend:
- POST /api/cafes/{cafeId}/employees (HrController). Owner/Manager only; creating a
  Manager requires Owner; Owner cannot be created here. Validates name/phone/role,
  enforces one-employee-per-phone, validates branch belongs to the café, and can
  optionally set username/password login in the same step (same hashing + uniqueness
  as the credentials endpoint). Returns EmployeeSummaryDto.

Dashboard:
- New "Team" tab on the HR screen (now the default): employee roster (name, role,
  phone, base salary) + an "Add employee" button (owner/manager) opening an inline
  form — name, phone, role, optional branch, optional base salary, optional login.
- Role labels + all form strings in fa/en/ar.

86 API tests pass; dashboard tsc + build clean.
2026-06-02 23:28:36 +03:30
soroush.asadi f1756b491e feat(admin): rich text editor for blog content (TipTap)
CI/CD / CI · API (dotnet build + test) (push) Successful in 3m33s
CI/CD / CI · Admin API (dotnet build) (push) Failing after 3m18s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m6s
CI/CD / CI · Admin Web (tsc) (push) Failing after 3m43s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been skipped
Blog post bodies were plain <textarea>s labelled "Markdown". Replace with a
TipTap rich editor (bold/italic/strike, H1–H3, lists, blockquote, code, links,
undo/redo), RTL-aware, producing HTML.

- New RichTextEditor component (TipTap v2: react + starter-kit + pm + link +
  placeholder), immediatelyRender:false for Next SSR, self-contained content
  styling, external-value sync.
- Wired into the FA/EN content fields of the blog editor; labels no longer say
  "Markdown" (fa/en/ar).
- Website blog page now renders HTML when the body is HTML and falls back to
  MDXRemote for older Markdown posts (backward-compatible). Content is authored
  only by trusted SystemAdmins, so HTML is stored/rendered directly.

Admin build + website typecheck clean.
2026-06-02 22:25:47 +03:30
soroush.asadi 97a9481627 feat(media): content-hash dedup for uploads + media-library endpoint
Uploads previously wrote every file to disk with a fresh GUID name, so the
same image uploaded twice produced two identical files. Now:

- New MediaAsset table records each stored upload (SHA-256 hash, size, type,
  url, kind, scope) + migration. Indexed on (CafeId, ContentHash).
- MediaStorageService computes the content hash on upload; if an identical file
  already exists for that café it returns the existing URL instead of writing a
  duplicate (covers images, videos, 3D models). Dedup lookup/record run via a
  scoped DbContext (the service is a singleton) and never block an upload on
  failure.
- GET /api/cafes/{cafeId}/media lists the café's library (newest first, optional
  ?kind=) so the UI can let users pick an existing file instead of re-uploading.

86 API tests pass.
2026-06-02 22:16:11 +03:30
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
106 changed files with 18333 additions and 588 deletions
+4 -1
View File
@@ -198,7 +198,10 @@ public class AuthController : ControllerBase
ExpiresAt: expiresAt,
UserId: userId,
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,
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
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,
cafe.InstagramHandle,
cafe.WebsiteUrl,
ToHoursDto(hours))));
ToHoursDto(hours),
cafe.ShowOnKoja)));
}
// ── PUT (description / social / hours) ───────────────────────────────────
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
if (request.WorkingHours is not null)
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);
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery,
cafe.InstagramHandle,
cafe.WebsiteUrl,
ToHoursDto(hours))));
ToHoursDto(hours),
cafe.ShowOnKoja)));
}
// ── POST gallery/upload ───────────────────────────────────────────────────
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
string? Description,
string? InstagramHandle,
string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours);
WorkingHoursPublicDto? WorkingHours,
bool? ShowOnKoja = null);
public record CafeProfileEditDto(
string? Description,
IReadOnlyList<string> GalleryUrls,
string? InstagramHandle,
string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours);
WorkingHoursPublicDto? WorkingHours,
bool ShowOnKoja);
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
@@ -2,7 +2,9 @@ using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Models.Public;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -12,11 +14,16 @@ public class CafeReviewsController : CafeApiControllerBase
{
private readonly IReviewService _reviews;
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
private readonly IPlatformCatalogService _catalog;
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
public CafeReviewsController(
IReviewService reviews,
IValidator<ReplyCafeReviewRequest> replyValidator,
IPlatformCatalogService catalog)
{
_reviews = reviews;
_replyValidator = replyValidator;
_catalog = catalog;
}
[HttpGet]
@@ -41,6 +48,13 @@ public class CafeReviewsController : CafeApiControllerBase
CancellationToken ct = default)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
// Replying to reviews is a paid feature (Starter+).
var tier = tenant.PlanTier ?? PlanTier.Free;
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, tier, "review_reply", ct))
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_FEATURE_DISABLED", "Replying to reviews is not included in your plan. Please upgrade.")));
var validation = await _replyValidator.ValidateAsync(request, ct);
if (!validation.IsValid)
{
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Branding;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -16,11 +18,16 @@ public class CafeSettingsController : CafeApiControllerBase
{
private readonly AppDbContext _db;
private readonly IValidator<PatchCafeSettingsRequest> _validator;
private readonly IPlatformCatalogService _catalog;
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
public CafeSettingsController(
AppDbContext db,
IValidator<PatchCafeSettingsRequest> validator,
IPlatformCatalogService catalog)
{
_db = db;
_validator = validator;
_catalog = catalog;
}
[HttpGet]
@@ -81,7 +88,19 @@ public class CafeSettingsController : CafeApiControllerBase
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
if (request.Theme is not null)
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
{
// Custom menu styling is a paid feature (Starter+). Only block an actual change,
// so a normal settings save that re-sends the current theme isn't rejected.
var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
if (newThemeJson != cafe.ThemeJson)
{
var styleTier = tenant.PlanTier ?? PlanTier.Free;
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct))
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade.")));
cafe.ThemeJson = newThemeJson;
}
}
if (request.DefaultTaxRate is decimal taxRate)
cafe.DefaultTaxRate = taxRate;
if (request.AllowBranchTaxOverride is bool allowTax)
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
CancellationToken ct)
{
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);
return Ok(new ApiResponse<DemoSeedResult>(true, result));
+88
View File
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr;
using Meezi.API.Services;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
@@ -46,6 +47,93 @@ public class HrController : CafeApiControllerBase
return Ok(new ApiResponse<IReadOnlyList<EmployeeSummaryDto>>(true, data));
}
/// <summary>Create a new employee (waiter, cashier, chef, …). Owner/Manager only;
/// creating a Manager requires Owner. Optionally sets login credentials in one step.</summary>
[HttpPost("employees")]
public async Task<IActionResult> CreateEmployee(
string cafeId,
[FromBody] CreateEmployeeRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
IActionResult Invalid(string message, string field) =>
BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", message, field)));
var name = request.Name?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
return Invalid("Name is required.", "Name");
var phone = request.Phone?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(phone))
return Invalid("Phone is required.", "Phone");
if (!Enum.IsDefined(typeof(EmployeeRole), request.Role))
return Invalid("Invalid role.", "Role");
// An Owner is created only at café registration, never via this endpoint.
if (request.Role == EmployeeRole.Owner)
return Invalid("Cannot create an owner here.", "Role");
// Only an Owner may add a Manager.
if (request.Role == EmployeeRole.Manager && EnsureOwner(tenant) is { } ownerOnly)
return ownerOnly;
// One employee per phone within a café.
var phoneTaken = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null && e.Phone == phone, ct);
if (phoneTaken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("PHONE_TAKEN", "An employee with this phone already exists.", "Phone")));
string? branchId = string.IsNullOrWhiteSpace(request.BranchId) ? null : request.BranchId.Trim();
if (branchId is not null)
{
var branchOk = await _db.Branches.AnyAsync(b => b.Id == branchId && b.CafeId == cafeId, ct);
if (!branchOk) return Invalid("Invalid branch.", "BranchId");
}
var employee = new Employee
{
Id = $"emp_{Guid.NewGuid():N}"[..24],
CafeId = cafeId,
BranchId = branchId,
Name = name,
Phone = phone,
Role = request.Role,
BaseSalary = request.BaseSalary ?? 0m,
NationalId = string.IsNullOrWhiteSpace(request.NationalId) ? null : request.NationalId.Trim(),
CreatedAt = DateTime.UtcNow,
};
// Optional: enable password login in the same step.
var wantsCreds = !string.IsNullOrWhiteSpace(request.Username) || !string.IsNullOrWhiteSpace(request.Password);
if (wantsCreds)
{
var username = (request.Username ?? string.Empty).Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(username))
return Invalid("Username is required when setting a password.", "Username");
if ((request.Password ?? string.Empty).Length < 8)
return Invalid("Password must be at least 8 characters.", "Password");
var usernameTaken = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.DeletedAt == null
&& e.Username != null && e.Username.ToLower() == username, ct);
if (usernameTaken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.", "Username")));
employee.Username = username;
employee.PasswordHash = PasswordHasher.Hash(request.Password!);
}
_db.Employees.Add(employee);
await _db.SaveChangesAsync(ct);
var dto = new EmployeeSummaryDto(employee.Id, employee.Name, employee.Phone, employee.Role, employee.BaseSalary);
return Ok(new ApiResponse<EmployeeSummaryDto>(true, dto));
}
[HttpGet("employees/{employeeId}")]
public async Task<IActionResult> GetEmployee(string cafeId, string employeeId, ITenantContext tenant, CancellationToken ct)
{
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
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")]
public async Task<IActionResult> Adjust(
string cafeId,
@@ -1,7 +1,9 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
@@ -81,6 +83,33 @@ public class MediaController : CafeApiControllerBase
string cafeId, IFormFile file, ITenantContext tenant, CancellationToken cancellationToken)
=> Upload(cafeId, file, tenant, _media.SaveCafeCoverAsync, "INVALID_FILE", "Use JPEG/PNG/WebP up to 5MB.", cancellationToken);
/// <summary>Media library for this café — previously uploaded files so the UI can
/// reuse one instead of re-uploading. Deduplication means each distinct file appears once.</summary>
[HttpGet]
public async Task<IActionResult> ListMedia(
string cafeId,
ITenantContext tenant,
[FromServices] AppDbContext db,
CancellationToken cancellationToken,
[FromQuery] string? kind = null,
[FromQuery] int limit = 60)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var query = db.MediaAssets.AsNoTracking().Where(m => m.CafeId == cafeId);
if (!string.IsNullOrWhiteSpace(kind))
query = query.Where(m => m.Kind == kind);
var items = await query
.OrderByDescending(m => m.CreatedAt)
.Take(Math.Clamp(limit, 1, 200))
.Select(m => new MediaAssetDto(
m.Id, m.Url, m.Kind, m.ContentType, m.SizeBytes, m.OriginalFileName, m.CreatedAt))
.ToListAsync(cancellationToken);
return Ok(new ApiResponse<List<MediaAssetDto>>(true, items));
}
private async Task<IActionResult> Upload(
string cafeId,
IFormFile file,
@@ -103,3 +132,12 @@ public class MediaController : CafeApiControllerBase
}
public record UploadResultDto(string Url);
public record MediaAssetDto(
string Id,
string Url,
string Kind,
string ContentType,
long SizeBytes,
string? OriginalFileName,
DateTime CreatedAt);
+18 -5
View File
@@ -7,6 +7,7 @@ using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -19,24 +20,27 @@ public class MenuController : CafeApiControllerBase
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
private readonly AppDbContext _db;
private readonly IPlatformCatalogService _catalog;
private const string CategoryLimitMessage =
"محدودیت دسته‌بندی پلن رایگان (۳ دسته). برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید.";
"به سقف دسته‌بندی منوی پلن شما رسیدید. برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید.";
private const string ItemLimitMessage =
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
"به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
public MenuController(
IMenuService menuService,
IMenuAi3dGenerationService menuAi3d,
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
IValidator<CreateMenuItemRequest> createItemValidator,
AppDbContext db)
AppDbContext db,
IPlatformCatalogService catalog)
{
_menuService = menuService;
_menuAi3d = menuAi3d;
_createCategoryValidator = createCategoryValidator;
_createItemValidator = createItemValidator;
_db = db;
_catalog = catalog;
}
[HttpGet("categories")]
@@ -59,7 +63,7 @@ public class MenuController : CafeApiControllerBase
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuCategories(tier);
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories;
if (max != int.MaxValue)
{
var count = await _db.MenuCategories.CountAsync(
@@ -120,7 +124,7 @@ public class MenuController : CafeApiControllerBase
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuItems(tier);
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems;
if (max != int.MaxValue)
{
var count = await _db.MenuItems.CountAsync(
@@ -163,6 +167,15 @@ public class MenuController : CafeApiControllerBase
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")]
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();
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);
@@ -1,8 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.API.Services;
using Meezi.Infrastructure.Services.Platform;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -11,8 +11,13 @@ namespace Meezi.API.Controllers;
public class TerminalsController : CafeApiControllerBase
{
private readonly ITerminalRegistryService _terminals;
private readonly IPlatformCatalogService _catalog;
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
public TerminalsController(ITerminalRegistryService terminals, IPlatformCatalogService catalog)
{
_terminals = terminals;
_catalog = catalog;
}
[HttpPost("register")]
public async Task<IActionResult> Register(
@@ -35,7 +40,7 @@ public class TerminalsController : CafeApiControllerBase
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var list = await _terminals.ListAsync(cafeId, ct);
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
var max = (await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxTerminals;
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
}
@@ -215,6 +215,9 @@ public static class ServiceCollectionExtensions
app.UseMeeziSecurity();
app.UseAuthentication();
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.UseAuthorization();
@@ -242,6 +245,11 @@ public static class ServiceCollectionExtensions
"branch-permanent-delete",
job => job.ExecuteAsync(),
Cron.Hourly);
RecurringJob.AddOrUpdate<IdempotencyCleanupJob>(
"idempotency-cleanup",
job => job.ExecuteAsync(),
Cron.Daily(4));
}
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;
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))
scopedMerchant.Role = role;
+10 -1
View File
@@ -22,6 +22,15 @@ public record BillingStatusDto(
int MenuAi3dUsedThisMonth,
int MenuAi3dMonthlyLimit,
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);
+12
View File
@@ -62,3 +62,15 @@ public record TodayShiftDto(ShiftType ShiftType, string Label);
/// <summary>Set or update username/password credentials for an employee.</summary>
public record SetEmployeeCredentialsRequest(string Username, string Password);
/// <summary>Create a new employee. Owner/Manager only; Manager role requires Owner.
/// Username+Password are optional and, when supplied, enable dashboard/POS login.</summary>
public record CreateEmployeeRequest(
string Name,
string Phone,
EmployeeRole Role,
string? BranchId = null,
decimal? BaseSalary = null,
string? NationalId = null,
string? Username = null,
string? Password = null);
+14 -4
View File
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
if (employee?.Cafe is null)
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
.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()))
.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);
}
@@ -510,12 +514,18 @@ public class AuthService : IAuthService
Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships,
string? requestedBranchId,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? existingRefreshToken = null)
{
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
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);
await _refreshTokenStore.StoreAsync(
+148 -10
View File
@@ -35,6 +35,11 @@ public interface IBillingService
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
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
return new BillingVerifyResult(false, failUrl);
}
payment.Status = SubscriptionPaymentStatus.Completed;
payment.RefId = verify.RefId;
var cafe = payment.Cafe;
cafe.PlanTier = payment.PlanTier;
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
? cafe.PlanExpiresAt.Value
: DateTime.UtcNow;
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
var now = DateTime.UtcNow;
// Where does the current paid coverage end? = the latest of the active plan's expiry
// and the furthest-out already-queued period. A new purchase is appended to that.
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 TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
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(
string cafeId,
PlanTier currentTier,
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);
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 ordersToday = await _db.Orders.CountAsync(
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
@@ -278,12 +413,14 @@ public class BillingService : IBillingService
ai3dUsedCount,
ai3dLimit,
discoverProfile,
isExpired);
isExpired,
queuedPlans);
}
private async Task TrySendConfirmationSmsAsync(
Cafe cafe,
SubscriptionPayment payment,
bool queued,
CancellationToken cancellationToken)
{
var ownerPhone = await _db.Employees
@@ -293,8 +430,9 @@ public class BillingService : IBillingService
if (string.IsNullOrEmpty(ownerPhone)) return;
var message =
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
var message = queued
? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز می‌شود. مبلغ: {payment.AmountToman:N0} ت"
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
try
{
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) =>
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,
Name = name,
Unit = unit,
@@ -160,7 +163,9 @@ public class DemoSeedService : IDemoSeedService
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
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,
BranchId = branchId,
Number = number,
@@ -89,6 +89,7 @@ public interface IInventoryService
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, 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<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
Task<IngredientDto?> AdjustAsync(
string cafeId,
string ingredientId,
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
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(
string cafeId,
string ingredientId,
+95 -7
View File
@@ -1,3 +1,8 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Services;
public interface IMediaStorageService
@@ -37,11 +42,16 @@ public class MediaStorageService : IMediaStorageService
private readonly IWebHostEnvironment _env;
private readonly ILogger<MediaStorageService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public MediaStorageService(IWebHostEnvironment env, ILogger<MediaStorageService> logger)
public MediaStorageService(
IWebHostEnvironment env,
ILogger<MediaStorageService> logger,
IServiceScopeFactory scopeFactory)
{
_env = env;
_logger = logger;
_scopeFactory = scopeFactory;
}
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
@@ -100,16 +110,29 @@ public class MediaStorageService : IMediaStorageService
|| Model3dMime.Contains(file.ContentType);
if (!isGlb) return null;
await using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
if (existing is not null)
{
_logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId);
return existing;
}
var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId);
Directory.CreateDirectory(dir);
var savedName = $"menu_3d_{Guid.NewGuid():N}.glb";
var path = Path.Combine(dir, savedName);
await using var stream = File.Create(path);
await file.CopyToAsync(stream, cancellationToken);
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
var url = $"/uploads/{cafeId}/{savedName}";
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken);
_logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId);
return $"/uploads/{cafeId}/{savedName}";
return url;
}
private async Task<string?> SaveAsync(
@@ -123,6 +146,20 @@ public class MediaStorageService : IMediaStorageService
if (file.Length == 0 || file.Length > maxBytes) return null;
if (!allowedMime.Contains(file.ContentType)) return null;
// Buffer once so we can hash the content and (if new) write it.
await using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var bytes = buffer.ToArray();
var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
// Dedup: an identical file already stored for this scope is reused as-is.
var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken);
if (existing is not null)
{
_logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId);
return existing;
}
var ext = file.ContentType.ToLowerInvariant() switch
{
"image/png" => ".png",
@@ -138,10 +175,61 @@ public class MediaStorageService : IMediaStorageService
var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}";
var path = Path.Combine(dir, fileName);
await using var stream = File.Create(path);
await file.CopyToAsync(stream, cancellationToken);
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
var url = $"/uploads/{cafeId}/{fileName}";
await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken);
_logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId);
return $"/uploads/{cafeId}/{fileName}";
return url;
}
// ─── Deduplication helpers ────────────────────────────────────────────────
// MediaStorageService is a singleton; resolve a scoped DbContext per call.
private async Task<string?> FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.MediaAssets.AsNoTracking()
.Where(m => m.CafeId == cafeId && m.ContentHash == hash)
.Select(m => m.Url)
.FirstOrDefaultAsync(ct);
}
catch (Exception ex)
{
// Never let a dedup-lookup failure block an upload.
_logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload");
return null;
}
}
private async Task RecordAsync(
string? cafeId, string hash, long size, string contentType,
string url, string kind, string? originalName, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.MediaAssets.Add(new MediaAsset
{
CafeId = cafeId,
ContentHash = hash,
SizeBytes = size,
ContentType = contentType,
Url = url,
Kind = kind,
OriginalFileName = originalName,
});
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
// The file is already written; a missing dedup record only means a
// future identical upload won't be de-duplicated. Don't fail the upload.
_logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId);
}
}
}
+11
View File
@@ -16,6 +16,7 @@ public interface IMenuService
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest 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<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
}
public class MenuService : IMenuService
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
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) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -99,6 +99,21 @@ public class PlanLimitChecker : IPlanLimitChecker
return (false, "PLAN_LIMIT_REACHED", "Branch limit reached for your plan. Please upgrade.");
}
var tablesPath = $"/api/cafes/{cafeId}/tables";
if (path.StartsWith(tablesPath, StringComparison.OrdinalIgnoreCase) &&
(path.Equals(tablesPath, StringComparison.OrdinalIgnoreCase) ||
path.Equals($"{tablesPath}/", StringComparison.OrdinalIgnoreCase)))
{
var limitsTables = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
var maxTables = limitsTables.MaxTables;
if (maxTables != int.MaxValue)
{
var tableCount = await _db.Tables.CountAsync(t => t.CafeId == cafeId, cancellationToken);
if (tableCount >= maxTables)
return (false, "PLAN_LIMIT_REACHED", "Table limit reached for your plan. Please upgrade.");
}
}
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
@@ -24,6 +24,11 @@ public interface IReservationService
string reservationId,
ReservationStatus status,
CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default);
}
public class ReservationService : IReservationService
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
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(
r.Id,
r.CafeId,
+1 -1
View File
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
DiscoverFilterParams filters,
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))
query = query.Where(c => c.City != null && c.City.Contains(filters.City));
@@ -23,8 +23,15 @@ public class TerminalRegistryService : ITerminalRegistryService
{
private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90);
private readonly IConnectionMultiplexer _redis;
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
public TerminalRegistryService(IConnectionMultiplexer redis) => _redis = redis;
public TerminalRegistryService(
IConnectionMultiplexer redis,
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
{
_redis = redis;
_catalog = catalog;
}
public async Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
string cafeId,
@@ -38,7 +45,7 @@ public class TerminalRegistryService : ITerminalRegistryService
terminalId = terminalId.Trim();
var db = _redis.GetDatabase();
var setKey = $"terminals:{cafeId}";
var max = PlanLimits.MaxTerminals(tier);
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxTerminals;
if (max == int.MaxValue)
{
+2 -2
View File
@@ -7,8 +7,8 @@
"Key": "meezi-dev-secret-key-min-32-chars!!",
"Issuer": "meezi",
"Audience": "meezi",
"AccessTokenExpiryDays": 7,
"RefreshTokenExpiryDays": 30
"AccessTokenExpiryDays": 30,
"RefreshTokenExpiryDays": 365
},
"App": {
"PublicBaseUrl": "https://localhost:7208",
+41 -26
View File
@@ -2,30 +2,53 @@ using Meezi.Core.Enums;
namespace Meezi.Core.Constants;
/// <summary>
/// Code-level DEFAULTS for per-plan numeric limits. These are the fallback /
/// seed values; the source of truth at runtime is the admin-editable
/// PlatformPlanDefinition (LimitsJson) read via IPlatformCatalogService.
/// Gating uses explicit tier sets, never `tier >= X`, so the appended Starter
/// tier (enum value 4) is handled correctly.
/// </summary>
public static class PlanLimits
{
private static bool IsPaid(PlanTier t) => t is not PlanTier.Free;
private static bool IsProPlus(PlanTier t) =>
t is PlanTier.Pro or PlanTier.Business or PlanTier.Enterprise;
private static bool IsBusinessPlus(PlanTier t) =>
t is PlanTier.Business or PlanTier.Enterprise;
public static int MaxOrdersPerDay(PlanTier tier) => tier switch
{
PlanTier.Free => 50,
PlanTier.Free => 30,
_ => int.MaxValue
};
/// <summary>Maximum tables a café may define.</summary>
public static int MaxTables(PlanTier tier) => tier switch
{
PlanTier.Free => 6,
PlanTier.Starter => 15,
PlanTier.Pro => 40,
_ => int.MaxValue
};
public static int MaxTerminals(PlanTier tier) => tier switch
{
PlanTier.Free => 1,
PlanTier.Starter => 2,
PlanTier.Pro => 3,
_ => int.MaxValue
};
public static int MaxCustomers(PlanTier tier) => tier switch
{
PlanTier.Free => 50,
_ => int.MaxValue
};
public static int MaxCustomers(PlanTier tier) => int.MaxValue; // CRM module gated by CanAccessCrm
/// <summary>Monthly bundled SMS. The product direction is pay-as-you-go credits for
/// all tiers, but until the credit-purchase system ships we keep the existing bundled
/// quotas so paying cafés don't lose SMS. (Switch to 0 + credits in the SMS stage.)</summary>
public static int MaxSmsPerMonth(PlanTier tier) => tier switch
{
PlanTier.Free => 0,
PlanTier.Starter => 0,
PlanTier.Pro => 50,
PlanTier.Business => 200,
_ => int.MaxValue
@@ -34,8 +57,8 @@ public static class PlanLimits
public static int MaxBranches(PlanTier tier) => tier switch
{
PlanTier.Free => 1,
PlanTier.Starter => 1,
PlanTier.Pro => 3,
PlanTier.Business => int.MaxValue,
_ => int.MaxValue
};
@@ -43,35 +66,27 @@ public static class PlanLimits
public static int MaxReportHistoryDays(PlanTier tier) => tier switch
{
PlanTier.Free => 8,
PlanTier.Starter => 30,
PlanTier.Pro => 90,
_ => int.MaxValue
};
/// <summary>AI image-to-3D generations per calendar month (UTC).</summary>
public static int MaxMenuAi3dPerMonth(PlanTier tier) => tier switch
{
PlanTier.Business => 100,
PlanTier.Enterprise => 100,
_ => 0
};
/// <summary>AI image-to-3D generations per calendar month (UTC). Business+ only.</summary>
public static int MaxMenuAi3dPerMonth(PlanTier tier) => IsBusinessPlus(tier) ? 100 : 0;
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
/// <summary>Maximum active menu categories. Free is capped at 10; paid tiers unlimited.</summary>
public static int MaxMenuCategories(PlanTier tier) => tier switch
{
PlanTier.Free => 3,
PlanTier.Free => 10,
_ => int.MaxValue
};
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
public static int MaxMenuItems(PlanTier tier) => tier switch
{
PlanTier.Free => 30,
_ => int.MaxValue
};
/// <summary>Menu items are unlimited on every tier (Free can fully build the menu).</summary>
public static int MaxMenuItems(PlanTier tier) => int.MaxValue;
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
/// <summary>CRM (customers, loyalty) Pro and above.</summary>
public static bool CanAccessCrm(PlanTier tier) => IsProPlus(tier);
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
/// <summary>Statistics / analytics dashboards Pro and above.</summary>
public static bool CanAccessStatistics(PlanTier tier) => IsProPlus(tier);
}
+3
View File
@@ -17,6 +17,9 @@ public class Cafe : BaseEntity
public PlanTier PlanTier { get; set; } = PlanTier.Free;
public DateTime? PlanExpiresAt { 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>
public bool IsSuspended { 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; }
}
+27
View File
@@ -0,0 +1,27 @@
namespace Meezi.Core.Entities;
/// <summary>
/// A stored upload, recorded so identical files can be de-duplicated and reused
/// instead of written to disk again. Files with the same content hash within a
/// scope (café, or platform when <see cref="CafeId"/> is null) share one stored file.
/// </summary>
public class MediaAsset : BaseEntity
{
/// <summary>Owning café, or null for platform-level (admin) uploads.</summary>
public string? CafeId { get; set; }
/// <summary>SHA-256 of the file content, lowercase hex.</summary>
public string ContentHash { get; set; } = string.Empty;
public long SizeBytes { get; set; }
public string ContentType { get; set; } = string.Empty;
/// <summary>Public URL/path the file is served from (e.g. /uploads/{cafeId}/{name}).</summary>
public string Url { get; set; } = string.Empty;
/// <summary>Logical kind/prefix: menu_img, logo, cover, gallery, review, menu_3d, blog, ...</summary>
public string Kind { get; set; } = string.Empty;
public string? OriginalFileName { get; set; }
}
@@ -13,5 +13,13 @@ public class SubscriptionPayment : TenantEntity
public string? RefId { get; set; }
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!;
}
@@ -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
}
+6 -1
View File
@@ -5,5 +5,10 @@ public enum PlanTier
Free = 0,
Pro = 1,
Business = 2,
Enterprise = 3
Enterprise = 3,
// Appended (not inserted) so existing stored tier ints (Cafe / SubscriptionPayment /
// PlanDefinition) keep their meaning — no data migration needed. Display & upgrade
// ordering is driven by PlatformPlanDefinition.SortOrder, NOT this numeric value,
// and gating uses explicit tier checks (never `tier >= X`).
Starter = 4
}
@@ -4,5 +4,9 @@ public enum SubscriptionPaymentStatus
{
Pending = 0,
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
}
+24 -30
View File
@@ -1,43 +1,37 @@
using Meezi.Core.Constants;
using Meezi.Core.Enums;
namespace Meezi.Core.Platform;
/// <summary>
/// Serializable per-plan numeric limits, stored as PlatformPlanDefinition.LimitsJson
/// and editable by admins. Missing fields default to "unlimited" (or 0 for opt-in
/// quotas) so older stored JSON stays safe. Defaults come from <see cref="PlanLimits"/>.
/// </summary>
public class PlanLimitsData
{
public int MaxOrdersPerDay { get; set; } = int.MaxValue;
public int MaxTables { get; set; } = int.MaxValue;
public int MaxTerminals { get; set; } = int.MaxValue;
public int MaxCustomers { get; set; } = int.MaxValue;
public int MaxSmsPerMonth { get; set; } = int.MaxValue;
public int MaxSmsPerMonth { get; set; } = 0;
public int MaxBranches { get; set; } = int.MaxValue;
public int MaxReportHistoryDays { get; set; } = int.MaxValue;
public int MaxMenuCategories { get; set; } = int.MaxValue;
public int MaxMenuItems { get; set; } = int.MaxValue;
public int MaxMenuAi3dPerMonth { get; set; } = 0;
public static PlanLimitsData ForTier(Enums.PlanTier tier) => tier switch
public static PlanLimitsData ForTier(PlanTier tier) => new()
{
Enums.PlanTier.Free => new PlanLimitsData
{
MaxOrdersPerDay = 50,
MaxTerminals = 1,
MaxCustomers = 50,
MaxSmsPerMonth = 0,
MaxBranches = 1,
MaxReportHistoryDays = 8
},
Enums.PlanTier.Pro => new PlanLimitsData
{
MaxOrdersPerDay = int.MaxValue,
MaxTerminals = 3,
MaxCustomers = int.MaxValue,
MaxSmsPerMonth = 50,
MaxBranches = 3,
MaxReportHistoryDays = 90
},
Enums.PlanTier.Business => new PlanLimitsData
{
MaxOrdersPerDay = int.MaxValue,
MaxTerminals = int.MaxValue,
MaxCustomers = int.MaxValue,
MaxSmsPerMonth = 200,
MaxBranches = int.MaxValue,
MaxReportHistoryDays = int.MaxValue
},
_ => new PlanLimitsData()
MaxOrdersPerDay = PlanLimits.MaxOrdersPerDay(tier),
MaxTables = PlanLimits.MaxTables(tier),
MaxTerminals = PlanLimits.MaxTerminals(tier),
MaxCustomers = PlanLimits.MaxCustomers(tier),
MaxSmsPerMonth = PlanLimits.MaxSmsPerMonth(tier),
MaxBranches = PlanLimits.MaxBranches(tier),
MaxReportHistoryDays = PlanLimits.MaxReportHistoryDays(tier),
MaxMenuCategories = PlanLimits.MaxMenuCategories(tier),
MaxMenuItems = PlanLimits.MaxMenuItems(tier),
MaxMenuAi3dPerMonth = PlanLimits.MaxMenuAi3dPerMonth(tier),
};
}
@@ -82,10 +82,28 @@ public class AppDbContext : DbContext
// Immutable audit trail of sensitive POS / management actions.
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
// Idempotency keys for safe retry of offline-replayed writes.
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
// Uploaded files, recorded for content-hash de-duplication and a media library.
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
protected override void OnModelCreating(ModelBuilder 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 =>
{
e.HasKey(x => x.Id);
@@ -98,6 +116,20 @@ public class AppDbContext : DbContext
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<MediaAsset>(e =>
{
e.HasKey(x => x.Id);
// Dedup lookups: same content within a scope (café or platform).
e.HasIndex(x => new { x.CafeId, x.ContentHash });
e.Property(x => x.CafeId).HasMaxLength(64);
e.Property(x => x.ContentHash).HasMaxLength(64).IsRequired();
e.Property(x => x.ContentType).HasMaxLength(100).IsRequired();
e.Property(x => x.Url).HasMaxLength(500).IsRequired();
e.Property(x => x.Kind).HasMaxLength(40).IsRequired();
e.Property(x => x.OriginalFileName).HasMaxLength(260);
e.HasQueryFilter(x => x.DeletedAt == null);
});
modelBuilder.Entity<Cafe>(e =>
{
e.HasKey(x => x.Id);
@@ -111,6 +143,9 @@ public class AppDbContext : DbContext
e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000);
e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000);
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);
});
@@ -11,6 +11,28 @@ namespace Meezi.Infrastructure.Data;
/// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary>
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[] ReviewComments =
[
@@ -27,6 +49,7 @@ public static class DiscoverShowcaseSeeder
foreach (var spec in DiscoverShowcaseCatalog.Cafes)
{
var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id);
var (geoLat, geoLng) = GeoFor(spec.Id, spec.City);
if (cafe is null)
{
cafe = new Cafe
@@ -37,6 +60,8 @@ public static class DiscoverShowcaseSeeder
Slug = spec.Slug,
City = spec.City,
Address = spec.Address,
Latitude = geoLat,
Longitude = geoLng,
Description = spec.Description,
PlanTier = spec.PlanTier,
PreferredLanguage = "fa",
@@ -100,6 +125,12 @@ public static class DiscoverShowcaseSeeder
cafe.IsVerified = true;
changed = true;
}
if (cafe.Latitude is null || cafe.Longitude is null)
{
cafe.Latitude = geoLat;
cafe.Longitude = geoLng;
changed = true;
}
if (changed)
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddMediaAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MediaAssets",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
CafeId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
ContentHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
ContentType = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Url = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
Kind = table.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
OriginalFileName = table.Column<string>(type: "character varying(260)", maxLength: 260, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MediaAssets", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_CafeId_ContentHash",
table: "MediaAssets",
columns: new[] { "CafeId", "ContentHash" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MediaAssets");
}
}
}
@@ -360,6 +360,11 @@ namespace Meezi.Infrastructure.Data.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<bool>("ShowOnKoja")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Slug")
.IsRequired()
.HasMaxLength(100)
@@ -1124,6 +1129,54 @@ namespace Meezi.Infrastructure.Data.Migrations
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 =>
{
b.Property<string>("Id")
@@ -1255,6 +1308,55 @@ namespace Meezi.Infrastructure.Data.Migrations
b.ToTable("LeaveRequests");
});
modelBuilder.Entity("Meezi.Core.Entities.MediaAsset", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("CafeId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("character varying(40)");
b.Property<string>("OriginalFileName")
.HasMaxLength(260)
.HasColumnType("character varying(260)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.HasKey("Id");
b.HasIndex("CafeId", "ContentHash");
b.ToTable("MediaAssets");
});
modelBuilder.Entity("Meezi.Core.Entities.MenuCategory", b =>
{
b.Property<string>("Id")
@@ -2006,6 +2108,12 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<DateTime?>("DeletedAt")
.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")
.HasColumnType("integer");
@@ -29,6 +29,25 @@ public static class PlatformDataSeeder
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
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())
{
// Production: also ensure integration settings (Kavenegar enabled/template,
@@ -39,12 +58,83 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
await SeedSettingsAsync(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>
/// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy.
@@ -184,50 +274,47 @@ public static class PlatformDataSeeder
logger.LogInformation("Platform upgrade: added {Count} features", newFeatures.Count);
}
var plans = await db.PlatformPlanDefinitions.ToListAsync();
var changed = 0;
foreach (var plan in plans)
{
if (plan.Tier is PlanTier.Free or PlanTier.Enterprise)
continue;
var keys = plan.Tier == PlanTier.Business || plan.Tier == PlanTier.Enterprise
? new[] { "menu_3d", "menu_3d_ai", "discover_profile" }
: new[] { "menu_3d", "discover_profile" };
var merged = MergeFeaturesJson(plan.FeaturesJson ?? "[]", keys);
if (merged == plan.FeaturesJson) continue;
plan.FeaturesJson = merged;
changed++;
}
if (changed > 0)
// One-time: bring plan definitions to the current matrix (Free·Starter·Pro·
// Business·Enterprise). Existing plans were never admin-editable before this, so
// updating their limits/features/order/price to the canonical defaults is safe.
// Version-guarded so it runs once and never clobbers later admin edits.
const string matrixVersionKey = "catalog.planMatrixVersion";
const string matrixVersion = "2";
var verSetting = await db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == matrixVersionKey);
if (verSetting?.Value != matrixVersion)
{
// Tier is unique across all rows (incl. soft-deleted), so at most one row per tier.
var byTier = (await db.PlatformPlanDefinitions.IgnoreQueryFilters().ToListAsync())
.ToDictionary(p => p.Tier);
foreach (var def in CanonicalPlans())
{
if (byTier.TryGetValue(def.Tier, out var ex))
{
ex.DisplayNameFa = def.DisplayNameFa;
ex.DisplayNameEn = def.DisplayNameEn;
ex.MonthlyPriceToman = def.MonthlyPriceToman;
ex.IsBillableOnline = def.IsBillableOnline;
ex.SortOrder = def.SortOrder;
ex.LimitsJson = def.LimitsJson;
ex.FeaturesJson = def.FeaturesJson;
ex.DeletedAt = null; // ensure all five plans are active
}
else
{
db.PlatformPlanDefinitions.Add(def);
}
}
if (verSetting is null)
db.PlatformSettings.Add(S(matrixVersionKey, matrixVersion, "catalog", "نسخه ماتریس پلن‌ها"));
else
verSetting.Value = matrixVersion;
await db.SaveChangesAsync();
logger.LogInformation("Platform upgrade: updated features on {Count} plans", changed);
logger.LogInformation("Platform upgrade: applied plan matrix v{Version}", matrixVersion);
}
await EnsureIntegrationSettingsAsync(db, logger);
}
private static string MergeFeaturesJson(string json, params string[] keys)
{
var list = JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
if (list.Contains("*"))
return json;
var updated = false;
foreach (var key in keys)
{
if (!list.Contains(key))
{
list.Add(key);
updated = true;
}
}
return updated ? JsonSerializer.Serialize(list, JsonOpts) : json;
}
private static async Task EnsureIntegrationSettingsAsync(AppDbContext db, ILogger logger)
{
var defaults = new[]
@@ -278,82 +365,68 @@ public static class PlatformDataSeeder
logger.LogInformation("Platform seed: system admin phone {Phone}", phone);
}
// ── Canonical plan matrix (Free·Starter·Pro·Business·Enterprise) ─────────────
// Single source of plan DEFAULTS. Free features are broad (KDS, queue, Koja,
// offline, reviews, reservations, coupons, employees); paid tiers add the rest.
private static readonly string[] FreeFeatures =
{
"pos", "menu", "tables", "qr_menu", "kds", "queue", "inventory",
"reservations", "reviews", "coupons", "discover_profile", "offline", "employees"
};
private static readonly string[] StarterFeatures =
FreeFeatures.Concat(new[] { "watermark_removed", "custom_menu_styling", "review_reply" }).ToArray();
private static readonly string[] ProFeatures =
StarterFeatures.Concat(new[] { "crm", "reports", "taxes", "hr", "delivery", "expenses", "branches" }).ToArray();
private static readonly string[] BusinessFeatures =
ProFeatures.Concat(new[] { "menu_3d", "menu_3d_ai" }).ToArray();
private static PlatformPlanDefinition Plan(
string id, PlanTier tier, string fa, string en, decimal price, bool billable, int sort, string[] features) => new()
{
Id = id,
Tier = tier,
DisplayNameFa = fa,
DisplayNameEn = en,
MonthlyPriceToman = price,
IsBillableOnline = billable,
SortOrder = sort,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(tier), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(features, JsonOpts)
};
private static PlatformPlanDefinition[] CanonicalPlans() =>
[
Plan("plan_free", PlanTier.Free, "رایگان", "Free", 0, false, 0, FreeFeatures),
Plan("plan_starter", PlanTier.Starter, "پایه", "Starter", 690_000, true, 1, StarterFeatures),
Plan("plan_pro", PlanTier.Pro, "حرفه‌ای", "Pro", 1_490_000, true, 2, ProFeatures),
Plan("plan_business", PlanTier.Business, "کسب‌وکار", "Business", 3_490_000, true, 3, BusinessFeatures),
Plan("plan_enterprise", PlanTier.Enterprise, "سازمانی", "Enterprise", 0, false, 4, new[] { "*" }),
];
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
{
if (await db.PlatformPlanDefinitions.AnyAsync())
return;
var plans = CanonicalPlans();
var plans = new[]
{
new PlatformPlanDefinition
{
Id = "plan_free",
Tier = PlanTier.Free,
DisplayNameFa = "رایگان",
DisplayNameEn = "Free",
MonthlyPriceToman = 0,
IsBillableOnline = false,
SortOrder = 0,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Free), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[] { "pos", "menu", "tables", "qr_menu" }, JsonOpts)
},
new PlatformPlanDefinition
{
Id = "plan_pro",
Tier = PlanTier.Pro,
DisplayNameFa = "حرفه‌ای",
DisplayNameEn = "Pro",
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Pro),
IsBillableOnline = true,
SortOrder = 1,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Pro), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[]
{
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
"menu_3d", "discover_profile"
}, JsonOpts)
},
new PlatformPlanDefinition
{
Id = "plan_business",
Tier = PlanTier.Business,
DisplayNameFa = "کسب‌وکار",
DisplayNameEn = "Business",
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Business),
IsBillableOnline = true,
SortOrder = 2,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Business), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[]
{
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
"hr", "sms", "reservations", "delivery", "expenses", "branches",
"menu_3d", "menu_3d_ai", "discover_profile"
}, JsonOpts)
},
new PlatformPlanDefinition
{
Id = "plan_enterprise",
Tier = PlanTier.Enterprise,
DisplayNameFa = "سازمانی",
DisplayNameEn = "Enterprise",
MonthlyPriceToman = 0,
IsBillableOnline = false,
SortOrder = 3,
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Enterprise), JsonOpts),
FeaturesJson = JsonSerializer.Serialize(new[] { "*" }, JsonOpts)
}
};
// 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(plans);
db.PlatformPlanDefinitions.AddRange(missing);
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)
{
if (await db.PlatformFeatures.AnyAsync())
return;
var features = new[]
{
F("pos", "صندوق", "POS", "core"),
@@ -376,12 +449,29 @@ public static class PlatformDataSeeder
F("queue", "صف", "Queue", "operations"),
F("menu_3d", "منوی سه‌بعدی", "3D menu", "growth"),
F("menu_3d_ai", "تولید ۳D با هوش مصنوعی", "AI 3D menu", "growth"),
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
F("discover_profile", "پروفایل کشف", "Discover profile", "growth"),
F("offline", "حالت آفلاین", "Offline mode", "core"),
F("employees", "کارکنان", "Employees", "operations"),
F("watermark_removed", "حذف واترمارک منو", "Remove menu watermark", "growth"),
F("custom_menu_styling", "طراحی اختصاصی منو", "Custom menu styling", "growth"),
F("review_reply", "پاسخ به نظرات", "Reply to reviews", "growth"),
F("api", "API عمومی", "Public API", "integrations"),
F("white_label", "وایت‌لیبل", "White-label", "integrations")
};
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();
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()
@@ -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) =>
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) =>
Task.FromResult<IngredientDto?>(null);
+43 -8
View File
@@ -1055,8 +1055,8 @@
"fieldExcerptEn": "الملخص بالإنجليزية",
"fieldCategoryFa": "الفئة بالفارسية",
"fieldCategoryEn": "الفئة بالإنجليزية",
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
"fieldContentFa": "المحتوى (فارسي)",
"fieldContentEn": "المحتوى (إنجليزي)",
"fieldPublished": "منشور",
"commentsTitle": "إدارة التعليقات",
"noComments": "لا توجد تعليقات",
@@ -1114,7 +1114,29 @@
"title": "خطط الاشتراك",
"monthlyPrice": "السعر الشهري (تومان)",
"maxOrders": "حد الطلبات اليومي",
"saved": "تم الحفظ"
"saved": "تم الحفظ",
"active": "مفعل",
"nameFa": "الاسم (فارسي)",
"nameEn": "الاسم (إنجليزي)",
"sortOrder": "الترتيب",
"billable": "قابل للدفع عبر الإنترنت",
"limitsTitle": "الحدود",
"featuresTitle": "الميزات",
"allFeatures": "كل الميزات",
"allFeaturesNote": "تشمل هذه الباقة جميع الميزات الحالية والمستقبلية.",
"save": "حفظ",
"limits": {
"maxOrders": "طلبات/يوم",
"maxTables": "الطاولات",
"maxTerminals": "أجهزة POS",
"maxBranches": "الفروع",
"maxCategories": "فئات القائمة",
"maxItems": "أصناف القائمة",
"maxCustomers": "العملاء",
"maxReportDays": "سجل التقارير (أيام)",
"maxSms": "رسائل/شهر",
"maxAi3d": "3D/شهر"
}
},
"settings": {
"title": "إعدادات التطبيق",
@@ -1241,8 +1263,13 @@
"instagramLabel": "إنستغرام",
"websiteLabel": "الموقع",
"days": {
"sat": "السبت", "sun": "الأحد", "mon": "الاثنين",
"tue": "الثلاثاء", "wed": "الأربعاء", "thu": "الخميس", "fri": "الجمعة"
"sat": "السبت",
"sun": "الأحد",
"mon": "الاثنين",
"tue": "الثلاثاء",
"wed": "الأربعاء",
"thu": "الخميس",
"fri": "الجمعة"
},
"coffeeAdvisor": {
"title": "مستشار المشروبات",
@@ -1253,7 +1280,10 @@
"notConfigured": "المستشار الذكي غير مفعّل لهذا المقهى بعد",
"failed": "الاقتراحات غير متاحة. حاول لاحقاً"
},
"cities": { "tehran": "طهران", "karaj": "كرج" },
"cities": {
"tehran": "طهران",
"karaj": "كرج"
},
"sort": {
"rating": "الأعلى تقييماً",
"reviews": "الأكثر مراجعات",
@@ -1296,8 +1326,13 @@
"openTime": "يفتح الساعة",
"closeTime": "يغلق الساعة",
"days": {
"sat": "السبت", "sun": "الأحد", "mon": "الاثنين",
"tue": "الثلاثاء", "wed": "الأربعاء", "thu": "الخميس", "fri": "الجمعة"
"sat": "السبت",
"sun": "الأحد",
"mon": "الاثنين",
"tue": "الثلاثاء",
"wed": "الأربعاء",
"thu": "الخميس",
"fri": "الجمعة"
},
"save": "حفظ",
"saved": "تم الحفظ",
+29 -4
View File
@@ -1056,8 +1056,8 @@
"fieldExcerptEn": "Excerpt (English)",
"fieldCategoryFa": "Category (Persian)",
"fieldCategoryEn": "Category (English)",
"fieldContentFa": "Content (Persian, Markdown)",
"fieldContentEn": "Content (English, Markdown)",
"fieldContentFa": "Content (Persian)",
"fieldContentEn": "Content (English)",
"fieldPublished": "Published",
"commentsTitle": "Comment management",
"noComments": "No comments found",
@@ -1107,7 +1107,29 @@
"title": "Subscription plans",
"monthlyPrice": "Monthly price (Toman)",
"maxOrders": "Max orders per day",
"saved": "Plan saved"
"saved": "Plan saved",
"active": "Active",
"nameFa": "Name (Persian)",
"nameEn": "Name (English)",
"sortOrder": "Sort order",
"billable": "Billable online",
"limitsTitle": "Limits",
"featuresTitle": "Features",
"allFeatures": "All features",
"allFeaturesNote": "This plan includes all features (current and future).",
"save": "Save",
"limits": {
"maxOrders": "Orders/day",
"maxTables": "Tables",
"maxTerminals": "POS terminals",
"maxBranches": "Branches",
"maxCategories": "Menu categories",
"maxItems": "Menu items",
"maxCustomers": "Customers",
"maxReportDays": "Report history (days)",
"maxSms": "SMS/month",
"maxAi3d": "AI 3D/month"
}
},
"settings": {
"title": "Application settings",
@@ -1219,7 +1241,10 @@
"notFound": "Café not found",
"exploreMore": "Explore more cafés",
"reviewCount": "{count} reviews",
"cities": { "tehran": "Tehran", "karaj": "Karaj" },
"cities": {
"tehran": "Tehran",
"karaj": "Karaj"
},
"sort": {
"rating": "Top rated",
"reviews": "Most reviews",
+25 -3
View File
@@ -1056,8 +1056,8 @@
"fieldExcerptEn": "خلاصه انگلیسی",
"fieldCategoryFa": "دسته‌بندی فارسی",
"fieldCategoryEn": "دسته‌بندی انگلیسی",
"fieldContentFa": "محتوا فارسی (Markdown)",
"fieldContentEn": "محتوا انگلیسی (Markdown)",
"fieldContentFa": "محتوا (فارسی)",
"fieldContentEn": "محتوا (انگلیسی)",
"fieldPublished": "وضعیت انتشار",
"commentsTitle": "مدیریت نظرات",
"noComments": "نظری یافت نشد",
@@ -1107,7 +1107,29 @@
"title": "پلن‌ها و قیمت‌گذاری",
"monthlyPrice": "قیمت ماهانه (تومان)",
"maxOrders": "سقف سفارش روزانه",
"saved": "پلن ذخیره شد"
"saved": "پلن ذخیره شد",
"active": "فعال",
"nameFa": "نام (فارسی)",
"nameEn": "نام (انگلیسی)",
"sortOrder": "ترتیب",
"billable": "قابل پرداخت آنلاین",
"limitsTitle": "محدودیت‌ها",
"featuresTitle": "امکانات",
"allFeatures": "همه امکانات",
"allFeaturesNote": "این پلن به همه امکانات (فعلی و آینده) دسترسی دارد.",
"save": "ذخیره",
"limits": {
"maxOrders": "سفارش روزانه",
"maxTables": "میزها",
"maxTerminals": "پایانه POS",
"maxBranches": "شعب",
"maxCategories": "دسته منو",
"maxItems": "آیتم منو",
"maxCustomers": "مشتریان",
"maxReportDays": "تاریخچه گزارش (روز)",
"maxSms": "پیامک ماهانه",
"maxAi3d": "تولید ۳D ماهانه"
}
},
"settings": {
"title": "تنظیمات اپلیکیشن",
+791 -7
View File
@@ -13,6 +13,11 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.59.20",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
@@ -427,6 +432,16 @@
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -1160,6 +1175,12 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1216,6 +1237,422 @@
"react": "^18 || ^19"
}
},
"node_modules/@tiptap/core": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz",
"integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.4",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.27.2",
"@tiptap/extension-floating-menu": "^2.27.2",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.27.2",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-dropcursor": "^2.27.2",
"@tiptap/extension-gapcursor": "^2.27.2",
"@tiptap/extension-hard-break": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-history": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/pm": "^2.27.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -1223,6 +1660,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
@@ -1237,14 +1696,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.29",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1255,12 +1714,18 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.60.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz",
@@ -1687,7 +2152,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-hidden": {
@@ -2302,6 +2766,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2334,7 +2804,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -2547,6 +3017,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -2734,7 +3216,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -3177,7 +3658,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-glob": {
@@ -4471,6 +4951,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -4522,6 +5027,33 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/markdown-it": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz",
"integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.1",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -4531,6 +5063,12 @@
"node": ">= 0.4"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -5000,6 +5538,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -5373,6 +5917,201 @@
"react-is": "^16.13.1"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz",
"integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.7",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.7.tgz",
"integrity": "sha512-A79aN8QEFUwI6cax8Yq4Rpcx1TJZ3Kagn+ii7qLo4/V8H3mMiHrhFyhTyHHvpSnOgMPpWiDGSwM3etwrxE50ug==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.41.8",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -5392,6 +6131,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5688,6 +6436,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -6420,6 +7174,15 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -6590,6 +7353,12 @@
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -6751,6 +7520,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -6758,6 +7536,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+6 -1
View File
@@ -15,10 +15,15 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tanstack/react-query": "^5.59.20",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-placeholder": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"axios": "^1.7.7",
"date-fns-jalali": "^4.1.0-0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns-jalali": "^4.1.0-0",
"lucide-react": "^0.454.0",
"next": "14.2.18",
"next-intl": "^3.23.5",
+183 -37
View File
@@ -18,6 +18,7 @@ import type {
AdminNotificationRow,
AdminPlan,
AdminStats,
PlanLimitsData,
GatewayCredentials,
PaymentGatewayConfig,
PlatformFeature,
@@ -44,6 +45,7 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
<button
type="button"
role="switch"
dir="ltr"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
@@ -130,45 +132,167 @@ function StatCard({ label, value }: { label: string; value: number }) {
);
}
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
const PLAN_UNLIMITED = 2147483647;
const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
{ key: "maxOrdersPerDay", label: "maxOrders" },
{ key: "maxTables", label: "maxTables" },
{ key: "maxTerminals", label: "maxTerminals" },
{ key: "maxBranches", label: "maxBranches" },
{ key: "maxMenuCategories", label: "maxCategories" },
{ key: "maxMenuItems", label: "maxItems" },
{ key: "maxCustomers", label: "maxCustomers" },
{ key: "maxReportHistoryDays", label: "maxReportDays" },
{ key: "maxSmsPerMonth", label: "maxSms" },
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
];
function LimitField({ label, value, onChange }: { label: string; value: number; onChange: (n: number) => void }) {
const unlimited = value >= PLAN_UNLIMITED;
return (
<div className="rounded-lg border border-border/70 p-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{label}</span>
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
<input type="checkbox" checked={unlimited} onChange={(e) => onChange(e.target.checked ? PLAN_UNLIMITED : 0)} />
</label>
</div>
<Input
type="number"
className="mt-1 h-8 text-sm"
disabled={unlimited}
value={unlimited ? "" : value}
onChange={(e) => onChange(Math.max(0, Number(e.target.value)))}
/>
</div>
);
}
function PlanCard({
plan,
features,
onSave,
saving,
}: {
plan: AdminPlan;
features: PlatformFeature[];
onSave: (p: AdminPlan) => void;
saving: boolean;
}) {
const t = useTranslations("admin.plans");
const [price, setPrice] = useState(plan.monthlyPriceToman);
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
const [draft, setDraft] = useState<AdminPlan>(plan);
// Re-sync from server after a save/refetch.
useEffect(() => { setDraft(plan); }, [plan]);
// Sync server values if they change (e.g. after successful save + refetch)
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
const setField = <K extends keyof AdminPlan>(k: K, v: AdminPlan[K]) =>
setDraft((d) => ({ ...d, [k]: v }));
const setLimit = (k: keyof PlanLimitsData, v: number) =>
setDraft((d) => ({ ...d, limits: { ...d.limits, [k]: v } }));
const flush = () =>
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
const wildcard = draft.featureKeys.includes("*");
const toggleFeature = (key: string, on: boolean) =>
setDraft((d) => {
const set = new Set(d.featureKeys.filter((k) => k !== "*"));
if (on) set.add(key);
else set.delete(key);
return { ...d, featureKeys: Array.from(set) };
});
const groups = Array.from(new Set(features.map((f) => f.moduleGroup)));
return (
<Card className="rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
<p className="text-xs text-muted-foreground">{plan.tier}</p>
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2">
<div>
<CardTitle className="text-base">{draft.displayNameFa || draft.tier}</CardTitle>
<p className="text-xs text-muted-foreground">{draft.tier}</p>
</div>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={draft.isActive} onChange={(e) => setField("isActive", e.target.checked)} />
{t("active")}
</label>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t("monthlyPrice")}
<Input
type="number"
className="mt-1"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
onBlur={flush}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
value={maxOrders}
onChange={(e) => setMaxOrders(Number(e.target.value))}
onBlur={flush}
/>
<CardContent className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<label className="text-sm">
{t("nameFa")}
<Input className="mt-1 h-8" value={draft.displayNameFa} onChange={(e) => setField("displayNameFa", e.target.value)} dir="rtl" />
</label>
<label className="text-sm">
{t("nameEn")}
<Input className="mt-1 h-8" value={draft.displayNameEn ?? ""} onChange={(e) => setField("displayNameEn", e.target.value)} dir="ltr" />
</label>
<label className="text-sm">
{t("monthlyPrice")}
<Input type="number" className="mt-1 h-8" value={draft.monthlyPriceToman} onChange={(e) => setField("monthlyPriceToman", Number(e.target.value))} />
</label>
<label className="text-sm">
{t("sortOrder")}
<Input type="number" className="mt-1 h-8" value={draft.sortOrder} onChange={(e) => setField("sortOrder", Number(e.target.value))} />
</label>
</div>
<label className="flex w-fit items-center gap-1.5 text-xs">
<input type="checkbox" checked={draft.isBillableOnline} onChange={(e) => setField("isBillableOnline", e.target.checked)} />
{t("billable")}
</label>
<div>
<p className="mb-2 text-xs font-semibold text-muted-foreground">{t("limitsTitle")}</p>
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
{LIMIT_FIELDS.map((f) => (
<LimitField key={f.key} label={t(`limits.${f.label}`)} value={draft.limits[f.key] ?? PLAN_UNLIMITED} onChange={(v) => setLimit(f.key, v)} />
))}
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground">{t("featuresTitle")}</p>
<label className="flex items-center gap-1.5 text-xs">
<input type="checkbox" checked={wildcard} onChange={(e) => setField("featureKeys", e.target.checked ? ["*"] : [])} />
{t("allFeatures")}
</label>
</div>
{wildcard ? (
<p className="text-xs text-muted-foreground">{t("allFeaturesNote")}</p>
) : (
<div className="space-y-3">
{groups.map((g) => (
<div key={g}>
<p className="mb-1 text-[11px] uppercase tracking-wide text-muted-foreground">{g}</p>
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
{features
.filter((f) => f.moduleGroup === g)
.map((f) => (
<label
key={f.key}
className={cn(
"flex items-center gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm",
!f.isEnabledGlobally && "opacity-50"
)}
>
<input type="checkbox" checked={draft.featureKeys.includes(f.key)} onChange={(e) => toggleFeature(f.key, e.target.checked)} />
<span className="truncate">{f.displayNameFa}</span>
</label>
))}
</div>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => onSave(draft)}
disabled={saving}
className="rounded-lg bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{t("save")}
</button>
</div>
</CardContent>
</Card>
);
@@ -181,6 +305,10 @@ export function AdminPlansScreen() {
queryKey: ["admin", "plans"],
queryFn: () => adminGet<AdminPlan[]>("/api/admin/plans"),
});
const { data: features = [] } = useQuery({
queryKey: ["admin", "features"],
queryFn: () => adminGet<PlatformFeature[]>("/api/admin/features"),
});
const save = useMutation({
mutationFn: (plan: AdminPlan) =>
@@ -200,11 +328,19 @@ export function AdminPlansScreen() {
},
});
const ordered = [...plans].sort((a, b) => a.sortOrder - b.sortOrder);
return (
<div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1>
{plans.map((plan) => (
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
{ordered.map((plan) => (
<PlanCard
key={plan.tier}
plan={plan}
features={features}
onSave={(p) => save.mutate(p)}
saving={save.isPending}
/>
))}
</div>
);
@@ -604,11 +740,18 @@ export function AdminIntegrationsScreen() {
});
}, [data]);
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
const save = useMutation({
mutationFn: () =>
adminPut<PlatformIntegrations>("/api/admin/integrations", {
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,
isEnabled: g.isEnabled,
merchantId: g.id === "zarinpal" ? g.merchantId : undefined,
@@ -637,11 +780,14 @@ export function AdminIntegrationsScreen() {
});
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 (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3">
@@ -14,6 +14,7 @@ import type {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RichTextEditor } from "@/components/ui/rich-text-editor";
import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
@@ -162,6 +163,7 @@ function BlogToggle({
<button
type="button"
role="switch"
dir="ltr"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
@@ -326,8 +328,14 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
</div>
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentFa")}</label>
<RichTextEditor value={form.contentFa} onChange={setField("contentFa")} dir="rtl" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{t("fieldContentEn")}</label>
<RichTextEditor value={form.contentEn} onChange={setField("contentEn")} dir="ltr" />
</div>
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
<span className="text-sm font-medium">{t("fieldPublished")}</span>
@@ -0,0 +1,158 @@
"use client";
import { useEffect } from "react";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link2,
Link2Off,
Undo2,
Redo2,
} from "lucide-react";
import { cn } from "@/lib/utils";
type Props = {
value: string;
onChange: (html: string) => void;
dir?: "rtl" | "ltr";
placeholder?: string;
};
/** Headless TipTap rich-text editor producing HTML. Used for long-form content
* (blog posts) edited by trusted admins. */
export function RichTextEditor({ value, onChange, dir = "rtl", placeholder }: Props) {
const editor = useEditor({
immediatelyRender: false, // required under Next.js SSR to avoid hydration mismatch
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Link.configure({ openOnClick: false, autolink: true, HTMLAttributes: { rel: "noopener", target: "_blank" } }),
Placeholder.configure({ placeholder: placeholder ?? "" }),
],
content: value || "",
editorProps: {
attributes: {
dir,
class: "meezi-rte-content min-h-44 px-3 py-2 focus:outline-none",
},
},
onUpdate: ({ editor }) => onChange(editor.getHTML()),
});
// Keep the editor in sync when the external value changes (load existing post / reset).
useEffect(() => {
if (!editor) return;
const current = editor.getHTML();
if (value !== current) {
editor.commands.setContent(value || "", false);
}
}, [value, editor]);
return (
<div className="overflow-hidden rounded-lg border border-border bg-background" dir={dir}>
<Toolbar editor={editor} />
<EditorContent editor={editor} />
<style>{`
.meezi-rte-content { font-size: 0.875rem; line-height: 1.7; }
.meezi-rte-content:focus { outline: none; }
.meezi-rte-content h1 { font-size: 1.5rem; font-weight: 700; margin: 0.6em 0 0.3em; }
.meezi-rte-content h2 { font-size: 1.25rem; font-weight: 700; margin: 0.6em 0 0.3em; }
.meezi-rte-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.5em 0 0.25em; }
.meezi-rte-content p { margin: 0.4em 0; }
.meezi-rte-content ul { list-style: disc; padding-inline-start: 1.5rem; margin: 0.4em 0; }
.meezi-rte-content ol { list-style: decimal; padding-inline-start: 1.5rem; margin: 0.4em 0; }
.meezi-rte-content blockquote { border-inline-start: 3px solid hsl(var(--primary)); padding-inline-start: 0.75rem; color: hsl(var(--muted-foreground)); margin: 0.5em 0; }
.meezi-rte-content a { color: hsl(var(--primary)); text-decoration: underline; }
.meezi-rte-content code { background: hsl(var(--muted)); padding: 0.1em 0.3em; border-radius: 4px; font-size: 0.85em; }
.meezi-rte-content pre { background: hsl(var(--muted)); padding: 0.6rem 0.8rem; border-radius: 8px; overflow-x: auto; }
.meezi-rte-content p.is-editor-empty:first-child::before { content: attr(data-placeholder); color: hsl(var(--muted-foreground)); float: inline-start; height: 0; pointer-events: none; }
`}</style>
</div>
);
}
function Toolbar({ editor }: { editor: Editor | null }) {
if (!editor) return <div className="h-9 border-b border-border bg-muted/40" />;
const setLink = () => {
const prev = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("URL", prev ?? "https://");
if (url === null) return;
if (url === "") {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
};
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-border bg-muted/40 px-1.5 py-1">
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} title="Bold"><Bold className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} title="Italic"><Italic className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} title="Strikethrough"><Strikethrough className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })} title="H1"><Heading1 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })} title="H2"><Heading2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })} title="H3"><Heading3 className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} title="Bullet list"><List className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} title="Numbered list"><ListOrdered className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} title="Quote"><Quote className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive("codeBlock")} title="Code block"><Code className="size-4" /></Btn>
<Sep />
<Btn onClick={setLink} active={editor.isActive("link")} title="Link"><Link2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().unsetLink().run()} active={false} title="Remove link" disabled={!editor.isActive("link")}><Link2Off className="size-4" /></Btn>
<Sep />
<Btn onClick={() => editor.chain().focus().undo().run()} active={false} title="Undo" disabled={!editor.can().undo()}><Undo2 className="size-4" /></Btn>
<Btn onClick={() => editor.chain().focus().redo().run()} active={false} title="Redo" disabled={!editor.can().redo()}><Redo2 className="size-4" /></Btn>
</div>
);
}
function Btn({
onClick,
active,
title,
disabled,
children,
}: {
onClick: () => void;
active: boolean;
title: string;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
disabled={disabled}
className={cn(
"inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background hover:text-foreground disabled:opacity-40",
active && "bg-primary/15 text-primary"
)}
>
{children}
</button>
);
}
function Sep() {
return <span className="mx-0.5 h-5 w-px bg-border" />;
}
+4
View File
@@ -8,11 +8,15 @@ export type AdminStats = {
export type PlanLimitsData = {
maxOrdersPerDay: number;
maxTables: number;
maxTerminals: number;
maxCustomers: number;
maxSmsPerMonth: number;
maxBranches: number;
maxReportHistoryDays: number;
maxMenuCategories: number;
maxMenuItems: number;
maxMenuAi3dPerMonth: number;
};
export type AdminPlan = {
+78 -13
View File
@@ -20,6 +20,14 @@
"saved": "تم الحفظ",
"errorGeneric": "حدث خطأ. حاول مرة أخرى."
},
"errors": {
"planLimit": "وصلت إلى حد الخطة",
"notFound": "غير موجود",
"unauthorized": "غير مصرح",
"network": "خطأ في الاتصال",
"generic": "حدث خطأ. حاول مرة أخرى.",
"OFFLINE_UNAVAILABLE": "يتطلب هذا الإجراء اتصالاً بالإنترنت. يرجى المحاولة بعد عودة الاتصال."
},
"brand": {
"name": "ميزي"
},
@@ -243,6 +251,7 @@
"void": "إلغاء",
"voidItem": "إلغاء الصنف",
"voided": "ملغى",
"itemNotePlaceholder": "ملاحظة للمطبخ/البار (اختياري)",
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
"voidError": "تعذر إلغاء الصنف",
"transferTable": "نقل الطاولة",
@@ -372,7 +381,10 @@
"duplicatePhone": "رقم الجوال مسجل مسبقاً.",
"generic": "تعذر الحفظ. حاول مرة أخرى."
}
}
},
"deleted": "تم حذف العميل",
"deleteConfirmTitle": "حذف العميل",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟"
},
"coupons": {
"title": "القسائم",
@@ -388,7 +400,10 @@
"FixedAmount": "مبلغ ثابت",
"FreeItem": "عنصر مجاني"
},
"noCoupons": "لا توجد قسائم"
"noCoupons": "لا توجد قسائم",
"deleted": "تم حذف القسيمة",
"deleteConfirmTitle": "حذف القسيمة",
"deleteConfirmDesc": "هل أنت متأكد من حذف القسيمة «{code}»؟"
},
"hr": {
"title": "الموارد البشرية",
@@ -397,7 +412,8 @@
"leave": "الإجازة",
"payroll": "الرواتب",
"access": "صلاحيات الفروع",
"credentials": "بيانات الدخول"
"credentials": "بيانات الدخول",
"team": "الموظفون"
},
"myAttendance": "حضوري",
"clockIn": "تسجيل دخول",
@@ -422,6 +438,33 @@
"saved": "تم حفظ بيانات الدخول.",
"removed": "تم حذف بيانات الدخول.",
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
},
"addEmployee": "إضافة موظف",
"noEmployees": "لا يوجد موظفون بعد.",
"employeeCreated": "تمت إضافة الموظف",
"save": "حفظ",
"cancel": "إلغاء",
"fields": {
"name": "الاسم",
"phone": "الجوال",
"role": "الدور",
"branch": "الفرع",
"branchOptional": "اختياري",
"noBranch": "بدون فرع",
"baseSalary": "الراتب الأساسي (تومان)",
"optional": "اختياري",
"enableLogin": "إنشاء اسم مستخدم وكلمة مرور",
"username": "اسم المستخدم",
"password": "كلمة المرور",
"passwordHint": "8 أحرف على الأقل"
},
"roles": {
"Owner": "المالك",
"Manager": "مدير",
"Cashier": "أمين الصندوق",
"Waiter": "نادل",
"Chef": "طاهٍ",
"Delivery": "موصّل"
}
},
"reviews": {
@@ -735,7 +778,13 @@
"addItemSuccess": "تمت إضافة الصنف",
"updateItemSuccess": "تم تحديث الصنف",
"addCategorySuccess": "تمت إضافة الفئة",
"updateCategorySuccess": "تم تحديث الفئة"
"updateCategorySuccess": "تم تحديث الفئة",
"deleteItemConfirmTitle": "حذف الصنف",
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteItemSuccess": "تم حذف الصنف",
"deleteCategoryConfirmTitle": "حذف الفئة",
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
"deleteCategorySuccess": "تم حذف الفئة"
},
"branchMenu": {
"title": "قائمة الفرع",
@@ -829,7 +878,10 @@
"purchasesThisMonth": "مشتريات المواد هذا الشهر",
"purchaseCount": "{count} عملية شراء",
"viewInExpenses": "عرض في المصروفات",
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع."
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع.",
"deleted": "تم حذف المادة",
"deleteConfirmTitle": "حذف المادة",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع."
},
"qr": {
"brand": "ميزي",
@@ -856,6 +908,7 @@
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
"guestName": "اسمك (اختياري)",
"guestPhone": "الجوال (اختياري)",
"itemNote": "ملاحظة (مثلاً بدون طماطم، سكر أقل)",
"addMoreItems": "إضافة المزيد",
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
@@ -943,7 +996,10 @@
"Cancelled": "ملغى",
"Seated": "جالس",
"Completed": "مكتمل"
}
},
"deleted": "تم حذف الحجز",
"deleteConfirmTitle": "حذف الحجز",
"deleteConfirmDesc": "هل أنت متأكد من حذف حجز «{name}»؟"
},
"branchesPage": {
"title": "الفروع",
@@ -1020,7 +1076,18 @@
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
"payTotal": "ادفع {total}",
"redirecting": "جارٍ التحويل إلى البوابة...",
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى.",
"queuedNotice": "لديك اشتراك نشط بالفعل. ستتم إضافة هذا الشراء إلى قائمة الانتظار وسيبدأ في {date}."
},
"queued": {
"title": "الاشتراكات في قائمة الانتظار",
"subtitle": "تبدأ تلقائيًا عند انتهاء اشتراكك الحالي.",
"months": "{count} أشهر",
"window": "من {from} إلى {to}",
"cancel": "إلغاء",
"cancelled": "تم إلغاء الاشتراك في قائمة الانتظار",
"cancelConfirmTitle": "إلغاء الاشتراك المجدول",
"cancelConfirmDesc": "إلغاء اشتراك {plan} المقرر أن يبدأ في {from}؟ لن يتأثر اشتراكك الحالي."
}
},
"settings": {
@@ -1359,12 +1426,6 @@
}
}
},
"errors": {
"planLimit": "وصلت إلى حد الخطة",
"notFound": "غير موجود",
"unauthorized": "غير مصرح",
"network": "خطأ في الاتصال"
},
"discoverPublic": {
"brand": "ميزي",
"title": "اكتشاف المقاهي",
@@ -1511,5 +1572,9 @@
"mid": "میانه",
"premium": "پریمیوم"
}
},
"cafePublicProfile": {
"showOnKoja": "العرض على كوجا",
"showOnKojaHint": "إدراج مقهاك في دليل كوجا العام (koja.meezi.ir). مفعّل افتراضيًا."
}
}
+77 -14
View File
@@ -20,6 +20,14 @@
"saved": "Saved",
"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": {
"name": "Meezi"
},
@@ -262,6 +270,7 @@
"void": "Void",
"voidItem": "Void item",
"voided": "Voided",
"itemNotePlaceholder": "Note for kitchen/bar (optional)",
"confirmVoid": "Are you sure you want to void this item?",
"voidError": "Could not void item",
"transferTable": "Transfer table",
@@ -391,7 +400,10 @@
"duplicatePhone": "This phone number is already registered.",
"generic": "Could not save. Please try again."
}
}
},
"deleted": "Customer deleted",
"deleteConfirmTitle": "Delete customer",
"deleteConfirmDesc": "Delete “{name}”?"
},
"coupons": {
"title": "Coupons",
@@ -407,7 +419,10 @@
"FixedAmount": "Fixed amount",
"FreeItem": "Free item"
},
"noCoupons": "No coupons yet"
"noCoupons": "No coupons yet",
"deleted": "Coupon deleted",
"deleteConfirmTitle": "Delete coupon",
"deleteConfirmDesc": "Delete coupon “{code}”?"
},
"hr": {
"title": "Human resources",
@@ -416,7 +431,8 @@
"leave": "Leave",
"payroll": "Payroll",
"access": "Branch access",
"credentials": "Login credentials"
"credentials": "Login credentials",
"team": "Team"
},
"myAttendance": "My attendance",
"clockIn": "Clock in",
@@ -441,6 +457,33 @@
"saved": "Credentials saved.",
"removed": "Credentials removed.",
"usernameTaken": "This username is already taken."
},
"addEmployee": "Add employee",
"noEmployees": "No employees yet.",
"employeeCreated": "Employee added",
"save": "Save",
"cancel": "Cancel",
"fields": {
"name": "Name",
"phone": "Mobile",
"role": "Role",
"branch": "Branch",
"branchOptional": "optional",
"noBranch": "No branch",
"baseSalary": "Base salary (Toman)",
"optional": "optional",
"enableLogin": "Create username & password",
"username": "Username",
"password": "Password",
"passwordHint": "At least 8 characters"
},
"roles": {
"Owner": "Owner",
"Manager": "Manager",
"Cashier": "Cashier",
"Waiter": "Waiter",
"Chef": "Chef",
"Delivery": "Delivery"
}
},
"reviews": {
@@ -778,7 +821,13 @@
"addItemSuccess": "Item added",
"updateItemSuccess": "Item updated",
"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": {
"title": "Branch Menu",
@@ -898,7 +947,10 @@
"purchasesThisMonth": "Material purchases this month",
"purchaseCount": "{count} purchases",
"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": {
"brand": "Meezi",
@@ -925,6 +977,7 @@
"orderHint": "Staff will prepare your order shortly",
"guestName": "Your name (optional)",
"guestPhone": "Mobile (optional)",
"itemNote": "Note (e.g. no tomato, less sugar)",
"addMoreItems": "Add more items",
"orderError": "Could not place order. Try again.",
"rateLimited": "Too many requests — please wait a few minutes",
@@ -1013,7 +1066,10 @@
"Cancelled": "Cancelled",
"Seated": "Seated",
"Completed": "Completed"
}
},
"deleted": "Reservation deleted",
"deleteConfirmTitle": "Delete reservation",
"deleteConfirmDesc": "Delete the reservation for “{name}”?"
},
"branchesPage": {
"title": "Branches",
@@ -1092,7 +1148,18 @@
"secureNote": "Payment is processed through a secure bank gateway.",
"payTotal": "Pay {total}",
"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": {
@@ -1441,12 +1508,6 @@
}
}
},
"errors": {
"planLimit": "Plan limit reached. Please upgrade.",
"notFound": "Not found",
"unauthorized": "Unauthorized",
"network": "Network error"
},
"discoverPublic": {
"brand": "Meezi",
"title": "Discover cafés",
@@ -1551,7 +1612,9 @@
"save": "Save",
"saved": "Saved",
"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": {
"sections": {
+77 -14
View File
@@ -20,6 +20,14 @@
"saved": "ذخیره شد",
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
},
"errors": {
"planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound": "یافت نشد",
"unauthorized": "دسترسی ندارید",
"network": "خطای ارتباط با سرور",
"generic": "خطایی رخ داد. دوباره تلاش کنید.",
"OFFLINE_UNAVAILABLE": "برای این کار به اینترنت نیاز است. لطفاً پس از اتصال دوباره تلاش کنید."
},
"brand": {
"name": "میزی"
},
@@ -262,6 +270,7 @@
"void": "ابطال",
"voidItem": "ابطال آیتم",
"voided": "ابطال شده",
"itemNotePlaceholder": "یادداشت برای آشپزخانه/بار (اختیاری)",
"confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟",
"voidError": "خطا در ابطال آیتم",
"transferTable": "انتقال میز",
@@ -391,7 +400,10 @@
"duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.",
"generic": "ذخیره انجام نشد. دوباره تلاش کنید."
}
}
},
"deleted": "مشتری حذف شد",
"deleteConfirmTitle": "حذف مشتری",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟"
},
"coupons": {
"title": "کوپن‌ها",
@@ -407,7 +419,10 @@
"FixedAmount": "مبلغ ثابت",
"FreeItem": "آیتم رایگان"
},
"noCoupons": "کوپنی ثبت نشده"
"noCoupons": "کوپنی ثبت نشده",
"deleted": "کوپن حذف شد",
"deleteConfirmTitle": "حذف کوپن",
"deleteConfirmDesc": "آیا از حذف کوپن «{code}» مطمئن هستید؟"
},
"hr": {
"title": "منابع انسانی",
@@ -416,7 +431,8 @@
"leave": "مرخصی",
"payroll": "حقوق",
"access": "دسترسی شعب",
"credentials": "رمز ورود"
"credentials": "رمز ورود",
"team": "کارکنان"
},
"myAttendance": "حضور من",
"clockIn": "ورود",
@@ -441,6 +457,33 @@
"saved": "رمز ورود ذخیره شد.",
"removed": "رمز ورود حذف شد.",
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
},
"addEmployee": "افزودن کارمند",
"noEmployees": "هنوز کارمندی ثبت نشده است.",
"employeeCreated": "کارمند اضافه شد",
"save": "ذخیره",
"cancel": "انصراف",
"fields": {
"name": "نام",
"phone": "موبایل",
"role": "نقش",
"branch": "شعبه",
"branchOptional": "اختیاری",
"noBranch": "بدون شعبه",
"baseSalary": "حقوق پایه (تومان)",
"optional": "اختیاری",
"enableLogin": "ایجاد نام کاربری و رمز ورود",
"username": "نام کاربری",
"password": "رمز عبور",
"passwordHint": "حداقل ۸ کاراکتر"
},
"roles": {
"Owner": "مالک",
"Manager": "مدیر",
"Cashier": "صندوق‌دار",
"Waiter": "گارسون",
"Chef": "آشپز",
"Delivery": "پیک"
}
},
"reviews": {
@@ -778,7 +821,13 @@
"addItemSuccess": "آیتم اضافه شد",
"updateItemSuccess": "آیتم به‌روز شد",
"addCategorySuccess": "دسته اضافه شد",
"updateCategorySuccess": "دسته به‌روز شد"
"updateCategorySuccess": "دسته به‌روز شد",
"deleteItemConfirmTitle": "حذف آیتم",
"deleteItemConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.",
"deleteItemSuccess": "آیتم حذف شد",
"deleteCategoryConfirmTitle": "حذف دسته‌بندی",
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
"deleteCategorySuccess": "دسته حذف شد"
},
"branchMenu": {
"title": "منوی شعبه",
@@ -898,7 +947,10 @@
"purchasesThisMonth": "خرید مواد این ماه",
"purchaseCount": "{count} خرید",
"viewInExpenses": "مشاهده در هزینه‌ها",
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید."
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید.",
"deleted": "ماده حذف شد",
"deleteConfirmTitle": "حذف ماده",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست."
},
"qr": {
"brand": "میزی",
@@ -925,6 +977,7 @@
"orderHint": "کارکنان به زودی سفارش شما را آماده می‌کنند",
"guestName": "نام شما (اختیاری)",
"guestPhone": "شماره موبایل (اختیاری)",
"itemNote": "یادداشت (مثلاً بدون گوجه، کم‌شکر)",
"addMoreItems": "افزودن آیتم دیگر",
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
@@ -1014,7 +1067,10 @@
"Cancelled": "لغو شده",
"Seated": "نشسته",
"Completed": "انجام شده"
}
},
"deleted": "رزرو حذف شد",
"deleteConfirmTitle": "حذف رزرو",
"deleteConfirmDesc": "آیا از حذف رزرو «{name}» مطمئن هستید؟"
},
"branchesPage": {
"title": "شعب",
@@ -1093,7 +1149,18 @@
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.",
"payTotal": "پرداخت {total}",
"redirecting": "در حال انتقال به درگاه...",
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید."
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید.",
"queuedNotice": "شما اشتراک فعالی دارید. این خرید در صف قرار می‌گیرد و از {date} آغاز می‌شود."
},
"queued": {
"title": "اشتراک‌های در صف",
"subtitle": "این اشتراک‌ها پس از پایان اشتراک فعلی به‌صورت خودکار فعال می‌شوند.",
"months": "{count} ماه",
"window": "از {from} تا {to}",
"cancel": "لغو",
"cancelled": "اشتراک در صف لغو شد",
"cancelConfirmTitle": "لغو اشتراک در صف",
"cancelConfirmDesc": "اشتراک {plan} که قرار بود از {from} آغاز شود لغو شود؟ اشتراک فعلی شما دست‌نخورده می‌ماند."
}
},
"settings": {
@@ -1442,12 +1509,6 @@
}
}
},
"errors": {
"planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound": "یافت نشد",
"unauthorized": "دسترسی ندارید",
"network": "خطای ارتباط با سرور"
},
"discoverPublic": {
"brand": "میزی",
"title": "کافه‌یاب",
@@ -1552,7 +1613,9 @@
"save": "ذخیره",
"saved": "ذخیره شد",
"saveFailed": "ذخیره ناموفق بود",
"loading": "در حال بارگذاری…"
"loading": "در حال بارگذاری…",
"showOnKoja": "نمایش در کوجا",
"showOnKojaHint": "کافه شما در فهرست عمومی کوجا (koja.meezi.ir) نمایش داده شود. پیش‌فرض روشن است."
},
"discoverProfile": {
"sections": {
@@ -3,24 +3,29 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Plus } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client";
import { Plus, Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
import type { Coupon, CouponType } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export function CouponsScreen() {
const t = useTranslations("coupons");
const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Coupon | null>(null);
const [code, setCode] = useState("");
const [type, setType] = useState<CouponType>("Percentage");
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;
return (
@@ -132,11 +147,34 @@ export function CouponsScreen() {
{t("usage")}: {formatNumber(c.usedCount)}
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
</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>
</Card>
))}
</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>
);
}
+49 -12
View File
@@ -1,23 +1,27 @@
"use client";
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 { Plus, Pencil, Search } from "lucide-react";
import { apiGet } from "@/lib/api/client";
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { apiDelete, apiGet } from "@/lib/api/client";
import type { Customer } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
export function CrmScreen() {
const t = useTranslations("crm");
const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
@@ -26,6 +30,7 @@ export function CrmScreen() {
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
const { data: customers = [], isLoading } = useQuery({
queryKey: ["customers", cafeId, debouncedSearch],
@@ -46,6 +51,16 @@ export function CrmScreen() {
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;
return (
@@ -104,21 +119,43 @@ export function CrmScreen() {
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="w-full"
onClick={() => openWizard("edit", c)}
>
<Pencil className="me-1 h-3.5 w-3.5" />
{tCommon("edit")}
</Button>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => openWizard("edit", c)}
>
<Pencil className="me-1 h-3.5 w-3.5" />
{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>
</Card>
))}
</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
open={wizardOpen}
mode={wizardMode}
@@ -4,6 +4,8 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -26,6 +28,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const qc = useQueryClient();
const apiError = useApiError();
const [done, setDone] = useState(false);
const [summary, setSummary] = useState<DemoSeedResult | null>(null);
@@ -39,6 +42,9 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
qc.invalidateQueries({ queryKey: key });
}
},
onError: (err) => {
notify.error(apiError(err));
},
});
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
@@ -9,6 +9,7 @@ import {
updateCafePublicProfile,
uploadGalleryPhoto,
type CafeProfileEdit,
type UpdateCafeProfilePayload,
} from "@/lib/api/cafe-public-profile";
import type { WorkingHours } from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client";
@@ -42,6 +43,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
const [instagram, setInstagram] = useState<string>("");
const [website, setWebsite] = useState<string>("");
const [hours, setHours] = useState<WorkingHours>(emptyHours());
const [showOnKoja, setShowOnKoja] = useState(true);
const [initialized, setInitialized] = useState(false);
// Populate local state once we get server data
@@ -50,17 +52,20 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
setInstagram(profile.instagramHandle ?? "");
setWebsite(profile.websiteUrl ?? "");
setHours(profile.workingHours ?? emptyHours());
setShowOnKoja(profile.showOnKoja ?? true);
setInitialized(true);
}
// ── Save info/social/hours ────────────────────────────────────────────────
const saveMutation = useMutation({
mutationFn: () =>
mutationFn: (override?: Partial<UpdateCafeProfilePayload>) =>
updateCafePublicProfile(cafeId, {
description,
instagramHandle: instagram || null,
websiteUrl: website || null,
workingHours: hours,
showOnKoja,
...override,
}),
onSuccess: (data) => {
qc.setQueryData(["cafe-public-profile", cafeId], data);
@@ -157,6 +162,23 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
{tab === "info" && (
<Card className="rounded-xl border border-border/80">
<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">
<Label>{t("description")}</Label>
<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]"
/>
</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>
</Card>
)}
@@ -276,7 +298,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
);
})}
</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>
</Card>
)}
@@ -307,7 +329,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
dir="ltr"
/>
</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>
</Card>
)}
@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiGet, apiPost } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
/** Roles that can be created from the dashboard (Owner is created only at signup). */
const ALL_ROLES = ["Manager", "Cashier", "Waiter", "Chef", "Delivery"] as const;
type Role = (typeof ALL_ROLES)[number];
type Branch = { id: string; name: string };
const selectClass =
"h-9 w-full rounded-lg border border-border bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-primary/30";
export function AddEmployeeForm({
cafeId,
canAddManager,
onClose,
}: {
cafeId: string;
canAddManager: boolean;
onClose: () => void;
}) {
const t = useTranslations("hr");
const apiError = useApiError();
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [role, setRole] = useState<Role>("Waiter");
const [branchId, setBranchId] = useState("");
const [baseSalary, setBaseSalary] = useState("");
const [withLogin, setWithLogin] = useState(false);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const { data: branches = [] } = useQuery({
queryKey: ["branches", cafeId],
queryFn: () => apiGet<Branch[]>(`/api/cafes/${cafeId}/branches`),
enabled: !!cafeId,
});
const roles = canAddManager ? ALL_ROLES : ALL_ROLES.filter((r) => r !== "Manager");
const credsValid = !withLogin || (username.trim().length > 0 && password.length >= 8);
const canSubmit = name.trim().length > 0 && phone.trim().length > 0 && credsValid;
const createMut = useMutation({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/employees`, {
name: name.trim(),
phone: phone.trim(),
role,
branchId: branchId || null,
baseSalary: baseSalary ? Number(baseSalary) : null,
username: withLogin ? username.trim() : null,
password: withLogin ? password : null,
}),
onSuccess: () => {
notify.success(t("employeeCreated"));
queryClient.invalidateQueries({ queryKey: ["employees", cafeId] });
onClose();
},
onError: (err) => notify.error(apiError(err)),
});
return (
<div className="rounded-xl border border-border bg-card p-4 shadow-sm">
<h3 className="mb-3 text-base font-semibold">{t("addEmployee")}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<LabeledField label={t("fields.name")} htmlFor="emp-name">
<Input id="emp-name" value={name} onChange={(e) => setName(e.target.value)} dir="rtl" />
</LabeledField>
<LabeledField label={t("fields.phone")} htmlFor="emp-phone">
<Input id="emp-phone" value={phone} onChange={(e) => setPhone(e.target.value)} dir="ltr" placeholder="09xxxxxxxxx" />
</LabeledField>
<LabeledField label={t("fields.role")} htmlFor="emp-role">
<select id="emp-role" className={selectClass} value={role} onChange={(e) => setRole(e.target.value as Role)}>
{roles.map((r) => (
<option key={r} value={r}>
{t(`roles.${r}`)}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("fields.branch")} htmlFor="emp-branch" hint={t("fields.branchOptional")}>
<select id="emp-branch" className={selectClass} value={branchId} onChange={(e) => setBranchId(e.target.value)}>
<option value="">{t("fields.noBranch")}</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>
{b.name}
</option>
))}
</select>
</LabeledField>
<LabeledField label={t("fields.baseSalary")} htmlFor="emp-salary" hint={t("fields.optional")}>
<Input
id="emp-salary"
value={baseSalary}
onChange={(e) => setBaseSalary(e.target.value.replace(/[^\d]/g, ""))}
dir="ltr"
inputMode="numeric"
/>
</LabeledField>
</div>
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" checked={withLogin} onChange={(e) => setWithLogin(e.target.checked)} className="size-4" />
{t("fields.enableLogin")}
</label>
{withLogin && (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<LabeledField label={t("fields.username")} htmlFor="emp-username">
<Input id="emp-username" value={username} onChange={(e) => setUsername(e.target.value)} dir="ltr" autoComplete="off" />
</LabeledField>
<LabeledField label={t("fields.password")} htmlFor="emp-password" hint={t("fields.passwordHint")}>
<Input id="emp-password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} dir="ltr" autoComplete="new-password" />
</LabeledField>
</div>
)}
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={onClose} disabled={createMut.isPending}>
{t("cancel")}
</Button>
<Button size="sm" onClick={() => createMut.mutate()} disabled={!canSubmit || createMut.isPending}>
{t("save")}
</Button>
</div>
</div>
);
}
+50 -3
View File
@@ -13,6 +13,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
import { AddEmployeeForm } from "@/components/hr/add-employee-form";
import { UserPlus } from "lucide-react";
interface Employee {
id: string;
@@ -48,7 +50,7 @@ interface Salary {
isPaid: boolean;
}
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
type Tab = "team" | "attendance" | "leave" | "payroll" | "access" | "credentials";
export function HrScreen() {
const t = useTranslations("hr");
@@ -57,7 +59,8 @@ export function HrScreen() {
const role = useAuthStore((s) => s.user?.role);
const canManageAccess = role === "Owner" || role === "Manager";
const queryClient = useQueryClient();
const [tab, setTab] = useState<Tab>("attendance");
const [tab, setTab] = useState<Tab>("team");
const [addingEmployee, setAddingEmployee] = useState(false);
const [monthYear, setMonthYear] = useState(
new Date().toISOString().slice(0, 7)
);
@@ -123,7 +126,7 @@ export function HrScreen() {
<h2 className="text-xl font-bold">{t("title")}</h2>
<div className="flex flex-wrap gap-2">
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
{((["team", "attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
(key) => (key !== "access" && key !== "credentials") || canManageAccess
)).map((key) => (
<Button
@@ -137,6 +140,50 @@ export function HrScreen() {
))}
</div>
{tab === "team" && (
<div className="space-y-4">
{canManageAccess && (
<div className="flex justify-end">
{addingEmployee ? null : (
<Button size="sm" onClick={() => setAddingEmployee(true)}>
<UserPlus className="me-1.5 size-4" />
{t("addEmployee")}
</Button>
)}
</div>
)}
{addingEmployee && (
<AddEmployeeForm
cafeId={cafeId}
canAddManager={role === "Owner"}
onClose={() => setAddingEmployee(false)}
/>
)}
{employees.length === 0 ? (
<p className="text-muted-foreground">{t("noEmployees")}</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{employees.map((e) => (
<Card key={e.id}>
<CardContent className="space-y-1 pt-4 text-sm">
<div className="flex items-center justify-between gap-2">
<p className="font-medium">{e.name}</p>
<Badge variant="outline">{t(`roles.${e.role}`)}</Badge>
</div>
<p dir="ltr" className="text-end font-mono text-xs text-muted-foreground">{e.phone}</p>
{e.baseSalary > 0 && (
<p className="text-xs text-muted-foreground">{formatCurrency(e.baseSalary)}</p>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
)}
{tab === "attendance" && (
<div className="space-y-4">
<Card>
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
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 { 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 { useAuthStore } from "@/lib/stores/auth.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 { cn } from "@/lib/utils";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
type Ingredient = {
id: string;
@@ -67,6 +69,7 @@ type PurchasesSummary = {
export function InventoryScreen() {
const t = useTranslations("inventory");
const tCommon = useTranslations("common");
const apiError = useApiError();
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId);
@@ -95,6 +98,7 @@ export function InventoryScreen() {
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Ingredient | null>(null);
const [editName, setEditName] = useState("");
const [editUnit, setEditUnit] = useState("گرم");
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({
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
@@ -478,6 +493,16 @@ export function InventoryScreen() {
>
<Pencil className="size-4" />
</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>
<p className="text-sm font-medium text-[#0F6E56]">
@@ -661,6 +686,17 @@ export function InventoryScreen() {
) : null}
</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>
);
}
@@ -178,6 +178,11 @@ export function KdsScreen() {
<li key={item.id}>
{formatNumber(item.quantity, numberLocale)}×{" "}
{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>
))}
</ul>
@@ -193,6 +193,8 @@ export function BranchMenuOverrides({
<button
type="button"
role="switch"
// Force LTR so the knob's translate-x stays inside the track in RTL.
dir="ltr"
aria-checked={row.isAvailable}
className={cn(
"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 { useLocale, useTranslations } from "next-intl";
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 { Menu3dUpload } from "@/components/media/menu-3d-upload";
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 type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
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 { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format";
@@ -126,6 +137,9 @@ function ToggleSwitch({
aria-checked={checked}
aria-label={label}
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)}
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",
@@ -184,11 +198,8 @@ function Modal({
export function MenuAdminScreen() {
const t = useTranslations("menuAdmin");
const tCommon = useTranslations("common");
const tNotify = useTranslations("notify");
const showError = (err: unknown) =>
notify.error(
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
);
const apiError = useApiError();
const showError = (err: unknown) => notify.error(apiError(err));
const isRtl = useIsRtl();
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
@@ -211,6 +222,11 @@ export function MenuAdminScreen() {
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
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 ───────────────────────────────────────────────────────────
const { data: categories = [] } = useQuery({
queryKey: ["menu-categories", cafeId],
@@ -301,6 +317,30 @@ export function MenuAdminScreen() {
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({
mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
@@ -893,20 +933,38 @@ export function MenuAdminScreen() {
</LabeledField>
{/* Actions */}
<div className="flex justify-end gap-2 border-t border-border pt-4">
<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 className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingItem ? (
<Button
type="button"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() =>
setConfirmDelete({
kind: "item",
id: editingItem.id,
name: editingItem.name,
})
}
>
<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>
</Modal>
@@ -941,20 +999,84 @@ export function MenuAdminScreen() {
}
/>
<div className="flex justify-end gap-2 border-t border-border pt-4">
<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 className="flex items-center justify-between gap-2 border-t border-border pt-4">
{editingCategory ? (
<Button
type="button"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() =>
setConfirmDelete({
kind: "category",
id: editingCategory.id,
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>
</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>
);
}
@@ -255,6 +255,7 @@ export function PosScreen() {
addItem,
removeItem,
updateQty,
setNotes,
couponCode,
appliedCoupon,
setCouponCode,
@@ -1210,10 +1211,11 @@ export function PosScreen() {
<div
key={line.orderItemId ?? line.menuItem.id}
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"
)}
>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<MenuItemLabels
item={line.menuItem}
@@ -1291,6 +1293,18 @@ export function PosScreen() {
</>
) : null}
</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>
))
)}
+28 -2
View File
@@ -1,20 +1,46 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { useEffect, useState } from "react";
import { ConfirmProvider } from "@/components/providers/confirm-provider";
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 }) {
const [queryClient] = useState(
() =>
new QueryClient({
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 (
<QueryClientProvider client={queryClient}>
<ConfirmProvider>
@@ -407,29 +407,44 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
{cart.map((c) => (
<div
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">
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
<p className="text-sm font-medium" style={{ color: primary }}>
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
</p>
</div>
<div className="flex items-center gap-2">
<QtyButton
label=""
onClick={() => removeFromCart(c.item.id)}
variant="outline"
color={primary}
/>
<span className="min-w-6 text-center font-semibold">{c.qty}</span>
<QtyButton
label="+"
onClick={() => addToCart(c.item)}
variant="filled"
color={primary}
/>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
<p className="text-sm font-medium" style={{ color: primary }}>
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
</p>
</div>
<div className="flex items-center gap-2">
<QtyButton
label=""
onClick={() => removeFromCart(c.item.id)}
variant="outline"
color={primary}
/>
<span className="min-w-6 text-center font-semibold">{c.qty}</span>
<QtyButton
label="+"
onClick={() => addToCart(c.item)}
variant="filled"
color={primary}
/>
</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>
@@ -4,7 +4,11 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
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 { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button";
@@ -45,8 +49,11 @@ const statusStyle: Record<ReservationStatus, string> = {
export function ReservationsScreen() {
const t = useTranslations("reservations");
const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<Reservation | null>(null);
const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("09121234567");
@@ -92,6 +99,16 @@ export function ReservationsScreen() {
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;
const posHref = (r: Reservation) => {
@@ -245,6 +262,15 @@ export function ReservationsScreen() {
{t("markCompleted")}
</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>
</CardContent>
</Card>
@@ -252,6 +278,19 @@ export function ReservationsScreen() {
))}
</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>
);
}
@@ -366,6 +366,26 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
>
ذخیره موقعیت
</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) && (
<Button
variant="ghost"
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useApiError } from "@/lib/use-api-error";
import { useRouter } from "@/i18n/routing";
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client";
@@ -34,6 +35,7 @@ export function CheckoutScreen() {
const t = useTranslations("subscription");
const tc = useTranslations("subscription.checkout");
const tPlans = useTranslations("settings.plans");
const apiError = useApiError();
const searchParams = useSearchParams();
const router = useRouter();
const user = useAuthStore((s) => s.user);
@@ -66,6 +68,37 @@ export function CheckoutScreen() {
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(() => {
if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
@@ -81,8 +114,7 @@ export function CheckoutScreen() {
window.location.href = data.paymentUrl;
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setPayError(msg || tc("paymentFailed"));
setPayError(apiError(err, 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 */}
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
{/* Invoice header */}
@@ -2,10 +2,11 @@
import { useEffect, useRef, useState } from "react";
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 { CalendarClock, Trash2 } from "lucide-react";
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 { useAuthStore } from "@/lib/stores/auth.store";
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 { PageHeader } from "@/components/layout/page-header";
import { PlanComparison } from "@/components/settings/plan-comparison";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import type { AuthTokenResponse } from "@/lib/api/types";
import { Alert } from "@/components/ui/alert";
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 = {
planTier: string;
@@ -30,6 +42,7 @@ type BillingStatus = {
menu3dEnabled: boolean;
discoverProfileEnabled: boolean;
isPlanExpired: boolean;
queuedPlans: QueuedPlan[];
};
export function SubscriptionScreen() {
@@ -40,8 +53,11 @@ export function SubscriptionScreen() {
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const setAuth = useAuthStore((s) => s.setAuth);
const apiError = useApiError();
const queryClient = useQueryClient();
const billingRefreshed = useRef(false);
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
const [cancelTarget, setCancelTarget] = useState<QueuedPlan | null>(null);
useEffect(() => {
const billing = searchParams.get("billing");
@@ -61,6 +77,18 @@ export function SubscriptionScreen() {
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(() => {
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
const refresh = localStorage.getItem("meezi_refresh_token");
@@ -155,12 +183,72 @@ export function SubscriptionScreen() {
</CardContent>
</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
currentPlan={status?.planTier ?? "Free"}
onSubscribe={(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>
);
}
@@ -6,6 +6,7 @@ import { useState } from "react";
import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -52,6 +53,7 @@ function formatDate(iso: string) {
export function SupportScreen() {
const t = useTranslations("support");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId);
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
@@ -61,6 +63,7 @@ export function SupportScreen() {
data: tickets = [],
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: ["support", cafeId],
@@ -135,7 +138,7 @@ export function SupportScreen() {
</p>
{isError ? (
<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()}>
{t("retry")}
</Button>
@@ -7,6 +7,7 @@ import * as signalR from "@microsoft/signalr";
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { PageHeader } from "@/components/layout/page-header";
import {
@@ -53,6 +54,7 @@ export function TablesScreen() {
const branchId = useBranchStore((s) => s.branchId);
const queryClient = useQueryClient();
const confirmDialog = useConfirm();
const apiError = useApiError();
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [number, setNumber] = useState("");
@@ -123,7 +125,7 @@ export function TablesScreen() {
refresh();
},
onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError");
const msg = apiError(err, t("createError"));
setActionMessage(msg);
notify.error(msg);
},
@@ -142,7 +144,7 @@ export function TablesScreen() {
refresh();
},
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"));
return;
}
setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError"));
setActionMessage(apiError(err, t("deleteError")));
},
});
@@ -188,7 +190,7 @@ export function TablesScreen() {
refresh();
},
onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError");
const msg = apiError(err, t("createError"));
setActionMessage(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({
locales: ["fa", "ar", "en"],
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 } =
@@ -8,6 +8,7 @@ export type CafeProfileEdit = {
instagramHandle: string | null;
websiteUrl: string | null;
workingHours: WorkingHours | null;
showOnKoja: boolean;
};
export type UpdateCafeProfilePayload = {
@@ -15,6 +16,7 @@ export type UpdateCafeProfilePayload = {
instagramHandle?: string | null;
websiteUrl?: string | null;
workingHours?: WorkingHours | null;
showOnKoja?: boolean;
};
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 { getOrCreateTerminalId } from "@/lib/terminal";
import { useAuthStore } from "@/lib/stores/auth.store";
import {
isNetworkError,
isOnlineOnly,
newIdempotencyKey,
OfflineUnavailableError,
queueWrite,
} from "@/lib/offline/offline-write";
const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
@@ -79,7 +86,7 @@ api.interceptors.response.use(
const apiError = error.response?.data?.error;
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") {
const path = window.location.pathname;
@@ -131,46 +138,110 @@ export class ApiClientError extends Error {
public readonly code: string,
message: string,
/** 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);
this.name = "ApiClientError";
}
}
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.post<ApiResponse<T>>(url, body);
/** Options for mutating requests. */
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) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
throw new ApiClientError(
data.error?.code ?? "REQUEST_FAILED",
data.error?.message ?? "Request failed",
data.data
);
}
return data.data;
}
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
const { data } = await api.put<ApiResponse<T>>(url, body);
if (!data.success || data.data === undefined) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
async function doWrite<T>(
method: "POST" | "PUT" | "PATCH" | "DELETE",
url: string,
body: unknown,
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> {
const { data } = await api.patch<ApiResponse<T>>(url, body);
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 apiPost<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
return doWrite<T>("POST", url, body, opts);
}
export async function apiDelete(url: string): Promise<void> {
const { data } = await api.delete<ApiResponse<unknown>>(url);
if (!data.success) {
const code = data.error?.code ?? "REQUEST_FAILED";
throw new ApiClientError(code, data.error?.message ?? "Request failed");
}
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
return doWrite<T>("PUT", url, body, opts);
}
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. */
+4 -1
View File
@@ -46,7 +46,10 @@ export const notify = {
};
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;
return fallback;
}
+168 -1
View File
@@ -22,8 +22,13 @@ export type OfflineQueueItem = {
};
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";
/** 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;
@@ -36,6 +41,12 @@ function openDb(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(STORE)) {
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 = () => {
_db = req.result;
@@ -109,3 +120,159 @@ export async function markQueueItemFailed(id: string): Promise<void> {
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";
import { useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
import {
enqueueOutboxOp,
getAllQueueItems,
getOutboxCount,
getQueueCount,
removeQueueItem,
markQueueItemFailed,
} 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 {
if (item.type === "create_order") {
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;
legacy = await getAllQueueItems();
} 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:
* - Load initial queue count from IndexedDB on mount
* - Listen to online/offline events
* - Auto-sync when back online or tab becomes visible
* Mount once in the app shell to:
* - migrate any legacy queued orders into the outbox,
* - keep the pending-count badge and online flag in sync,
* - drain the outbox when back online or the tab regains focus,
* - refresh server data once writes have synced.
*/
export function useOfflineSync() {
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
const queryClient = useQueryClient();
const syncLock = useRef(false);
const refreshCount = useCallback(async () => {
const n = await getQueueCount();
const n = (await getOutboxCount()) + (await getQueueCount());
setQueueCount(n);
return n;
}, [setQueueCount]);
const syncQueue = useCallback(async () => {
if (syncLock.current) return;
if (!navigator.onLine) return;
const count = await refreshCount();
if (count === 0) return;
if (typeof navigator !== "undefined" && !navigator.onLine) return;
syncLock.current = true;
setSyncing(true);
try {
const items = await getAllQueueItems();
for (const item of items) {
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3
const ok = await processItem(item);
if (ok) {
await removeQueueItem(item.id);
} else {
await markQueueItemFailed(item.id);
}
const result = await drainOutbox();
if (result.sent > 0) {
// Replace optimistic local data with the authoritative server state.
await queryClient.invalidateQueries();
}
} finally {
syncLock.current = false;
setSyncing(false);
await refreshCount();
}
}, [refreshCount, setSyncing]);
}, [refreshCount, setSyncing, queryClient]);
useEffect(() => {
// Load initial count
void refreshCount();
// Ask the browser to keep our IndexedDB (outbox + cache) from being evicted
// 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 = () => {
setOnline(true);
void syncQueue();
@@ -92,7 +130,6 @@ export function useOfflineSync() {
window.addEventListener("online", handleOnline);
window.addEventListener("offline", handleOffline);
// Sync when tab regains focus
const handleVisibility = () => {
if (document.visibilityState === "visible" && navigator.onLine) {
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 { CartItem } from "@/lib/stores/cart.store";
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";
export type SubmitOrderCart = {
@@ -24,32 +25,32 @@ export type SubmitOrderParams = {
cartItems?: CartItem[];
};
// ─── Offline helpers ──────────────────────────────────────────────────────────
// ─── Helpers ────────────────────────────────────────────────────────────────
// isNetworkError / newLocalId / newIdempotencyKey are shared from offline-write.
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")
);
}
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(
/** Body for a create-order POST. */
function buildCreateBody(
params: SubmitOrderParams,
cartItems: CartItem[]
): Order {
pending: ReturnType<SubmitOrderCart["getPendingLines"]>
) {
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 localId = newLocalId();
const items: OrderItemLine[] = pending.map((p) => {
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
@@ -69,7 +70,7 @@ function buildLocalOrder(
const total = subtotal + taxTotal;
return {
id: localId,
id: orderId,
cafeId: params.cafeId,
branchId: params.orderBranchId,
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(
params: SubmitOrderParams,
cartItems: CartItem[]
cartItems: CartItem[],
idempotencyKey: string
): Promise<Order> {
const pending = params.cart.getPendingLines();
const { cafeId, cart } = params;
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const isAddToExisting =
!!params.cart.activeOrderId &&
!params.cart.activeOrderId.startsWith("local_");
const activeId = cart.activeOrderId;
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(),
type: isAddToExisting ? "add_items" : "create_order",
cafeId: params.cafeId,
targetOrderId: isAddToExisting ? params.cart.activeOrderId : null,
payload: isAddToExisting
? {
cafeId: params.cafeId,
orderId: params.cart.activeOrderId!,
body: { items: pending },
}
: {
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(),
idempotencyKey,
method: "POST",
url: `/api/cafes/${cafeId}/orders`,
body: buildCreateBody(params, pending),
entityType: "order",
createsClientId: localOrderId,
idField: "id",
createdAt: Date.now(),
});
// Update global queue count
const count = await getQueueCount();
useSyncQueueStore.getState().setQueueCount(count);
return buildLocalOrder(params, cartItems);
await refreshQueueBadge();
return buildLocalOrder(params, cartItems, localOrderId);
}
// ─── Main export ──────────────────────────────────────────────────────────────
@@ -145,47 +154,45 @@ export async function submitOrderToApi({
reservationId,
cartItems = [],
}: SubmitOrderParams): Promise<Order> {
const params: SubmitOrderParams = { cafeId, orderBranchId, cart, reservationId, cartItems };
const pending = cart.getPendingLines();
if (pending.length === 0) throw new Error("nothing pending");
const tryOnline = async (): Promise<Order> => {
if (cart.activeOrderId && !cart.activeOrderId.startsWith("local_")) {
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,
});
};
const idempotencyKey = newIdempotencyKey();
const addingToLocalOrder = isLocalOrder(cart.activeOrderId);
// Try online first
if (navigator.onLine) {
// Fast path: online, and either a new order or adding to a real server order.
// (Adding to a still-local order must be queued so the outbox can remap its id.)
if (typeof navigator !== "undefined" && navigator.onLine && !addingToLocalOrder) {
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) {
// 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;
}
}
// Offline path: queue and return a local mock order
return queueAndBuildLocalOrder({ cafeId, orderBranchId, cart, reservationId, cartItems }, cartItems);
return queueAndBuildLocalOrder(params, cartItems, idempotencyKey);
}
export function orderAmountDue(order: Order): number {
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 {
return !!orderId?.startsWith("local_");
}
@@ -34,6 +34,7 @@ interface CartState {
addItem: (item: MenuItem) => void;
removeItem: (menuItemId: string) => void;
updateQty: (menuItemId: string, quantity: number) => void;
setNotes: (menuItemId: string, notes: string) => void;
setCouponCode: (code: string) => void;
setAppliedCoupon: (coupon: AppliedCoupon | null) => 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 }),
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
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: "**" },
],
},
async redirects() {
return [
// Short URL: koja.meezi.ir/my-cafe → koja.meezi.ir/fa/cafe/my-cafe
{
source: "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])",
destination: "/fa/cafe/:slug",
permanent: false,
},
];
},
// NOTE: the previous "short URL" redirect (/:slug → /fa/cafe/:slug) matched
// single-segment paths INCLUDING the locale itself, so "/fa" redirected to
// "/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
// exclusions if short café URLs are needed.
};
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 isFa = locale === "fa";
const [cafe, menu, reviews] = await Promise.all([
getCafe(slug),
// Resolve the café first so an unknown slug 404s cleanly instead of doing
// (and potentially erroring on) the menu/review fetches.
const cafe = await getCafe(slug);
if (!cafe) notFound();
const [menu, reviews] = await Promise.all([
getCafeMenu(slug),
getCafeReviews(slug),
]);
if (!cafe) notFound();
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;
// Similar cafes
+4 -3
View File
@@ -11,7 +11,8 @@ interface Props {
export function CafeCard({ cafe, locale, href }: Props) {
const isFa = locale === "fa";
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;
return (
@@ -72,9 +73,9 @@ export function CafeCard({ cafe, locale, href }: Props) {
)}
{/* Tags */}
{cafe.discoverProfile.themes.length > 0 && (
{themes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{cafe.discoverProfile.themes.slice(0, 3).map((tag) => (
{themes.slice(0, 3).map((tag) => (
<span
key={tag}
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",
},
} : {}),
...(cafe.discoverProfile.themes.length ? {
...(cafe.discoverProfile?.themes?.length ? {
servesCuisine: cafe.discoverProfile.themes,
} : {}),
priceRange: (() => {
const tier = cafe.discoverProfile.priceTier;
const tier = cafe.discoverProfile?.priceTier;
if (tier === "budget") return "﷼";
if (tier === "moderate") return "﷼﷼";
if (tier === "upscale") return "﷼﷼﷼";

Some files were not shown because too many files have changed in this diff Show More