Compare commits

..

20 Commits

Author SHA1 Message Date
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
64 changed files with 8062 additions and 191 deletions
+4 -1
View File
@@ -198,7 +198,10 @@ public class AuthController : ControllerBase
ExpiresAt: expiresAt, ExpiresAt: expiresAt,
UserId: userId, UserId: userId,
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty, CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty, // .NET remaps the short "role" claim to ClaimTypes.Role on inbound; read both.
Role: User.FindFirstValue(MeeziClaimTypes.Role)
?? User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)
?? string.Empty,
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty, PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty, Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant, Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
return Ok(new ApiResponse<BillingStatusDto>(true, data)); return Ok(new ApiResponse<BillingStatusDto>(true, data));
} }
[Authorize]
[HttpDelete("api/billing/queued/{paymentId}")]
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
{
if (string.IsNullOrEmpty(tenant.CafeId))
return Unauthorized();
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
if (!ok)
{
return code == "NOT_FOUND"
? NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found.")))
: BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
}
return Ok(new ApiResponse<object>(true, new { id = paymentId }));
}
} }
@@ -57,7 +57,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery, gallery,
cafe.InstagramHandle, cafe.InstagramHandle,
cafe.WebsiteUrl, cafe.WebsiteUrl,
ToHoursDto(hours)))); ToHoursDto(hours),
cafe.ShowOnKoja)));
} }
// ── PUT (description / social / hours) ─────────────────────────────────── // ── PUT (description / social / hours) ───────────────────────────────────
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
if (request.WorkingHours is not null) if (request.WorkingHours is not null)
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts); cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
// Koja (public discovery) listing preference
if (request.ShowOnKoja.HasValue)
cafe.ShowOnKoja = request.ShowOnKoja.Value;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? []; var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
gallery, gallery,
cafe.InstagramHandle, cafe.InstagramHandle,
cafe.WebsiteUrl, cafe.WebsiteUrl,
ToHoursDto(hours)))); ToHoursDto(hours),
cafe.ShowOnKoja)));
} }
// ── POST gallery/upload ─────────────────────────────────────────────────── // ── POST gallery/upload ───────────────────────────────────────────────────
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
string? Description, string? Description,
string? InstagramHandle, string? InstagramHandle,
string? WebsiteUrl, string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours); WorkingHoursPublicDto? WorkingHours,
bool? ShowOnKoja = null);
public record CafeProfileEditDto( public record CafeProfileEditDto(
string? Description, string? Description,
IReadOnlyList<string> GalleryUrls, IReadOnlyList<string> GalleryUrls,
string? InstagramHandle, string? InstagramHandle,
string? WebsiteUrl, string? WebsiteUrl,
WorkingHoursPublicDto? WorkingHours); WorkingHoursPublicDto? WorkingHours,
bool ShowOnKoja);
public record GalleryDto(IReadOnlyList<string> GalleryUrls); public record GalleryDto(IReadOnlyList<string> GalleryUrls);
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied; // Demo data is a setup helper; Owner or Manager may run it (matches the
// dashboard banner, which is shown to both roles).
if (EnsureManager(tenant) is { } managerDenied) return managerDenied;
var result = await _demoSeed.SeedAsync(cafeId, ct); var result = await _demoSeed.SeedAsync(cafeId, ct);
return Ok(new ApiResponse<DemoSeedResult>(true, result)); return Ok(new ApiResponse<DemoSeedResult>(true, result));
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
return Ok(new ApiResponse<object>(true, updated)); return Ok(new ApiResponse<object>(true, updated));
} }
[HttpDelete("ingredients/{ingredientId}")]
public async Task<IActionResult> Delete(
string cafeId,
string ingredientId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
}
[HttpPost("ingredients/{ingredientId}/adjust")] [HttpPost("ingredients/{ingredientId}/adjust")]
public async Task<IActionResult> Adjust( public async Task<IActionResult> Adjust(
string cafeId, string cafeId,
@@ -163,6 +163,15 @@ public class MenuController : CafeApiControllerBase
return Ok(new ApiResponse<MenuItemDto>(true, data)); return Ok(new ApiResponse<MenuItemDto>(true, data));
} }
[HttpDelete("items/{id}")]
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
[HttpGet("ai-3d/usage")] [HttpGet("ai-3d/usage")]
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
{ {
@@ -66,6 +66,19 @@ public class ReservationsController : CafeApiControllerBase
if (data is null) return NotFoundError(); if (data is null) return NotFoundError();
return Ok(new ApiResponse<ReservationDto>(true, data)); return Ok(new ApiResponse<ReservationDto>(true, data));
} }
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(
string cafeId,
string id,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
if (!deleted) return NotFoundError();
return Ok(new ApiResponse<object>(true, new { id }));
}
} }
public record UpdateReservationStatusRequest(ReservationStatus Status); public record UpdateReservationStatusRequest(ReservationStatus Status);
+6 -1
View File
@@ -92,7 +92,12 @@ public class TenantMiddleware
{ {
scopedMerchant.CafeId = cafeId; scopedMerchant.CafeId = cafeId;
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value; // .NET's JWT handler remaps the short "role" claim to ClaimTypes.Role
// on inbound, so FindFirst("role") returns null and tenant.Role would
// stay null — making EnsureManager/EnsureOwner reject even a real owner.
// Read both the raw claim and the mapped one.
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role)) if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
scopedMerchant.Role = role; scopedMerchant.Role = role;
+10 -1
View File
@@ -22,6 +22,15 @@ public record BillingStatusDto(
int MenuAi3dUsedThisMonth, int MenuAi3dUsedThisMonth,
int MenuAi3dMonthlyLimit, int MenuAi3dMonthlyLimit,
bool DiscoverProfileEnabled, bool DiscoverProfileEnabled,
bool IsPlanExpired); bool IsPlanExpired,
IReadOnlyList<QueuedPlanDto> QueuedPlans);
public record QueuedPlanDto(
string PaymentId,
PlanTier PlanTier,
int Months,
DateTime EffectiveFrom,
DateTime EffectiveTo,
decimal AmountToman);
public record BillingVerifyResult(bool Success, string RedirectUrl); public record BillingVerifyResult(bool Success, string RedirectUrl);
+14 -4
View File
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
if (employee?.Cafe is null) if (employee?.Cafe is null)
return (false, null, "NOT_FOUND", "User no longer exists."); return (false, null, "NOT_FOUND", "User no longer exists.");
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken); // Note: we intentionally do NOT revoke the presented refresh token here.
// It is reused (with a slid TTL) so concurrent refreshes from multiple
// tabs/devices stay valid instead of racing each other into a logout.
var allMemberships = await _db.Employees var allMemberships = await _db.Employees
.Include(e => e.Cafe) .Include(e => e.Cafe)
@@ -265,7 +267,9 @@ public class AuthService : IAuthService
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString())) .Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList(); .ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken); var tokens = await IssueTokensAsync(
employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken,
existingRefreshToken: request.RefreshToken);
return (true, tokens, null, null); return (true, tokens, null, null);
} }
@@ -510,12 +514,18 @@ public class AuthService : IAuthService
Core.Entities.Cafe cafe, Core.Entities.Cafe cafe,
List<CafeMembershipDto>? memberships, List<CafeMembershipDto>? memberships,
string? requestedBranchId, string? requestedBranchId,
CancellationToken cancellationToken) CancellationToken cancellationToken,
string? existingRefreshToken = null)
{ {
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken); var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId); var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
var refreshToken = _jwtTokenService.CreateRefreshToken(); // On refresh, reuse the caller's refresh token (and slide its TTL below) instead
// of minting a new one. A café often runs POS + KDS + queue display at once; if
// refresh rotated the token, the first refresh would revoke it and every other
// concurrent refresh would get INVALID_TOKEN → forced logout → OTP storm.
// Mint a fresh token only on a real login (existingRefreshToken == null).
var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken();
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30); var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
await _refreshTokenStore.StoreAsync( await _refreshTokenStore.StoreAsync(
+148 -10
View File
@@ -35,6 +35,11 @@ public interface IBillingService
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default); Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
string cafeId,
string paymentId,
CancellationToken cancellationToken = default);
} }
public class BillingService : IBillingService public class BillingService : IBillingService
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
return new BillingVerifyResult(false, failUrl); return new BillingVerifyResult(false, failUrl);
} }
payment.Status = SubscriptionPaymentStatus.Completed;
payment.RefId = verify.RefId; payment.RefId = verify.RefId;
var cafe = payment.Cafe; var cafe = payment.Cafe;
cafe.PlanTier = payment.PlanTier; var now = DateTime.UtcNow;
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
? cafe.PlanExpiresAt.Value // Where does the current paid coverage end? = the latest of the active plan's expiry
: DateTime.UtcNow; // and the furthest-out already-queued period. A new purchase is appended to that.
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months); var coverageEnd = await ComputeCoverageEndAsync(cafe, payment.Id, now, cancellationToken);
payment.EffectiveFrom = coverageEnd;
payment.EffectiveTo = coverageEnd.AddMonths(payment.Months);
var queued = coverageEnd > now;
if (queued)
{
// The owner already has active/queued coverage → book this one after it.
payment.Status = SubscriptionPaymentStatus.Scheduled;
}
else
{
// No active coverage → activate immediately.
payment.Status = SubscriptionPaymentStatus.Completed;
cafe.PlanTier = payment.PlanTier;
cafe.PlanExpiresAt = payment.EffectiveTo;
}
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken); await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
return new BillingVerifyResult(true, successUrl); return new BillingVerifyResult(true, successUrl);
} }
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
/// and the furthest-out scheduled (queued) period. Returns <paramref name="now"/> if neither
/// extends past now (i.e. nothing active/queued).</summary>
private async Task<DateTime> ComputeCoverageEndAsync(
Cafe cafe, string? excludePaymentId, DateTime now, CancellationToken ct)
{
var end = now;
if (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > end)
end = cafe.PlanExpiresAt.Value;
var lastScheduledEnd = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafe.Id
&& p.Status == SubscriptionPaymentStatus.Scheduled
&& (excludePaymentId == null || p.Id != excludePaymentId)
&& p.EffectiveTo != null)
.OrderByDescending(p => p.EffectiveTo)
.Select(p => p.EffectiveTo)
.FirstOrDefaultAsync(ct);
if (lastScheduledEnd.HasValue && lastScheduledEnd.Value > end)
end = lastScheduledEnd.Value;
return end;
}
/// <summary>When the active plan has lapsed, promote due queued periods to active.
/// Loops so a fully-elapsed short queued period doesn't strand the next one.</summary>
private async Task PromoteDueScheduledAsync(string cafeId, CancellationToken ct)
{
var now = DateTime.UtcNow;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return;
var changed = false;
while (!(cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now))
{
var next = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafeId
&& p.Status == SubscriptionPaymentStatus.Scheduled
&& p.EffectiveFrom != null && p.EffectiveFrom <= now)
.OrderBy(p => p.EffectiveFrom)
.FirstOrDefaultAsync(ct);
if (next is null) break;
cafe.PlanTier = next.PlanTier;
cafe.PlanExpiresAt = next.EffectiveTo;
next.Status = SubscriptionPaymentStatus.Completed;
changed = true;
}
if (changed) await _db.SaveChangesAsync(ct);
}
public async Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
string cafeId,
string paymentId,
CancellationToken cancellationToken = default)
{
var payment = await _db.SubscriptionPayments
.FirstOrDefaultAsync(p => p.Id == paymentId && p.CafeId == cafeId, cancellationToken);
if (payment is null)
return (false, "NOT_FOUND", "Subscription not found.");
// Only a queued (not-yet-started) subscription can be cancelled. The active prepaid
// plan keeps running until its paid time ends.
if (payment.Status != SubscriptionPaymentStatus.Scheduled)
return (false, "NOT_CANCELLABLE", "Only a queued subscription can be cancelled.");
payment.Status = SubscriptionPaymentStatus.Cancelled;
await _db.SaveChangesAsync(cancellationToken);
// Re-pack the remaining queue so later periods slide earlier to fill the gap.
await RecomputeQueueAsync(cafeId, cancellationToken);
return (true, null, null);
}
/// <summary>Re-sequences the remaining queued periods contiguously after the active plan
/// (purchase order preserved), so cancelling one in the middle doesn't leave a gap.</summary>
private async Task RecomputeQueueAsync(string cafeId, CancellationToken ct)
{
var now = DateTime.UtcNow;
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
if (cafe is null) return;
var anchor = (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now)
? cafe.PlanExpiresAt.Value
: now;
var scheduled = await _db.SubscriptionPayments
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled)
.OrderBy(p => p.CreatedAt)
.ToListAsync(ct);
foreach (var s in scheduled)
{
s.EffectiveFrom = anchor;
s.EffectiveTo = anchor.AddMonths(s.Months);
anchor = s.EffectiveTo.Value;
}
if (scheduled.Count > 0) await _db.SaveChangesAsync(ct);
}
public async Task<BillingStatusDto?> GetStatusAsync( public async Task<BillingStatusDto?> GetStatusAsync(
string cafeId, string cafeId,
PlanTier currentTier, PlanTier currentTier,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// Lazily activate any queued plan whose start date has passed before reading status.
await PromoteDueScheduledAsync(cafeId, cancellationToken);
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken); var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
if (cafe is null) return null; if (cafe is null) return null;
var queuedPlans = await _db.SubscriptionPayments.AsNoTracking()
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled
&& p.EffectiveFrom != null && p.EffectiveTo != null)
.OrderBy(p => p.EffectiveFrom)
.Select(p => new QueuedPlanDto(
p.Id, p.PlanTier, p.Months, p.EffectiveFrom!.Value, p.EffectiveTo!.Value, p.AmountToman))
.ToListAsync(cancellationToken);
var todayStart = DateTime.UtcNow.Date; var todayStart = DateTime.UtcNow.Date;
var ordersToday = await _db.Orders.CountAsync( var ordersToday = await _db.Orders.CountAsync(
o => o.CafeId == cafeId && o.CreatedAt >= todayStart, o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
@@ -278,12 +413,14 @@ public class BillingService : IBillingService
ai3dUsedCount, ai3dUsedCount,
ai3dLimit, ai3dLimit,
discoverProfile, discoverProfile,
isExpired); isExpired,
queuedPlans);
} }
private async Task TrySendConfirmationSmsAsync( private async Task TrySendConfirmationSmsAsync(
Cafe cafe, Cafe cafe,
SubscriptionPayment payment, SubscriptionPayment payment,
bool queued,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var ownerPhone = await _db.Employees var ownerPhone = await _db.Employees
@@ -293,8 +430,9 @@ public class BillingService : IBillingService
if (string.IsNullOrEmpty(ownerPhone)) return; if (string.IsNullOrEmpty(ownerPhone)) return;
var message = var message = queued
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت"; ? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز می‌شود. مبلغ: {payment.AmountToman:N0} ت"
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
try try
{ {
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken); await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
+7 -2
View File
@@ -130,7 +130,10 @@ public class DemoSeedService : IDemoSeedService
decimal qty, decimal reorder, decimal cost, decimal par) => decimal qty, decimal reorder, decimal cost, decimal par) =>
new() new()
{ {
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36], // No [..36] truncation: Id is a text column, and truncating to 36 chars
// cuts off the unique guid for real (32-char) café ids → every row gets
// the same id → PK collision → 500. Keep the full unique id.
Id = $"{cafeId}_ing_{Guid.NewGuid():N}",
CafeId = cafeId, CafeId = cafeId,
Name = name, Name = name,
Unit = unit, Unit = unit,
@@ -160,7 +163,9 @@ public class DemoSeedService : IDemoSeedService
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) => string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
new() new()
{ {
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36], // No [..36] truncation (see Ingredient above): truncating cuts the guid
// for real 32-char café ids → identical ids → PK collision → 500.
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}",
CafeId = cafeId, CafeId = cafeId,
BranchId = branchId, BranchId = branchId,
Number = number, Number = number,
@@ -89,6 +89,7 @@ public interface IInventoryService
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default); Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default); Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default); Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
Task<IngredientDto?> AdjustAsync( Task<IngredientDto?> AdjustAsync(
string cafeId, string cafeId,
string ingredientId, string ingredientId,
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
return ToDto(entity); return ToDto(entity);
} }
public async Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default)
{
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
if (entity is null) return false;
// Soft delete: Ingredient has a global DeletedAt query filter, so it (and its
// recipe lines / stock movements) drop out of every query without FK trouble.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return true;
}
public async Task<IngredientDto?> AdjustAsync( public async Task<IngredientDto?> AdjustAsync(
string cafeId, string cafeId,
string ingredientId, string ingredientId,
+11
View File
@@ -16,6 +16,7 @@ public interface IMenuService
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default); Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default); Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default); Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
} }
public class MenuService : IMenuService public class MenuService : IMenuService
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
return ToItemDto(entity); return ToItemDto(entity);
} }
public async Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default)
{
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
if (entity is null) return false;
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return true;
}
private static string? NormalizeOptionalText(string? value) => private static string? NormalizeOptionalText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();
@@ -24,6 +24,11 @@ public interface IReservationService
string reservationId, string reservationId,
ReservationStatus status, ReservationStatus status,
CancellationToken cancellationToken = default); CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default);
} }
public class ReservationService : IReservationService public class ReservationService : IReservationService
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
return Map(entity); return Map(entity);
} }
public async Task<bool> DeleteAsync(
string cafeId,
string reservationId,
CancellationToken cancellationToken = default)
{
var entity = await _db.TableReservations
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
if (entity is null) return false;
// Soft delete: TableReservation has a global DeletedAt query filter.
entity.DeletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (!string.IsNullOrEmpty(entity.TableId))
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
return true;
}
internal static ReservationDto Map(TableReservation r) => new( internal static ReservationDto Map(TableReservation r) => new(
r.Id, r.Id,
r.CafeId, r.CafeId,
+1 -1
View File
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
DiscoverFilterParams filters, DiscoverFilterParams filters,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null); var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null && c.ShowOnKoja);
if (!string.IsNullOrWhiteSpace(filters.City)) if (!string.IsNullOrWhiteSpace(filters.City))
query = query.Where(c => c.City != null && c.City.Contains(filters.City)); query = query.Where(c => c.City != null && c.City.Contains(filters.City));
+3
View File
@@ -17,6 +17,9 @@ public class Cafe : BaseEntity
public PlanTier PlanTier { get; set; } = PlanTier.Free; public PlanTier PlanTier { get; set; } = PlanTier.Free;
public DateTime? PlanExpiresAt { get; set; } public DateTime? PlanExpiresAt { get; set; }
public bool IsVerified { get; set; } public bool IsVerified { get; set; }
/// <summary>Owner preference: list this café on Koja (public discovery). Defaults true so a
/// verified café is discoverable out of the box; the owner can opt out from settings.</summary>
public bool ShowOnKoja { get; set; } = true;
/// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary> /// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary>
public bool IsSuspended { get; set; } public bool IsSuspended { get; set; }
public string? SnappfoodVendorId { get; set; } public string? SnappfoodVendorId { get; set; }
@@ -13,5 +13,13 @@ public class SubscriptionPayment : TenantEntity
public string? RefId { get; set; } public string? RefId { get; set; }
public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending; public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending;
/// <summary>When this paid period starts. For an immediately-activated purchase this is
/// (around) the payment time; for a queued (Scheduled) purchase it is the end of the
/// current coverage. Null until the payment completes.</summary>
public DateTime? EffectiveFrom { get; set; }
/// <summary>When this paid period ends (EffectiveFrom + Months). Null until completed.</summary>
public DateTime? EffectiveTo { get; set; }
public Cafe Cafe { get; set; } = null!; public Cafe Cafe { get; set; } = null!;
} }
@@ -4,5 +4,9 @@ public enum SubscriptionPaymentStatus
{ {
Pending = 0, Pending = 0,
Completed = 1, Completed = 1,
Failed = 2 Failed = 2,
/// <summary>Paid, but queued to start after the current coverage ends.</summary>
Scheduled = 3,
/// <summary>A queued (Scheduled) subscription the owner cancelled before it started.</summary>
Cancelled = 4
} }
@@ -111,6 +111,9 @@ public class AppDbContext : DbContext
e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000); e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000);
e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000); e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000);
e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2); e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2);
// Default true at the DB level so existing cafés stay listed on Koja after
// the column is added (EF doesn't read the C# initializer for the SQL default).
e.Property(x => x.ShowOnKoja).HasDefaultValue(true);
e.HasQueryFilter(x => x.DeletedAt == null); e.HasQueryFilter(x => x.DeletedAt == null);
}); });
@@ -11,6 +11,28 @@ namespace Meezi.Infrastructure.Data;
/// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary> /// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary>
public static class DiscoverShowcaseSeeder public static class DiscoverShowcaseSeeder
{ {
// Approximate city centres. Each café is scattered around its city with a
// small deterministic offset (derived from its id) so the marketing map
// shows a realistic cluster of blinking lights instead of one stacked dot.
private static readonly Dictionary<string, (double Lat, double Lng, double Spread)> CityGeo = new()
{
["تهران"] = (35.70, 51.39, 0.13),
["کرج"] = (35.83, 50.99, 0.07),
};
private static (double Lat, double Lng) GeoFor(string id, string city)
{
var (lat, lng, spread) = CityGeo.TryGetValue(city, out var g) ? g : (35.70, 51.39, 0.13);
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"]; private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"];
private static readonly string[] ReviewComments = private static readonly string[] ReviewComments =
[ [
@@ -27,6 +49,7 @@ public static class DiscoverShowcaseSeeder
foreach (var spec in DiscoverShowcaseCatalog.Cafes) foreach (var spec in DiscoverShowcaseCatalog.Cafes)
{ {
var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id); var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id);
var (geoLat, geoLng) = GeoFor(spec.Id, spec.City);
if (cafe is null) if (cafe is null)
{ {
cafe = new Cafe cafe = new Cafe
@@ -37,6 +60,8 @@ public static class DiscoverShowcaseSeeder
Slug = spec.Slug, Slug = spec.Slug,
City = spec.City, City = spec.City,
Address = spec.Address, Address = spec.Address,
Latitude = geoLat,
Longitude = geoLng,
Description = spec.Description, Description = spec.Description,
PlanTier = spec.PlanTier, PlanTier = spec.PlanTier,
PreferredLanguage = "fa", PreferredLanguage = "fa",
@@ -100,6 +125,12 @@ public static class DiscoverShowcaseSeeder
cafe.IsVerified = true; cafe.IsVerified = true;
changed = true; changed = true;
} }
if (cafe.Latitude is null || cafe.Longitude is null)
{
cafe.Latitude = geoLat;
cafe.Longitude = geoLng;
changed = true;
}
if (changed) if (changed)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddCafeShowOnKoja : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ShowOnKoja",
table: "Cafes",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ShowOnKoja",
table: "Cafes");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddSubscriptionScheduling : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "EffectiveFrom",
table: "SubscriptionPayments",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EffectiveTo",
table: "SubscriptionPayments",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EffectiveFrom",
table: "SubscriptionPayments");
migrationBuilder.DropColumn(
name: "EffectiveTo",
table: "SubscriptionPayments");
}
}
}
@@ -360,6 +360,11 @@ namespace Meezi.Infrastructure.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<bool>("ShowOnKoja")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true);
b.Property<string>("Slug") b.Property<string>("Slug")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -2006,6 +2011,12 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EffectiveFrom")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("EffectiveTo")
.HasColumnType("timestamp with time zone");
b.Property<int>("Months") b.Property<int>("Months")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -29,6 +29,25 @@ public static class PlatformDataSeeder
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone". // fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger); await EnsureOwnerAdminAsync(db, config, logger);
// Best-effort, NON-FATAL seeding. These steps populate convenience data
// (map pins, plan/feature catalog) and must never crash-loop the API on
// boot — a failure is logged and startup continues so the service serves.
try
{
// Give cafés without a map pin an approximate location from their
// city so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger);
// Subscription plans + feature flags the admin panel reads in every
// environment. Idempotent: adds any tiers/keys that are missing.
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Non-fatal platform seeding step failed; continuing startup");
}
if (!env.IsDevelopment()) if (!env.IsDevelopment())
{ {
// Production: also ensure integration settings (Kavenegar enabled/template, // Production: also ensure integration settings (Kavenegar enabled/template,
@@ -39,12 +58,83 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger); await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger); await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
await SeedSettingsAsync(db, logger); await SeedSettingsAsync(db, logger);
await EnsureIntegrationSettingsAsync(db, logger); await EnsureIntegrationSettingsAsync(db, logger);
} }
// Approximate centres for the major Iranian cities cafés sign up from.
private static readonly Dictionary<string, (double Lat, double Lng)> CityCentres = new(StringComparer.Ordinal)
{
["تهران"] = (35.70, 51.39),
["کرج"] = (35.84, 50.99),
["مشهد"] = (36.30, 59.61),
["اصفهان"] = (32.66, 51.67),
["شیراز"] = (29.59, 52.53),
["تبریز"] = (38.08, 46.29),
["قم"] = (34.64, 50.88),
["اهواز"] = (31.32, 48.67),
["کرمانشاه"] = (34.31, 47.07),
["رشت"] = (37.28, 49.58),
["ارومیه"] = (37.55, 45.07),
["همدان"] = (34.80, 48.52),
["یزد"] = (31.90, 54.37),
["اراک"] = (34.09, 49.69),
["کرمان"] = (30.28, 57.08),
["بندرعباس"] = (27.18, 56.27),
["قزوین"] = (36.28, 50.00),
["ساری"] = (36.57, 53.06),
["گرگان"] = (36.84, 54.44),
["زنجان"] = (36.68, 48.49),
["کیش"] = (26.56, 53.98),
};
/// <summary>
/// Gives cafés that have no map pin an approximate location at their city
/// centre (plus a small deterministic per-café offset so multiple cafés in
/// one city don't stack on a single point). Only fills rows where Latitude or
/// Longitude is null and the city is recognised; owners can drop an exact pin
/// later from Settings. Idempotent — never overwrites an existing pin.
/// </summary>
private static async Task BackfillCafeLocationsAsync(AppDbContext db, ILogger logger)
{
var cafes = await db.Cafes
.Where(c => c.DeletedAt == null
&& (c.Latitude == null || c.Longitude == null)
&& c.City != null)
.ToListAsync();
if (cafes.Count == 0) return;
var updated = 0;
foreach (var cafe in cafes)
{
var city = cafe.City!.Trim();
if (!CityCentres.TryGetValue(city, out var centre)) continue;
var (lat, lng) = ScatterAround(cafe.Id, centre.Lat, centre.Lng, 0.05);
cafe.Latitude = lat;
cafe.Longitude = lng;
updated++;
}
if (updated > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Cafe location backfill: set approximate coordinates for {Count} café(s) from city centre", updated);
}
}
private static (double Lat, double Lng) ScatterAround(string id, double lat, double lng, double spread)
{
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
/// <summary> /// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment /// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy. /// (including production), so the admin panel is reachable on a fresh deploy.
@@ -280,9 +370,6 @@ public static class PlatformDataSeeder
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger) private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformPlanDefinitions.AnyAsync())
return;
var plans = new[] var plans = new[]
{ {
new PlatformPlanDefinition new PlatformPlanDefinition
@@ -344,16 +431,26 @@ public static class PlatformDataSeeder
} }
}; };
db.PlatformPlanDefinitions.AddRange(plans); // Tier (not Id) carries the unique constraint, so dedupe on Tier — an
// existing Free plan may have a different Id, and inserting another
// Free-tier row would violate IX_PlatformPlanDefinitions_Tier.
// IgnoreQueryFilters: a SOFT-DELETED plan still occupies its Tier in the
// unique index, so it must be counted or the insert collides on boot.
var existingTiers = (await db.PlatformPlanDefinitions
.IgnoreQueryFilters()
.Select(p => p.Tier)
.ToListAsync())
.ToHashSet();
var missing = plans.Where(p => !existingTiers.Contains(p.Tier)).ToArray();
if (missing.Length == 0) return;
db.PlatformPlanDefinitions.AddRange(missing);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} subscription plans", plans.Length); logger.LogInformation("Platform seed: +{Count} subscription plans", missing.Length);
} }
private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger) private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformFeatures.AnyAsync())
return;
var features = new[] var features = new[]
{ {
F("pos", "صندوق", "POS", "core"), F("pos", "صندوق", "POS", "core"),
@@ -379,9 +476,19 @@ public static class PlatformDataSeeder
F("discover_profile", "پروفایل کشف", "Discover profile", "growth") F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
}; };
db.PlatformFeatures.AddRange(features); // Key carries the unique constraint, so dedupe on Key (not Id).
// IgnoreQueryFilters so a soft-deleted feature's Key is still counted.
var existingKeys = (await db.PlatformFeatures
.IgnoreQueryFilters()
.Select(f => f.Key)
.ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = features.Where(f => !existingKeys.Contains(f.Key)).ToArray();
if (missing.Length == 0) return;
db.PlatformFeatures.AddRange(missing);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} feature flags", features.Length); logger.LogInformation("Platform seed: +{Count} feature flags", missing.Length);
} }
private static PlatformFeature F(string key, string fa, string en, string group) => new() private static PlatformFeature F(string key, string fa, string en, string group) => new()
@@ -16,6 +16,9 @@ internal sealed class NoOpInventoryService : IInventoryService
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) => public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
public Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default) =>
Task.FromResult(false);
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) => public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null); Task.FromResult<IngredientDto?>(null);
@@ -44,6 +44,7 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
<button <button
type="button" type="button"
role="switch" role="switch"
dir="ltr"
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
@@ -604,11 +605,18 @@ export function AdminIntegrationsScreen() {
}); });
}, [data]); }, [data]);
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
const save = useMutation({ const save = useMutation({
mutationFn: () => mutationFn: () =>
adminPut<PlatformIntegrations>("/api/admin/integrations", { adminPut<PlatformIntegrations>("/api/admin/integrations", {
activePaymentGateway: activeGateway, activePaymentGateway: activeGateway,
paymentGateways: gateways.map((g) => ({ // Save from `list` (what's rendered/edited), not `gateways` — if the
// gateways state hasn't hydrated, `list` falls back to the fetched data,
// and edits go through updateGateway which seeds it. This keeps the
// rendered, edited, and saved arrays the same source (was dropping
// edits like the Zarinpal merchantId when gateways was empty).
paymentGateways: list.map((g) => ({
id: g.id, id: g.id,
isEnabled: g.isEnabled, isEnabled: g.isEnabled,
merchantId: g.id === "zarinpal" ? g.merchantId : undefined, merchantId: g.id === "zarinpal" ? g.merchantId : undefined,
@@ -637,11 +645,14 @@ export function AdminIntegrationsScreen() {
}); });
const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => { const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => {
setGateways((prev) => prev.map((g) => (g.id === id ? { ...g, ...patch } : g))); setGateways((prev) => {
// Seed from fetched data on the first edit so an edit is never dropped
// because the state hadn't hydrated yet.
const base = prev.length > 0 ? prev : data?.paymentGateways?.map((g) => ({ ...g })) ?? [];
return base.map((g) => (g.id === id ? { ...g, ...patch } : g));
});
}; };
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -162,6 +162,7 @@ function BlogToggle({
<button <button
type="button" type="button"
role="switch" role="switch"
dir="ltr"
aria-checked={checked} aria-checked={checked}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
className={cn( className={cn(
+48 -12
View File
@@ -20,6 +20,13 @@
"saved": "تم الحفظ", "saved": "تم الحفظ",
"errorGeneric": "حدث خطأ. حاول مرة أخرى." "errorGeneric": "حدث خطأ. حاول مرة أخرى."
}, },
"errors": {
"planLimit": "وصلت إلى حد الخطة",
"notFound": "غير موجود",
"unauthorized": "غير مصرح",
"network": "خطأ في الاتصال",
"generic": "حدث خطأ. حاول مرة أخرى."
},
"brand": { "brand": {
"name": "ميزي" "name": "ميزي"
}, },
@@ -243,6 +250,7 @@
"void": "إلغاء", "void": "إلغاء",
"voidItem": "إلغاء الصنف", "voidItem": "إلغاء الصنف",
"voided": "ملغى", "voided": "ملغى",
"itemNotePlaceholder": "ملاحظة للمطبخ/البار (اختياري)",
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟", "confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
"voidError": "تعذر إلغاء الصنف", "voidError": "تعذر إلغاء الصنف",
"transferTable": "نقل الطاولة", "transferTable": "نقل الطاولة",
@@ -372,7 +380,10 @@
"duplicatePhone": "رقم الجوال مسجل مسبقاً.", "duplicatePhone": "رقم الجوال مسجل مسبقاً.",
"generic": "تعذر الحفظ. حاول مرة أخرى." "generic": "تعذر الحفظ. حاول مرة أخرى."
} }
} },
"deleted": "تم حذف العميل",
"deleteConfirmTitle": "حذف العميل",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟"
}, },
"coupons": { "coupons": {
"title": "القسائم", "title": "القسائم",
@@ -388,7 +399,10 @@
"FixedAmount": "مبلغ ثابت", "FixedAmount": "مبلغ ثابت",
"FreeItem": "عنصر مجاني" "FreeItem": "عنصر مجاني"
}, },
"noCoupons": "لا توجد قسائم" "noCoupons": "لا توجد قسائم",
"deleted": "تم حذف القسيمة",
"deleteConfirmTitle": "حذف القسيمة",
"deleteConfirmDesc": "هل أنت متأكد من حذف القسيمة «{code}»؟"
}, },
"hr": { "hr": {
"title": "الموارد البشرية", "title": "الموارد البشرية",
@@ -735,7 +749,13 @@
"addItemSuccess": "تمت إضافة الصنف", "addItemSuccess": "تمت إضافة الصنف",
"updateItemSuccess": "تم تحديث الصنف", "updateItemSuccess": "تم تحديث الصنف",
"addCategorySuccess": "تمت إضافة الفئة", "addCategorySuccess": "تمت إضافة الفئة",
"updateCategorySuccess": "تم تحديث الفئة" "updateCategorySuccess": "تم تحديث الفئة",
"deleteItemConfirmTitle": "حذف الصنف",
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteItemSuccess": "تم حذف الصنف",
"deleteCategoryConfirmTitle": "حذف الفئة",
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
"deleteCategorySuccess": "تم حذف الفئة"
}, },
"branchMenu": { "branchMenu": {
"title": "قائمة الفرع", "title": "قائمة الفرع",
@@ -829,7 +849,10 @@
"purchasesThisMonth": "مشتريات المواد هذا الشهر", "purchasesThisMonth": "مشتريات المواد هذا الشهر",
"purchaseCount": "{count} عملية شراء", "purchaseCount": "{count} عملية شراء",
"viewInExpenses": "عرض في المصروفات", "viewInExpenses": "عرض في المصروفات",
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع." "selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع.",
"deleted": "تم حذف المادة",
"deleteConfirmTitle": "حذف المادة",
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع."
}, },
"qr": { "qr": {
"brand": "ميزي", "brand": "ميزي",
@@ -856,6 +879,7 @@
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً", "orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
"guestName": "اسمك (اختياري)", "guestName": "اسمك (اختياري)",
"guestPhone": "الجوال (اختياري)", "guestPhone": "الجوال (اختياري)",
"itemNote": "ملاحظة (مثلاً بدون طماطم، سكر أقل)",
"addMoreItems": "إضافة المزيد", "addMoreItems": "إضافة المزيد",
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.", "orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق", "rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
@@ -943,7 +967,10 @@
"Cancelled": "ملغى", "Cancelled": "ملغى",
"Seated": "جالس", "Seated": "جالس",
"Completed": "مكتمل" "Completed": "مكتمل"
} },
"deleted": "تم حذف الحجز",
"deleteConfirmTitle": "حذف الحجز",
"deleteConfirmDesc": "هل أنت متأكد من حذف حجز «{name}»؟"
}, },
"branchesPage": { "branchesPage": {
"title": "الفروع", "title": "الفروع",
@@ -1020,7 +1047,18 @@
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.", "secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
"payTotal": "ادفع {total}", "payTotal": "ادفع {total}",
"redirecting": "جارٍ التحويل إلى البوابة...", "redirecting": "جارٍ التحويل إلى البوابة...",
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى." "paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى.",
"queuedNotice": "لديك اشتراك نشط بالفعل. ستتم إضافة هذا الشراء إلى قائمة الانتظار وسيبدأ في {date}."
},
"queued": {
"title": "الاشتراكات في قائمة الانتظار",
"subtitle": "تبدأ تلقائيًا عند انتهاء اشتراكك الحالي.",
"months": "{count} أشهر",
"window": "من {from} إلى {to}",
"cancel": "إلغاء",
"cancelled": "تم إلغاء الاشتراك في قائمة الانتظار",
"cancelConfirmTitle": "إلغاء الاشتراك المجدول",
"cancelConfirmDesc": "إلغاء اشتراك {plan} المقرر أن يبدأ في {from}؟ لن يتأثر اشتراكك الحالي."
} }
}, },
"settings": { "settings": {
@@ -1359,12 +1397,6 @@
} }
} }
}, },
"errors": {
"planLimit": "وصلت إلى حد الخطة",
"notFound": "غير موجود",
"unauthorized": "غير مصرح",
"network": "خطأ في الاتصال"
},
"discoverPublic": { "discoverPublic": {
"brand": "ميزي", "brand": "ميزي",
"title": "اكتشاف المقاهي", "title": "اكتشاف المقاهي",
@@ -1511,5 +1543,9 @@
"mid": "میانه", "mid": "میانه",
"premium": "پریمیوم" "premium": "پریمیوم"
} }
},
"cafePublicProfile": {
"showOnKoja": "العرض على كوجا",
"showOnKojaHint": "إدراج مقهاك في دليل كوجا العام (koja.meezi.ir). مفعّل افتراضيًا."
} }
} }
+47 -13
View File
@@ -20,6 +20,13 @@
"saved": "Saved", "saved": "Saved",
"errorGeneric": "Something went wrong. Please try again." "errorGeneric": "Something went wrong. Please try again."
}, },
"errors": {
"planLimit": "Plan limit reached. Please upgrade.",
"notFound": "Not found",
"unauthorized": "Unauthorized",
"network": "Network error",
"generic": "Something went wrong. Please try again."
},
"brand": { "brand": {
"name": "Meezi" "name": "Meezi"
}, },
@@ -262,6 +269,7 @@
"void": "Void", "void": "Void",
"voidItem": "Void item", "voidItem": "Void item",
"voided": "Voided", "voided": "Voided",
"itemNotePlaceholder": "Note for kitchen/bar (optional)",
"confirmVoid": "Are you sure you want to void this item?", "confirmVoid": "Are you sure you want to void this item?",
"voidError": "Could not void item", "voidError": "Could not void item",
"transferTable": "Transfer table", "transferTable": "Transfer table",
@@ -391,7 +399,10 @@
"duplicatePhone": "This phone number is already registered.", "duplicatePhone": "This phone number is already registered.",
"generic": "Could not save. Please try again." "generic": "Could not save. Please try again."
} }
} },
"deleted": "Customer deleted",
"deleteConfirmTitle": "Delete customer",
"deleteConfirmDesc": "Delete “{name}”?"
}, },
"coupons": { "coupons": {
"title": "Coupons", "title": "Coupons",
@@ -407,7 +418,10 @@
"FixedAmount": "Fixed amount", "FixedAmount": "Fixed amount",
"FreeItem": "Free item" "FreeItem": "Free item"
}, },
"noCoupons": "No coupons yet" "noCoupons": "No coupons yet",
"deleted": "Coupon deleted",
"deleteConfirmTitle": "Delete coupon",
"deleteConfirmDesc": "Delete coupon “{code}”?"
}, },
"hr": { "hr": {
"title": "Human resources", "title": "Human resources",
@@ -778,7 +792,13 @@
"addItemSuccess": "Item added", "addItemSuccess": "Item added",
"updateItemSuccess": "Item updated", "updateItemSuccess": "Item updated",
"addCategorySuccess": "Category added", "addCategorySuccess": "Category added",
"updateCategorySuccess": "Category updated" "updateCategorySuccess": "Category updated",
"deleteItemConfirmTitle": "Delete item",
"deleteItemConfirmDesc": "Are you sure you want to delete “{name}”? This can't be undone.",
"deleteItemSuccess": "Item deleted",
"deleteCategoryConfirmTitle": "Delete category",
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
"deleteCategorySuccess": "Category deleted"
}, },
"branchMenu": { "branchMenu": {
"title": "Branch Menu", "title": "Branch Menu",
@@ -898,7 +918,10 @@
"purchasesThisMonth": "Material purchases this month", "purchasesThisMonth": "Material purchases this month",
"purchaseCount": "{count} purchases", "purchaseCount": "{count} purchases",
"viewInExpenses": "View in expenses", "viewInExpenses": "View in expenses",
"selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases." "selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases.",
"deleted": "Material deleted",
"deleteConfirmTitle": "Delete material",
"deleteConfirmDesc": "Delete “{name}”? This cant be undone."
}, },
"qr": { "qr": {
"brand": "Meezi", "brand": "Meezi",
@@ -925,6 +948,7 @@
"orderHint": "Staff will prepare your order shortly", "orderHint": "Staff will prepare your order shortly",
"guestName": "Your name (optional)", "guestName": "Your name (optional)",
"guestPhone": "Mobile (optional)", "guestPhone": "Mobile (optional)",
"itemNote": "Note (e.g. no tomato, less sugar)",
"addMoreItems": "Add more items", "addMoreItems": "Add more items",
"orderError": "Could not place order. Try again.", "orderError": "Could not place order. Try again.",
"rateLimited": "Too many requests — please wait a few minutes", "rateLimited": "Too many requests — please wait a few minutes",
@@ -1013,7 +1037,10 @@
"Cancelled": "Cancelled", "Cancelled": "Cancelled",
"Seated": "Seated", "Seated": "Seated",
"Completed": "Completed" "Completed": "Completed"
} },
"deleted": "Reservation deleted",
"deleteConfirmTitle": "Delete reservation",
"deleteConfirmDesc": "Delete the reservation for “{name}”?"
}, },
"branchesPage": { "branchesPage": {
"title": "Branches", "title": "Branches",
@@ -1092,7 +1119,18 @@
"secureNote": "Payment is processed through a secure bank gateway.", "secureNote": "Payment is processed through a secure bank gateway.",
"payTotal": "Pay {total}", "payTotal": "Pay {total}",
"redirecting": "Redirecting to gateway...", "redirecting": "Redirecting to gateway...",
"paymentFailed": "Payment failed. Please try again." "paymentFailed": "Payment failed. Please try again.",
"queuedNotice": "You already have an active subscription. This purchase will be queued and start on {date}."
},
"queued": {
"title": "Queued subscriptions",
"subtitle": "These start automatically when your current subscription ends.",
"months": "{count} months",
"window": "From {from} to {to}",
"cancel": "Cancel",
"cancelled": "Queued subscription cancelled",
"cancelConfirmTitle": "Cancel queued subscription",
"cancelConfirmDesc": "Cancel the {plan} subscription scheduled to start on {from}? Your current subscription is unaffected."
} }
}, },
"settings": { "settings": {
@@ -1441,12 +1479,6 @@
} }
} }
}, },
"errors": {
"planLimit": "Plan limit reached. Please upgrade.",
"notFound": "Not found",
"unauthorized": "Unauthorized",
"network": "Network error"
},
"discoverPublic": { "discoverPublic": {
"brand": "Meezi", "brand": "Meezi",
"title": "Discover cafés", "title": "Discover cafés",
@@ -1551,7 +1583,9 @@
"save": "Save", "save": "Save",
"saved": "Saved", "saved": "Saved",
"saveFailed": "Save failed", "saveFailed": "Save failed",
"loading": "Loading…" "loading": "Loading…",
"showOnKoja": "Show on Koja",
"showOnKojaHint": "List your café in the public Koja directory (koja.meezi.ir). On by default."
}, },
"discoverProfile": { "discoverProfile": {
"sections": { "sections": {
+47 -13
View File
@@ -20,6 +20,13 @@
"saved": "ذخیره شد", "saved": "ذخیره شد",
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید." "errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
}, },
"errors": {
"planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound": "یافت نشد",
"unauthorized": "دسترسی ندارید",
"network": "خطای ارتباط با سرور",
"generic": "خطایی رخ داد. دوباره تلاش کنید."
},
"brand": { "brand": {
"name": "میزی" "name": "میزی"
}, },
@@ -262,6 +269,7 @@
"void": "ابطال", "void": "ابطال",
"voidItem": "ابطال آیتم", "voidItem": "ابطال آیتم",
"voided": "ابطال شده", "voided": "ابطال شده",
"itemNotePlaceholder": "یادداشت برای آشپزخانه/بار (اختیاری)",
"confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟", "confirmVoid": "آیا مطمئن هستید که می‌خواهید این آیتم را ابطال کنید؟",
"voidError": "خطا در ابطال آیتم", "voidError": "خطا در ابطال آیتم",
"transferTable": "انتقال میز", "transferTable": "انتقال میز",
@@ -391,7 +399,10 @@
"duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.", "duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.",
"generic": "ذخیره انجام نشد. دوباره تلاش کنید." "generic": "ذخیره انجام نشد. دوباره تلاش کنید."
} }
} },
"deleted": "مشتری حذف شد",
"deleteConfirmTitle": "حذف مشتری",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟"
}, },
"coupons": { "coupons": {
"title": "کوپن‌ها", "title": "کوپن‌ها",
@@ -407,7 +418,10 @@
"FixedAmount": "مبلغ ثابت", "FixedAmount": "مبلغ ثابت",
"FreeItem": "آیتم رایگان" "FreeItem": "آیتم رایگان"
}, },
"noCoupons": "کوپنی ثبت نشده" "noCoupons": "کوپنی ثبت نشده",
"deleted": "کوپن حذف شد",
"deleteConfirmTitle": "حذف کوپن",
"deleteConfirmDesc": "آیا از حذف کوپن «{code}» مطمئن هستید؟"
}, },
"hr": { "hr": {
"title": "منابع انسانی", "title": "منابع انسانی",
@@ -778,7 +792,13 @@
"addItemSuccess": "آیتم اضافه شد", "addItemSuccess": "آیتم اضافه شد",
"updateItemSuccess": "آیتم به‌روز شد", "updateItemSuccess": "آیتم به‌روز شد",
"addCategorySuccess": "دسته اضافه شد", "addCategorySuccess": "دسته اضافه شد",
"updateCategorySuccess": "دسته به‌روز شد" "updateCategorySuccess": "دسته به‌روز شد",
"deleteItemConfirmTitle": "حذف آیتم",
"deleteItemConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.",
"deleteItemSuccess": "آیتم حذف شد",
"deleteCategoryConfirmTitle": "حذف دسته‌بندی",
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
"deleteCategorySuccess": "دسته حذف شد"
}, },
"branchMenu": { "branchMenu": {
"title": "منوی شعبه", "title": "منوی شعبه",
@@ -898,7 +918,10 @@
"purchasesThisMonth": "خرید مواد این ماه", "purchasesThisMonth": "خرید مواد این ماه",
"purchaseCount": "{count} خرید", "purchaseCount": "{count} خرید",
"viewInExpenses": "مشاهده در هزینه‌ها", "viewInExpenses": "مشاهده در هزینه‌ها",
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید." "selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید.",
"deleted": "ماده حذف شد",
"deleteConfirmTitle": "حذف ماده",
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست."
}, },
"qr": { "qr": {
"brand": "میزی", "brand": "میزی",
@@ -925,6 +948,7 @@
"orderHint": "کارکنان به زودی سفارش شما را آماده می‌کنند", "orderHint": "کارکنان به زودی سفارش شما را آماده می‌کنند",
"guestName": "نام شما (اختیاری)", "guestName": "نام شما (اختیاری)",
"guestPhone": "شماره موبایل (اختیاری)", "guestPhone": "شماره موبایل (اختیاری)",
"itemNote": "یادداشت (مثلاً بدون گوجه، کم‌شکر)",
"addMoreItems": "افزودن آیتم دیگر", "addMoreItems": "افزودن آیتم دیگر",
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید", "orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.", "orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
@@ -1014,7 +1038,10 @@
"Cancelled": "لغو شده", "Cancelled": "لغو شده",
"Seated": "نشسته", "Seated": "نشسته",
"Completed": "انجام شده" "Completed": "انجام شده"
} },
"deleted": "رزرو حذف شد",
"deleteConfirmTitle": "حذف رزرو",
"deleteConfirmDesc": "آیا از حذف رزرو «{name}» مطمئن هستید؟"
}, },
"branchesPage": { "branchesPage": {
"title": "شعب", "title": "شعب",
@@ -1093,7 +1120,18 @@
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.", "secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.",
"payTotal": "پرداخت {total}", "payTotal": "پرداخت {total}",
"redirecting": "در حال انتقال به درگاه...", "redirecting": "در حال انتقال به درگاه...",
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید." "paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید.",
"queuedNotice": "شما اشتراک فعالی دارید. این خرید در صف قرار می‌گیرد و از {date} آغاز می‌شود."
},
"queued": {
"title": "اشتراک‌های در صف",
"subtitle": "این اشتراک‌ها پس از پایان اشتراک فعلی به‌صورت خودکار فعال می‌شوند.",
"months": "{count} ماه",
"window": "از {from} تا {to}",
"cancel": "لغو",
"cancelled": "اشتراک در صف لغو شد",
"cancelConfirmTitle": "لغو اشتراک در صف",
"cancelConfirmDesc": "اشتراک {plan} که قرار بود از {from} آغاز شود لغو شود؟ اشتراک فعلی شما دست‌نخورده می‌ماند."
} }
}, },
"settings": { "settings": {
@@ -1442,12 +1480,6 @@
} }
} }
}, },
"errors": {
"planLimit": "به سقف پلن رسیده‌اید. برای ادامه ارتقا دهید",
"notFound": "یافت نشد",
"unauthorized": "دسترسی ندارید",
"network": "خطای ارتباط با سرور"
},
"discoverPublic": { "discoverPublic": {
"brand": "میزی", "brand": "میزی",
"title": "کافه‌یاب", "title": "کافه‌یاب",
@@ -1552,7 +1584,9 @@
"save": "ذخیره", "save": "ذخیره",
"saved": "ذخیره شد", "saved": "ذخیره شد",
"saveFailed": "ذخیره ناموفق بود", "saveFailed": "ذخیره ناموفق بود",
"loading": "در حال بارگذاری…" "loading": "در حال بارگذاری…",
"showOnKoja": "نمایش در کوجا",
"showOnKojaHint": "کافه شما در فهرست عمومی کوجا (koja.meezi.ir) نمایش داده شود. پیش‌فرض روشن است."
}, },
"discoverProfile": { "discoverProfile": {
"sections": { "sections": {
@@ -3,24 +3,29 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Plus } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
import type { Coupon, CouponType } from "@/lib/api/types"; import type { Coupon, CouponType } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
export function CouponsScreen() { export function CouponsScreen() {
const t = useTranslations("coupons"); const t = useTranslations("coupons");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Coupon | null>(null);
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [type, setType] = useState<CouponType>("Percentage"); const [type, setType] = useState<CouponType>("Percentage");
const [value, setValue] = useState("10"); const [value, setValue] = useState("10");
@@ -47,6 +52,16 @@ export function CouponsScreen() {
}, },
}); });
const deleteCoupon = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/coupons/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
return ( return (
@@ -132,11 +147,34 @@ export function CouponsScreen() {
{t("usage")}: {formatNumber(c.usedCount)} {t("usage")}: {formatNumber(c.usedCount)}
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""} {c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
</p> </p>
<div className="mt-2 flex justify-end">
<Button
type="button"
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { code: deleteTarget.code }) : undefined}
busy={deleteCoupon.isPending}
onConfirm={() => deleteTarget && deleteCoupon.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
+49 -12
View File
@@ -1,23 +1,27 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Plus, Pencil, Search } from "lucide-react"; import { Plus, Pencil, Search, Trash2 } from "lucide-react";
import { apiGet } from "@/lib/api/client"; import { apiDelete, apiGet } from "@/lib/api/client";
import type { Customer } from "@/lib/api/types"; import type { Customer } from "@/lib/api/types";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field"; import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard"; import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
export function CrmScreen() { export function CrmScreen() {
const t = useTranslations("crm"); const t = useTranslations("crm");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -26,6 +30,7 @@ export function CrmScreen() {
const [wizardOpen, setWizardOpen] = useState(false); const [wizardOpen, setWizardOpen] = useState(false);
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create"); const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null); const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
const { data: customers = [], isLoading } = useQuery({ const { data: customers = [], isLoading } = useQuery({
queryKey: ["customers", cafeId, debouncedSearch], queryKey: ["customers", cafeId, debouncedSearch],
@@ -46,6 +51,16 @@ export function CrmScreen() {
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] }); queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
}; };
const deleteCustomer = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/customers/${id}`),
onSuccess: () => {
refreshCustomers();
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
return ( return (
@@ -104,21 +119,43 @@ export function CrmScreen() {
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)} {t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
</p> </p>
</div> </div>
<Button <div className="flex gap-2">
size="sm" <Button
variant="outline" size="sm"
className="w-full" variant="outline"
onClick={() => openWizard("edit", c)} className="flex-1"
> onClick={() => openWizard("edit", c)}
<Pencil className="me-1 h-3.5 w-3.5" /> >
{tCommon("edit")} <Pencil className="me-1 h-3.5 w-3.5" />
</Button> {tCommon("edit")}
</Button>
<Button
size="sm"
variant="outline"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(c)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</div> </div>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
busy={deleteCustomer.isPending}
onConfirm={() => deleteTarget && deleteCustomer.mutate(deleteTarget.id)}
/>
<CustomerWizard <CustomerWizard
open={wizardOpen} open={wizardOpen}
mode={wizardMode} mode={wizardMode}
@@ -4,6 +4,8 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react"; import { Sparkles, Loader2 } from "lucide-react";
import { apiPost } from "@/lib/api/client"; import { apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -26,6 +28,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const qc = useQueryClient(); const qc = useQueryClient();
const apiError = useApiError();
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [summary, setSummary] = useState<DemoSeedResult | null>(null); const [summary, setSummary] = useState<DemoSeedResult | null>(null);
@@ -39,6 +42,9 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
qc.invalidateQueries({ queryKey: key }); qc.invalidateQueries({ queryKey: key });
} }
}, },
onError: (err) => {
notify.error(apiError(err));
},
}); });
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null; if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
@@ -9,6 +9,7 @@ import {
updateCafePublicProfile, updateCafePublicProfile,
uploadGalleryPhoto, uploadGalleryPhoto,
type CafeProfileEdit, type CafeProfileEdit,
type UpdateCafeProfilePayload,
} from "@/lib/api/cafe-public-profile"; } from "@/lib/api/cafe-public-profile";
import type { WorkingHours } from "@/lib/api/public-discover"; import type { WorkingHours } from "@/lib/api/public-discover";
import { resolveMediaUrl } from "@/lib/api/client"; import { resolveMediaUrl } from "@/lib/api/client";
@@ -42,6 +43,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
const [instagram, setInstagram] = useState<string>(""); const [instagram, setInstagram] = useState<string>("");
const [website, setWebsite] = useState<string>(""); const [website, setWebsite] = useState<string>("");
const [hours, setHours] = useState<WorkingHours>(emptyHours()); const [hours, setHours] = useState<WorkingHours>(emptyHours());
const [showOnKoja, setShowOnKoja] = useState(true);
const [initialized, setInitialized] = useState(false); const [initialized, setInitialized] = useState(false);
// Populate local state once we get server data // Populate local state once we get server data
@@ -50,17 +52,20 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
setInstagram(profile.instagramHandle ?? ""); setInstagram(profile.instagramHandle ?? "");
setWebsite(profile.websiteUrl ?? ""); setWebsite(profile.websiteUrl ?? "");
setHours(profile.workingHours ?? emptyHours()); setHours(profile.workingHours ?? emptyHours());
setShowOnKoja(profile.showOnKoja ?? true);
setInitialized(true); setInitialized(true);
} }
// ── Save info/social/hours ──────────────────────────────────────────────── // ── Save info/social/hours ────────────────────────────────────────────────
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => mutationFn: (override?: Partial<UpdateCafeProfilePayload>) =>
updateCafePublicProfile(cafeId, { updateCafePublicProfile(cafeId, {
description, description,
instagramHandle: instagram || null, instagramHandle: instagram || null,
websiteUrl: website || null, websiteUrl: website || null,
workingHours: hours, workingHours: hours,
showOnKoja,
...override,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
qc.setQueryData(["cafe-public-profile", cafeId], data); qc.setQueryData(["cafe-public-profile", cafeId], data);
@@ -157,6 +162,23 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
{tab === "info" && ( {tab === "info" && (
<Card className="rounded-xl border border-border/80"> <Card className="rounded-xl border border-border/80">
<CardContent className="space-y-4 p-4"> <CardContent className="space-y-4 p-4">
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/40 px-3 py-2.5">
<span className="min-w-0">
<span className="block text-sm font-medium">{t("showOnKoja")}</span>
<span className="block text-xs text-muted-foreground">{t("showOnKojaHint")}</span>
</span>
<input
type="checkbox"
checked={showOnKoja}
onChange={(e) => {
const v = e.target.checked;
setShowOnKoja(v);
// Persist immediately (pass the new value to avoid stale state).
saveMutation.mutate({ showOnKoja: v });
}}
className="h-5 w-5 shrink-0 cursor-pointer accent-[#0F6E56]"
/>
</label>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t("description")}</Label> <Label>{t("description")}</Label>
<textarea <textarea
@@ -167,7 +189,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]" className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
/> />
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -276,7 +298,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
); );
})} })}
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -307,7 +329,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
dir="ltr" dir="ltr"
/> />
</div> </div>
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} /> <SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { Pencil } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field"; import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store"; import { useBranchStore } from "@/lib/stores/branch.store";
@@ -19,6 +20,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
type Ingredient = { type Ingredient = {
id: string; id: string;
@@ -67,6 +69,7 @@ type PurchasesSummary = {
export function InventoryScreen() { export function InventoryScreen() {
const t = useTranslations("inventory"); const t = useTranslations("inventory");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const apiError = useApiError();
const locale = useLocale(); const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR"; const numberLocale = locale === "en" ? "en-US" : "fa-IR";
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
@@ -95,6 +98,7 @@ export function InventoryScreen() {
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({}); const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({}); const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Ingredient | null>(null);
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [editUnit, setEditUnit] = useState("گرم"); const [editUnit, setEditUnit] = useState("گرم");
const [editReorder, setEditReorder] = useState("0"); const [editReorder, setEditReorder] = useState("0");
@@ -198,6 +202,17 @@ export function InventoryScreen() {
}, },
}); });
const deleteIngredient = useMutation({
mutationFn: (id: string) =>
apiDelete(`/api/cafes/${cafeId}/inventory/ingredients/${id}`),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
const adjustStock = useMutation({ const adjustStock = useMutation({
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) => mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, { apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
@@ -478,6 +493,16 @@ export function InventoryScreen() {
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
</Button> </Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-8 text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(ing)}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</div> </div>
<p className="text-sm font-medium text-[#0F6E56]"> <p className="text-sm font-medium text-[#0F6E56]">
@@ -661,6 +686,17 @@ export function InventoryScreen() {
) : null} ) : null}
</Card> </Card>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
busy={deleteIngredient.isPending}
onConfirm={() => deleteTarget && deleteIngredient.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
@@ -178,6 +178,11 @@ export function KdsScreen() {
<li key={item.id}> <li key={item.id}>
{formatNumber(item.quantity, numberLocale)}×{" "} {formatNumber(item.quantity, numberLocale)}×{" "}
{item.menuItemName} {item.menuItemName}
{item.notes ? (
<span className="mt-0.5 block rounded bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
{item.notes}
</span>
) : null}
</li> </li>
))} ))}
</ul> </ul>
@@ -193,6 +193,8 @@ export function BranchMenuOverrides({
<button <button
type="button" type="button"
role="switch" role="switch"
// Force LTR so the knob's translate-x stays inside the track in RTL.
dir="ltr"
aria-checked={row.isAvailable} aria-checked={row.isAvailable}
className={cn( className={cn(
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors", "relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl"; import { useLocale, useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl"; import { useIsRtl } from "@/lib/use-is-rtl";
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react"; import { Box, Pencil, Plus, Search, Trash2, Video, X } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { Menu3dUpload } from "@/components/media/menu-3d-upload"; import { Menu3dUpload } from "@/components/media/menu-3d-upload";
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate"; import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
@@ -12,8 +12,19 @@ import { CategoryVisual } from "@/components/menu/category-visual";
import { CategoryMediaFields } from "@/components/menu/category-media-fields"; import { CategoryMediaFields } from "@/components/menu/category-media-fields";
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker"; import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets"; import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store"; import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format"; import { formatCurrency, formatNumber } from "@/lib/format";
@@ -126,6 +137,9 @@ function ToggleSwitch({
aria-checked={checked} aria-checked={checked}
aria-label={label} aria-label={label}
type="button" type="button"
// Force LTR so the knob's translate-x stays inside the track; in RTL the
// flex start sits on the right and translate-x-4 would push it out.
dir="ltr"
onClick={() => !disabled && onChange(!checked)} onClick={() => !disabled && onChange(!checked)}
className={cn( className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200", "relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
@@ -184,11 +198,8 @@ function Modal({
export function MenuAdminScreen() { export function MenuAdminScreen() {
const t = useTranslations("menuAdmin"); const t = useTranslations("menuAdmin");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const tNotify = useTranslations("notify"); const apiError = useApiError();
const showError = (err: unknown) => const showError = (err: unknown) => notify.error(apiError(err));
notify.error(
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
);
const isRtl = useIsRtl(); const isRtl = useIsRtl();
const locale = useLocale(); const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR"; const numberLocale = locale === "en" ? "en-US" : "fa-IR";
@@ -211,6 +222,11 @@ export function MenuAdminScreen() {
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null); const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm); const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
// Delete confirmation (shared dialog for items + categories)
const [confirmDelete, setConfirmDelete] = useState<
{ kind: "item" | "category"; id: string; name: string } | null
>(null);
// ── Data queries ─────────────────────────────────────────────────────────── // ── Data queries ───────────────────────────────────────────────────────────
const { data: categories = [] } = useQuery({ const { data: categories = [] } = useQuery({
queryKey: ["menu-categories", cafeId], queryKey: ["menu-categories", cafeId],
@@ -301,6 +317,30 @@ export function MenuAdminScreen() {
onError: showError, onError: showError,
}); });
const deleteItemMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/items/${id}`),
onSuccess: () => {
setConfirmDelete(null);
setItemModalOpen(false);
notify.success(t("deleteItemSuccess"));
invalidateMenu();
},
onError: showError,
});
const deleteCategoryMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/categories/${id}`),
onSuccess: (_data, id) => {
setConfirmDelete(null);
setCatModalOpen(false);
// If the deleted category was selected, fall back to "all items".
setSelectedCategoryId((prev) => (prev === id ? "all" : prev));
notify.success(t("deleteCategorySuccess"));
invalidateMenu();
},
onError: showError,
});
const addCategoryMutation = useMutation({ const addCategoryMutation = useMutation({
mutationFn: () => mutationFn: () =>
apiPost(`/api/cafes/${cafeId}/menu/categories`, { apiPost(`/api/cafes/${cafeId}/menu/categories`, {
@@ -893,20 +933,38 @@ export function MenuAdminScreen() {
</LabeledField> </LabeledField>
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 border-t border-border pt-4"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
<Button {editingItem ? (
variant="ghost" <Button
onClick={() => setItemModalOpen(false)} type="button"
> variant="ghost"
{tCommon("cancel")} className="text-red-600 hover:bg-red-50 hover:text-red-700"
</Button> onClick={() =>
<Button setConfirmDelete({
className="bg-[#0F6E56] hover:bg-[#0c5a46]" kind: "item",
disabled={!itemFormValid || itemMutationBusy} id: editingItem.id,
onClick={handleItemSave} name: editingItem.name,
> })
{itemMutationBusy ? t("saving") : tCommon("save")} }
</Button> >
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setItemModalOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!itemFormValid || itemMutationBusy}
onClick={handleItemSave}
>
{itemMutationBusy ? t("saving") : tCommon("save")}
</Button>
</div>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -941,20 +999,84 @@ export function MenuAdminScreen() {
} }
/> />
<div className="flex justify-end gap-2 border-t border-border pt-4"> <div className="flex items-center justify-between gap-2 border-t border-border pt-4">
<Button variant="ghost" onClick={() => setCatModalOpen(false)}> {editingCategory ? (
{tCommon("cancel")} <Button
</Button> type="button"
<Button variant="ghost"
className="bg-[#0F6E56] hover:bg-[#0c5a46]" className="text-red-600 hover:bg-red-50 hover:text-red-700"
disabled={!catForm.name.trim() || catMutationBusy} onClick={() =>
onClick={handleCategorySave} setConfirmDelete({
> kind: "category",
{catMutationBusy ? t("saving") : tCommon("save")} id: editingCategory.id,
</Button> name: editingCategory.name,
})
}
>
<Trash2 className="me-1.5 size-4" />
{tCommon("delete")}
</Button>
) : (
<span />
)}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={!catForm.name.trim() || catMutationBusy}
onClick={handleCategorySave}
>
{catMutationBusy ? t("saving") : tCommon("save")}
</Button>
</div>
</div> </div>
</div> </div>
</Modal> </Modal>
{/* ── Delete confirmation (items + categories) ──────────────────────── */}
<AlertDialog
open={!!confirmDelete}
onOpenChange={(open) => {
if (!open) setConfirmDelete(null);
}}
>
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>
{confirmDelete?.kind === "category"
? t("deleteCategoryConfirmTitle")
: t("deleteItemConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{confirmDelete?.kind === "category"
? t("deleteCategoryConfirmDesc", { name: confirmDelete?.name ?? "" })
: t("deleteItemConfirmDesc", { name: confirmDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 text-white hover:bg-red-700"
disabled={deleteItemMutation.isPending || deleteCategoryMutation.isPending}
onClick={(e) => {
e.preventDefault(); // keep dialog open until the mutation resolves
if (!confirmDelete) return;
if (confirmDelete.kind === "category") {
deleteCategoryMutation.mutate(confirmDelete.id);
} else {
deleteItemMutation.mutate(confirmDelete.id);
}
}}
>
{deleteItemMutation.isPending || deleteCategoryMutation.isPending
? t("saving")
: tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }
@@ -255,6 +255,7 @@ export function PosScreen() {
addItem, addItem,
removeItem, removeItem,
updateQty, updateQty,
setNotes,
couponCode, couponCode,
appliedCoupon, appliedCoupon,
setCouponCode, setCouponCode,
@@ -1210,10 +1211,11 @@ export function PosScreen() {
<div <div
key={line.orderItemId ?? line.menuItem.id} key={line.orderItemId ?? line.menuItem.id}
className={cn( className={cn(
"flex items-center gap-2 rounded-lg border border-border p-2", "flex flex-col gap-1.5 rounded-lg border border-border p-2",
line.isVoided && "opacity-60" line.isVoided && "opacity-60"
)} )}
> >
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<MenuItemLabels <MenuItemLabels
item={line.menuItem} item={line.menuItem}
@@ -1291,6 +1293,18 @@ export function PosScreen() {
</> </>
) : null} ) : null}
</div> </div>
</div>
{!line.isVoided && (
<input
type="text"
value={line.notes ?? ""}
onChange={(e) => setNotes(line.menuItem.id, e.target.value)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
placeholder={t("itemNotePlaceholder")}
className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
/>
)}
</div> </div>
)) ))
)} )}
@@ -407,29 +407,44 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
{cart.map((c) => ( {cart.map((c) => (
<div <div
key={c.item.id} key={c.item.id}
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0" className="flex flex-col gap-2 border-b px-3 py-3 last:border-0"
> >
<div className="min-w-0 flex-1"> <div className="flex items-center justify-between gap-3">
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" /> <div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: primary }}> <MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
{formatCurrency(effectiveLinePrice(c.item), "fa-IR")} <p className="text-sm font-medium" style={{ color: primary }}>
</p> {formatCurrency(effectiveLinePrice(c.item), "fa-IR")}
</div> </p>
<div className="flex items-center gap-2"> </div>
<QtyButton <div className="flex items-center gap-2">
label="" <QtyButton
onClick={() => removeFromCart(c.item.id)} label=""
variant="outline" onClick={() => removeFromCart(c.item.id)}
color={primary} variant="outline"
/> color={primary}
<span className="min-w-6 text-center font-semibold">{c.qty}</span> />
<QtyButton <span className="min-w-6 text-center font-semibold">{c.qty}</span>
label="+" <QtyButton
onClick={() => addToCart(c.item)} label="+"
variant="filled" onClick={() => addToCart(c.item)}
color={primary} variant="filled"
/> color={primary}
/>
</div>
</div> </div>
<input
type="text"
value={c.note ?? ""}
onChange={(e) =>
setCart((prev) =>
prev.map((l) =>
l.item.id === c.item.id ? { ...l, note: e.target.value } : l
)
)
}
placeholder={t("itemNote")}
className="w-full rounded-md border qr-border bg-transparent px-2 py-1.5 text-xs placeholder:opacity-60 focus:outline-none"
/>
</div> </div>
))} ))}
</div> </div>
@@ -4,7 +4,11 @@ import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { Trash2 } from "lucide-react";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -45,8 +49,11 @@ const statusStyle: Record<ReservationStatus, string> = {
export function ReservationsScreen() { export function ReservationsScreen() {
const t = useTranslations("reservations"); const t = useTranslations("reservations");
const tCommon = useTranslations("common");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = useState<Reservation | null>(null);
const [guestName, setGuestName] = useState(""); const [guestName, setGuestName] = useState("");
const [guestPhone, setGuestPhone] = useState("09121234567"); const [guestPhone, setGuestPhone] = useState("09121234567");
@@ -92,6 +99,16 @@ export function ReservationsScreen() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }),
}); });
const deleteReservation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/reservations/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
setDeleteTarget(null);
notify.success(t("deleted"));
},
onError: (err) => notify.error(apiError(err)),
});
if (!cafeId) return null; if (!cafeId) return null;
const posHref = (r: Reservation) => { const posHref = (r: Reservation) => {
@@ -245,6 +262,15 @@ export function ReservationsScreen() {
{t("markCompleted")} {t("markCompleted")}
</Button> </Button>
)} )}
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={tCommon("delete")}
onClick={() => setDeleteTarget(r)}
>
<Trash2 className="size-4" />
</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -252,6 +278,19 @@ export function ReservationsScreen() {
))} ))}
</ul> </ul>
)} )}
<ConfirmDialog
open={!!deleteTarget}
onOpenChange={(o) => {
if (!o) setDeleteTarget(null);
}}
title={t("deleteConfirmTitle")}
description={
deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.guestName }) : undefined
}
busy={deleteReservation.isPending}
onConfirm={() => deleteTarget && deleteReservation.mutate(deleteTarget.id)}
/>
</div> </div>
); );
} }
@@ -366,6 +366,26 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
> >
ذخیره موقعیت ذخیره موقعیت
</Button> </Button>
<Button
variant="outline"
onClick={() => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
notify.error("مرورگر شما موقعیت‌یابی را پشتیبانی نمی‌کند");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatInput(pos.coords.latitude.toFixed(5));
setLngInput(pos.coords.longitude.toFixed(5));
setLocationError(null);
},
() => notify.error("دسترسی به موقعیت امکان‌پذیر نبود. لطفاً اجازه دسترسی بدهید."),
{ enableHighAccuracy: true, timeout: 10000 }
);
}}
>
موقعیت فعلی من
</Button>
{(latInput || lngInput) && ( {(latInput || lngInput) && (
<Button <Button
variant="ghost" variant="ghost"
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useApiError } from "@/lib/use-api-error";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react"; import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
@@ -34,6 +35,7 @@ export function CheckoutScreen() {
const t = useTranslations("subscription"); const t = useTranslations("subscription");
const tc = useTranslations("subscription.checkout"); const tc = useTranslations("subscription.checkout");
const tPlans = useTranslations("settings.plans"); const tPlans = useTranslations("settings.plans");
const apiError = useApiError();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -66,6 +68,37 @@ export function CheckoutScreen() {
enabled: !!cafeId && isCafeOwner(role), enabled: !!cafeId && isCafeOwner(role),
}); });
// If the owner is still covered (active plan and/or queued plans), this purchase will be
// queued to start when the current coverage ends rather than activating immediately.
const { data: billingStatus } = useQuery({
queryKey: ["billing-status", cafeId],
queryFn: () =>
apiGet<{
planTier: string;
planExpiresAt: string | null;
isPlanExpired: boolean;
queuedPlans: { effectiveTo: string }[];
}>("/api/billing/status"),
enabled: !!cafeId && isCafeOwner(role),
});
const coverageEnd = useMemo(() => {
if (!billingStatus) return null;
const now = Date.now();
let end = 0;
if (
billingStatus.planTier !== "Free" &&
billingStatus.planExpiresAt &&
!billingStatus.isPlanExpired
) {
end = Math.max(end, new Date(billingStatus.planExpiresAt).getTime());
}
for (const q of billingStatus.queuedPlans ?? []) {
end = Math.max(end, new Date(q.effectiveTo).getTime());
}
return end > now ? new Date(end) : null;
}, [billingStatus]);
useEffect(() => { useEffect(() => {
if (!paymentMethod && paymentMethods.length > 0) { if (!paymentMethod && paymentMethods.length > 0) {
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0]; const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
@@ -81,8 +114,7 @@ export function CheckoutScreen() {
window.location.href = data.paymentUrl; window.location.href = data.paymentUrl;
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err); setPayError(apiError(err, tc("paymentFailed")));
setPayError(msg || tc("paymentFailed"));
}, },
}); });
@@ -139,6 +171,13 @@ export function CheckoutScreen() {
} }
/> />
{coverageEnd ? (
<div className="flex items-start gap-2 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/50 px-4 py-3 text-sm text-[#0F6E56]">
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
<p>{tc("queuedNotice", { date: coverageEnd.toLocaleDateString(numberLocale) })}</p>
</div>
) : null}
{/* Factor / invoice */} {/* Factor / invoice */}
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm"> <Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
{/* Invoice header */} {/* Invoice header */}
@@ -2,10 +2,11 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { CalendarClock, Trash2 } from "lucide-react";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
import { isCafeOwner } from "@/lib/auth-permissions"; import { isCafeOwner } from "@/lib/auth-permissions";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
@@ -14,9 +15,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { PlanComparison } from "@/components/settings/plan-comparison"; import { PlanComparison } from "@/components/settings/plan-comparison";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import type { AuthTokenResponse } from "@/lib/api/types"; import type { AuthTokenResponse } from "@/lib/api/types";
import { Alert } from "@/components/ui/alert"; import { Alert } from "@/components/ui/alert";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
type QueuedPlan = {
paymentId: string;
planTier: string;
months: number;
effectiveFrom: string;
effectiveTo: string;
amountToman: number;
};
type BillingStatus = { type BillingStatus = {
planTier: string; planTier: string;
@@ -30,6 +42,7 @@ type BillingStatus = {
menu3dEnabled: boolean; menu3dEnabled: boolean;
discoverProfileEnabled: boolean; discoverProfileEnabled: boolean;
isPlanExpired: boolean; isPlanExpired: boolean;
queuedPlans: QueuedPlan[];
}; };
export function SubscriptionScreen() { export function SubscriptionScreen() {
@@ -40,8 +53,11 @@ export function SubscriptionScreen() {
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const setAuth = useAuthStore((s) => s.setAuth); const setAuth = useAuthStore((s) => s.setAuth);
const apiError = useApiError();
const queryClient = useQueryClient();
const billingRefreshed = useRef(false); const billingRefreshed = useRef(false);
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null); const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
const [cancelTarget, setCancelTarget] = useState<QueuedPlan | null>(null);
useEffect(() => { useEffect(() => {
const billing = searchParams.get("billing"); const billing = searchParams.get("billing");
@@ -61,6 +77,18 @@ export function SubscriptionScreen() {
enabled: !!cafeId, enabled: !!cafeId,
}); });
const cancelQueued = useMutation({
mutationFn: (paymentId: string) => apiDelete(`/api/billing/queued/${paymentId}`),
onSuccess: () => {
setCancelTarget(null);
notify.success(t("queued.cancelled"));
queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
},
onError: (err) => notify.error(apiError(err)),
});
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString("fa-IR");
useEffect(() => { useEffect(() => {
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return; if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
const refresh = localStorage.getItem("meezi_refresh_token"); const refresh = localStorage.getItem("meezi_refresh_token");
@@ -155,12 +183,72 @@ export function SubscriptionScreen() {
</CardContent> </CardContent>
</Card> </Card>
{status?.queuedPlans && status.queuedPlans.length > 0 ? (
<Card className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/30 shadow-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="size-4 text-[#0F6E56]" aria-hidden />
{t("queued.title")}
</CardTitle>
<p className="text-sm text-muted-foreground">{t("queued.subtitle")}</p>
</CardHeader>
<CardContent className="space-y-2">
{status.queuedPlans.map((q) => (
<div
key={q.paymentId}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card px-3 py-2.5"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Badge>{q.planTier}</Badge>
<span className="text-sm text-muted-foreground">
{t("queued.months", { count: formatNumber(q.months) })}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{t("queued.window", { from: fmtDate(q.effectiveFrom), to: fmtDate(q.effectiveTo) })}
</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-red-600 hover:bg-red-50 hover:text-red-700"
onClick={() => setCancelTarget(q)}
>
<Trash2 className="me-1.5 size-4" />
{t("queued.cancel")}
</Button>
</div>
))}
</CardContent>
</Card>
) : null}
<PlanComparison <PlanComparison
currentPlan={status?.planTier ?? "Free"} currentPlan={status?.planTier ?? "Free"}
onSubscribe={(planTier) => onSubscribe={(planTier) =>
router.push(`/subscription/checkout?plan=${planTier}`) router.push(`/subscription/checkout?plan=${planTier}`)
} }
/> />
<ConfirmDialog
open={!!cancelTarget}
onOpenChange={(o) => {
if (!o) setCancelTarget(null);
}}
title={t("queued.cancelConfirmTitle")}
description={
cancelTarget
? t("queued.cancelConfirmDesc", {
plan: cancelTarget.planTier,
from: fmtDate(cancelTarget.effectiveFrom),
})
: undefined
}
confirmLabel={t("queued.cancel")}
busy={cancelQueued.isPending}
onConfirm={() => cancelTarget && cancelQueued.mutate(cancelTarget.paymentId)}
/>
</div> </div>
); );
} }
@@ -6,6 +6,7 @@ import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -52,6 +53,7 @@ function formatDate(iso: string) {
export function SupportScreen() { export function SupportScreen() {
const t = useTranslations("support"); const t = useTranslations("support");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
@@ -61,6 +63,7 @@ export function SupportScreen() {
data: tickets = [], data: tickets = [],
isLoading, isLoading,
isError, isError,
error,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["support", cafeId], queryKey: ["support", cafeId],
@@ -135,7 +138,7 @@ export function SupportScreen() {
</p> </p>
{isError ? ( {isError ? (
<Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive"> <Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive">
<p>{t("loadFailed")}</p> <p>{apiError(error, t("loadFailed"))}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}> <Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}>
{t("retry")} {t("retry")}
</Button> </Button>
@@ -7,6 +7,7 @@ import * as signalR from "@microsoft/signalr";
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react"; import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { MediaPairUpload } from "@/components/media/media-pair-upload"; import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { import {
@@ -53,6 +54,7 @@ export function TablesScreen() {
const branchId = useBranchStore((s) => s.branchId); const branchId = useBranchStore((s) => s.branchId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const confirmDialog = useConfirm(); const confirmDialog = useConfirm();
const apiError = useApiError();
const [actionMessage, setActionMessage] = useState<string | null>(null); const [actionMessage, setActionMessage] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [number, setNumber] = useState(""); const [number, setNumber] = useState("");
@@ -123,7 +125,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError"); const msg = apiError(err, t("createError"));
setActionMessage(msg); setActionMessage(msg);
notify.error(msg); notify.error(msg);
}, },
@@ -142,7 +144,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
setActionMessage(err instanceof ApiClientError ? err.message : t("cleaningError")); setActionMessage(apiError(err, t("cleaningError")));
}, },
}); });
@@ -158,7 +160,7 @@ export function TablesScreen() {
setActionMessage(t("tableHasOpenOrder")); setActionMessage(t("tableHasOpenOrder"));
return; return;
} }
setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError")); setActionMessage(apiError(err, t("deleteError")));
}, },
}); });
@@ -188,7 +190,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError"); const msg = apiError(err, t("createError"));
setActionMessage(msg); setActionMessage(msg);
notify.error(msg); notify.error(msg);
}, },
@@ -0,0 +1,68 @@
"use client";
import { useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
/**
* Shared confirmation dialog (used for destructive delete actions across screens).
* Keeps the dialog open while `busy` so the row stays until the mutation resolves;
* the caller closes it via onOpenChange(false) on success.
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
onConfirm,
busy = false,
destructive = true,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmLabel?: string;
onConfirm: () => void;
busy?: boolean;
destructive?: boolean;
}) {
const tCommon = useTranslations("common");
const isRtl = useIsRtl();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
className={destructive ? "bg-red-600 text-white hover:bg-red-700" : ""}
disabled={busy}
onClick={(e) => {
e.preventDefault(); // keep open until the mutation resolves
onConfirm();
}}
>
{busy ? tCommon("loading") : confirmLabel ?? tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+5
View File
@@ -4,6 +4,11 @@ import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["fa", "ar", "en"], locales: ["fa", "ar", "en"],
defaultLocale: "fa", defaultLocale: "fa",
// Iran-first: don't auto-pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). A locale-less URL defaults to
// fa; the locale is otherwise taken from the URL prefix or the marketing-site
// link (e.g. app.meezi.ir/fa/login).
localeDetection: false,
}); });
export const { Link, redirect, usePathname, useRouter } = export const { Link, redirect, usePathname, useRouter } =
@@ -8,6 +8,7 @@ export type CafeProfileEdit = {
instagramHandle: string | null; instagramHandle: string | null;
websiteUrl: string | null; websiteUrl: string | null;
workingHours: WorkingHours | null; workingHours: WorkingHours | null;
showOnKoja: boolean;
}; };
export type UpdateCafeProfilePayload = { export type UpdateCafeProfilePayload = {
@@ -15,6 +16,7 @@ export type UpdateCafeProfilePayload = {
instagramHandle?: string | null; instagramHandle?: string | null;
websiteUrl?: string | null; websiteUrl?: string | null;
workingHours?: WorkingHours | null; workingHours?: WorkingHours | null;
showOnKoja?: boolean;
}; };
async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> { async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
+4 -1
View File
@@ -46,7 +46,10 @@ export const notify = {
}; };
export function getErrorMessage(err: unknown, fallback: string): string { export function getErrorMessage(err: unknown, fallback: string): string {
if (err instanceof ApiClientError) return err.message; // ApiClientError.message is the raw (usually English) backend message; prefer
// the caller's localized fallback. For code-specific localized text, use the
// useApiError() hook instead of this helper.
if (err instanceof ApiClientError) return fallback;
if (err instanceof Error && err.message) return err.message; if (err instanceof Error && err.message) return err.message;
return fallback; return fallback;
} }
@@ -34,6 +34,7 @@ interface CartState {
addItem: (item: MenuItem) => void; addItem: (item: MenuItem) => void;
removeItem: (menuItemId: string) => void; removeItem: (menuItemId: string) => void;
updateQty: (menuItemId: string, quantity: number) => void; updateQty: (menuItemId: string, quantity: number) => void;
setNotes: (menuItemId: string, notes: string) => void;
setCouponCode: (code: string) => void; setCouponCode: (code: string) => void;
setAppliedCoupon: (coupon: AppliedCoupon | null) => void; setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
clearCoupon: () => void; clearCoupon: () => void;
@@ -135,6 +136,13 @@ export const useCartStore = create<CartState>((set, get) => ({
}); });
}, },
setNotes: (menuItemId, notes) =>
set({
items: get().items.map((i) =>
i.menuItem.id === menuItemId ? { ...i, notes: notes.trim() || undefined } : i
),
}),
setCouponCode: (code) => set({ couponCode: code }), setCouponCode: (code) => set({ couponCode: code }),
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }), setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
clearCoupon: () => set(clearCouponState), clearCoupon: () => set(clearCouponState),
+21
View File
@@ -0,0 +1,21 @@
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 => {
if (err instanceof ApiClientError && err.code && t.has(err.code)) {
return t(err.code);
}
return fallback ?? t("generic");
};
}
+5 -10
View File
@@ -50,16 +50,11 @@ const nextConfig: NextConfig = {
{ protocol: "http", hostname: "**" }, { protocol: "http", hostname: "**" },
], ],
}, },
async redirects() { // NOTE: the previous "short URL" redirect (/:slug → /fa/cafe/:slug) matched
return [ // single-segment paths INCLUDING the locale itself, so "/fa" redirected to
// Short URL: koja.meezi.ir/my-cafe → koja.meezi.ir/fa/cafe/my-cafe // "/fa/cafe/fa" (and "/en" → "/fa/cafe/en") — a non-existent slug that 500'd
{ // the home page. Removed; re-add via middleware with explicit reserved-word
source: "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])", // exclusions if short café URLs are needed.
destination: "/fa/cafe/:slug",
permanent: false,
},
];
},
}; };
export default withPWA(withNextIntl(nextConfig)); export default withPWA(withNextIntl(nextConfig));
+18 -5
View File
@@ -70,16 +70,29 @@ export default async function CafePage({
const t = await getTranslations({ locale, namespace: "cafe" }); const t = await getTranslations({ locale, namespace: "cafe" });
const isFa = locale === "fa"; const isFa = locale === "fa";
const [cafe, menu, reviews] = await Promise.all([ // Resolve the café first so an unknown slug 404s cleanly instead of doing
getCafe(slug), // (and potentially erroring on) the menu/review fetches.
const cafe = await getCafe(slug);
if (!cafe) notFound();
const [menu, reviews] = await Promise.all([
getCafeMenu(slug), getCafeMenu(slug),
getCafeReviews(slug), getCafeReviews(slug),
]); ]);
if (!cafe) notFound();
const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name); const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name);
const profile = cafe.discoverProfile; // discoverProfile may be absent for cafés that never filled it in — fall back
// to an empty profile so the page renders instead of throwing a 500.
const profile = cafe.discoverProfile ?? {
themes: [],
size: null,
floors: null,
vibes: [],
occasions: [],
spaceFeatures: [],
noiseLevel: null,
priceTier: null,
};
const priceTier = profile.priceTier; const priceTier = profile.priceTier;
// Similar cafes // Similar cafes
+4 -3
View File
@@ -11,7 +11,8 @@ interface Props {
export function CafeCard({ cafe, locale, href }: Props) { export function CafeCard({ cafe, locale, href }: Props) {
const isFa = locale === "fa"; const isFa = locale === "fa";
const name = isFa ? cafe.name : (cafe.name); const name = isFa ? cafe.name : (cafe.name);
const priceTier = cafe.discoverProfile.priceTier; const priceTier = cafe.discoverProfile?.priceTier ?? null;
const themes = cafe.discoverProfile?.themes ?? [];
const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null; const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null;
return ( return (
@@ -72,9 +73,9 @@ export function CafeCard({ cafe, locale, href }: Props) {
)} )}
{/* Tags */} {/* Tags */}
{cafe.discoverProfile.themes.length > 0 && ( {themes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{cafe.discoverProfile.themes.slice(0, 3).map((tag) => ( {themes.slice(0, 3).map((tag) => (
<span <span
key={tag} key={tag}
className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700" className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700"
+2 -2
View File
@@ -46,11 +46,11 @@ export function CafeJsonLd({ cafe, locale, baseUrl }: Props) {
worstRating: "1", worstRating: "1",
}, },
} : {}), } : {}),
...(cafe.discoverProfile.themes.length ? { ...(cafe.discoverProfile?.themes?.length ? {
servesCuisine: cafe.discoverProfile.themes, servesCuisine: cafe.discoverProfile.themes,
} : {}), } : {}),
priceRange: (() => { priceRange: (() => {
const tier = cafe.discoverProfile.priceTier; const tier = cafe.discoverProfile?.priceTier;
if (tier === "budget") return "﷼"; if (tier === "budget") return "﷼";
if (tier === "moderate") return "﷼﷼"; if (tier === "moderate") return "﷼﷼";
if (tier === "upscale") return "﷼﷼﷼"; if (tier === "upscale") return "﷼﷼﷼";
+3
View File
@@ -3,4 +3,7 @@ import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["fa", "en"], locales: ["fa", "en"],
defaultLocale: "fa", defaultLocale: "fa",
// Iran-first: don't pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). Locale-less URLs default to fa.
localeDetection: false,
}); });
@@ -41,7 +41,7 @@ const fa = {
desc: "از داشبورد میزی در دسترس است", desc: "از داشبورد میزی در دسترس است",
value: "چت زنده", value: "چت زنده",
cta: "ورود به داشبورد", cta: "ورود به داشبورد",
href: "https://app.meezi.ir", href: "https://app.meezi.ir/fa",
}, },
], ],
officeTitle: "دفتر مرکزی", officeTitle: "دفتر مرکزی",
@@ -79,7 +79,7 @@ const en = {
desc: "Available inside the Meezi dashboard", desc: "Available inside the Meezi dashboard",
value: "Live chat", value: "Live chat",
cta: "Go to dashboard", cta: "Go to dashboard",
href: "https://app.meezi.ir", href: "https://app.meezi.ir/en",
}, },
], ],
officeTitle: "Head Office", officeTitle: "Head Office",
+2 -2
View File
@@ -93,7 +93,7 @@ export function Navbar() {
{locale === "fa" ? "EN" : "فا"} {locale === "fa" ? "EN" : "فا"}
</button> </button>
<a <a
href="https://app.meezi.ir/login" href={`https://app.meezi.ir/${locale}/login`}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" className="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
> >
{t("login")} {t("login")}
@@ -143,7 +143,7 @@ export function Navbar() {
</ul> </ul>
<div className="mt-3 flex flex-col gap-2 border-t border-gray-100 pt-3"> <div className="mt-3 flex flex-col gap-2 border-t border-gray-100 pt-3">
<a <a
href="https://app.meezi.ir/login" href={`https://app.meezi.ir/${locale}/login`}
className="block rounded-lg px-3 py-2.5 text-center text-sm font-medium text-gray-600 hover:bg-gray-50" className="block rounded-lg px-3 py-2.5 text-center text-sm font-medium text-gray-600 hover:bg-gray-50"
> >
{t("login")} {t("login")}
@@ -101,7 +101,7 @@ export function LaunchCountdownSection() {
</div> </div>
<a <a
href="https://app.meezi.ir/register" href={`https://app.meezi.ir/${locale}/register`}
className={cn( className={cn(
"inline-flex items-center justify-center rounded-xl bg-brand-700 px-6 py-3 text-sm font-semibold text-white", "inline-flex items-center justify-center rounded-xl bg-brand-700 px-6 py-3 text-sm font-semibold text-white",
"transition hover:bg-brand-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-700 focus-visible:ring-offset-2" "transition hover:bg-brand-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-700 focus-visible:ring-offset-2"
@@ -34,7 +34,7 @@ export function PricingSection() {
priceNote: t("freePriceNote"), priceNote: t("freePriceNote"),
desc: t("freeDesc"), desc: t("freeDesc"),
cta: t("ctaFree"), cta: t("ctaFree"),
href: "https://app.meezi.ir/register", href: `https://app.meezi.ir/${locale}/register`,
features: [t("f1"), t("f2"), t("f3"), t("f4"), t("f5")], features: [t("f1"), t("f2"), t("f3"), t("f4"), t("f5")],
popular: false, popular: false,
variant: "outline", variant: "outline",