Compare commits

...

32 Commits

Author SHA1 Message Date
soroush.asadi b5a6b1b68d fix(website): accurate Iran border on homepage map + slow on/off marker blink
CI/CD / CI · API (dotnet build + test) (pull_request) Successful in 46s
CI/CD / CI · Admin API (dotnet build) (pull_request) Successful in 31s
CI/CD / CI · Dashboard (tsc) (pull_request) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (pull_request) Successful in 35s
CI/CD / CI · Website (tsc) (pull_request) Successful in 45s
CI/CD / CI · Koja (tsc) (pull_request) Successful in 51s
CI/CD / Deploy · all services (pull_request) Has been skipped
Replaced the rough 40-point hand-drawn polygon with the real national border (74 vertices, Natural Earth via world.geo.json) and fitted the projection bounding box to Iran's true extent, so the silhouette is recognisable and café markers stay aligned. Reworked the marker animation from a radar-style expanding ring into a slow 3.6s ease-in-out lamp fade (opacity 1->0.2->1) with a halo that glows on and off in sync. Verified via the SVG timeline: opacity 1.0 at 0s, 0.2 at 1.8s, 1.0 at 3.6s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 21:38:25 +03:30
soroush.asadi f813cc4854 test: repair test suite broken by feature drift (red -> 81 passing)
CI/CD / CI · API (dotnet build + test) (pull_request) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (pull_request) Successful in 44s
CI/CD / CI · Dashboard (tsc) (pull_request) Successful in 1m7s
CI/CD / CI · Admin Web (tsc) (pull_request) Successful in 36s
CI/CD / CI · Website (tsc) (pull_request) Successful in 45s
CI/CD / CI · Koja (tsc) (pull_request) Successful in 51s
CI/CD / Deploy · all services (pull_request) Has been skipped
The test project no longer compiled: recent feature commits changed
interfaces and DTOs without updating the test doubles/call sites, so the
whole suite (and therefore CI) was failing to build.

- NoOpInventoryService: add IInventoryService.GetPurchasesSummaryAsync and
  the new string? userId param on AdjustAsync.
- NoOpLoyaltyService: add ILoyaltyService.RedeemOnOrderAsync.
- NoOpOrderNotificationService: add NotifyCallWaiterAsync.
- New NoOpAbuseProtectionService and NoOpMediaStorageService test doubles.
- QrMenuTests: ReviewService/PublicService gained IAbuseProtectionService +
  IHttpContextAccessor (and ReviewService an IMediaStorageService); wire the
  new no-op doubles + a real HttpContextAccessor.
- PrintingTests: OrderDto gained a DisplayNumber int between CreatedAt and
  Items; pass it.
- DiscoverFilterTests: add missing `using Xunit;` and the new openNow arg on
  DiscoverFilterParams.FromQuery.

Result: dotnet test -> Passed: 81, Failed: 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:44:41 +03:30
soroush.asadi 024a455ab3 fix: menu item/category create, demo banner reach, token refresh, blog publish
Dashboard & API bug fixes for owner-reported breakage:

- MenuController validators (PosValidators): NameEn was required but the
  dashboard sends null when blank, so every manual menu-item create failed
  and category create failed 100% (the form never sends nameEn). Now optional.
- DemoDataBanner: only showed when a cafe was exactly empty, so
  showcase-seeded cafes (2-3 cats / 3-5 items) could never trigger the
  one-click seed. Widened gate to sparse menus (<5 cats && <10 items) and
  added a clear "nothing to add" message when already populated.
- client.ts: added one-time JWT refresh-and-retry on 401 (shared in-flight
  promise) before bouncing to /login. Expired access tokens silently broke
  ticket list, add-table, and other reads.
- Surface API errors as toasts on menu + table mutations (were swallowed
  silently, so failures looked like "nothing happens").
- Admin blog editor: saving an edit dropped IsPublished (defaulted false,
  silently unpublishing the post on every save); now persisted with a
  toggle. Also hoisted the inner Field component to module scope - it was
  remounting every input on each keystroke and dropping focus.
- Admin integrations: replaced raw radio gateway selector with a styled
  RadioDot matching the iOS toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:25:34 +03:30
soroush.asadi f687178238 fix(migration): add [Migration] attribute so EF discovers AddCafeLocation
CI/CD / CI · API (dotnet build + test) (push) Successful in 57s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 46s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 1m27s
Manual migration was missing the [Migration("...")] and [DbContext] attributes
that EF Core requires to discover and apply migrations via MigrateAsync().
Without them the Latitude/Longitude columns were never added to Cafes, causing
every query involving the Cafe entity to throw 42703 column-not-found errors.

Columns must be applied manually on the server before the next deploy:
  ALTER TABLE "Cafes" ADD COLUMN IF NOT EXISTS "Latitude" double precision, ...

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:59:08 +03:30
soroush.asadi dc07eb9594 ci: prune dangling images after successful deploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 58s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m1s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 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 23s
Removes <none>-tagged images left over from previous builds.
Only affects untagged images (dangling=true) — never touches other
projects' named images (soroushasadi-site, drsousan, etc.).
Also logs disk usage after prune.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:35:18 +03:30
soroush.asadi 5e980cdfc0 feat: plan limits, café location, nearby API, Iran map section
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 49s
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 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m15s
• PlanLimits: add MaxMenuCategories (Free→3), MaxMenuItems (Free→30),
  CanAccessCrm and CanAccessStatistics (Pro+ only)
• MenuController: enforce category/item limits before create (403 + PLAN_LIMIT_REACHED)
• Cafe entity + EF migration: Latitude/Longitude (double?, nullable)
• CafeSettingsController: PATCH accepts lat/lng with range validation
• PublicController: GET /api/public/map-markers (marketing SVG map feed)
  and GET /api/public/nearby (Koja nearby-cafés with Haversine sort)
• Dashboard settings: location card with OSM iframe preview + Neshan link
• Website homepage: IranMapSection — stylised SVG silhouette with
  SMIL-animated blinking dots at real café coordinates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 15:09:09 +03:30
soroush.asadi 665e3ca279 fix(demo): scope category/item IDs per café to prevent PK collisions
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
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 1m31s
DemoMenuSeeder used hardcoded IDs like cat_demo_coffee for every café.
If the dev seeder (runs when ASPNETCORE_ENVIRONMENT=Development) already
inserted those IDs for cafe_demo_001, a production café clicking
"Add demo data" hit a primary-key constraint violation.

Fix: EnsureMenuAsync now accepts useScopedIds=true which prefixes every
category and item ID with cafeId (e.g. cafe_abc_cat_demo_coffee).
CategoryId FKs on items are remapped through the same function.

DemoSeedService (the API endpoint handler) always passes useScopedIds=true.
DevelopmentDataSeeder keeps useScopedIds=false (default) so the existing
cafe_demo_001 rows in dev databases are not touched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:59:48 +03:30
soroush.asadi c3ca39ed15 fix(ci): staged deploy with crash detection and full diagnostics
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m4s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 35s
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 54s
CI/CD / Deploy · all services (push) Successful in 22s
- Start api alone first; web/admin-api each wait for their own step
  so a health-check wait never blocks unrelated services
- Detect crash-loops via RestartCount > 1 (restart:unless-stopped hides
  the exited state behind rapid restarts — count is reliable)
- Dump up to 120 lines of api logs immediately on crash/timeout
- Log infra network state (json) in attach step + failure dump so we
  can see exactly which aliases are registered on meezi_default
- admin-api and admin-web are now started in separate steps, same pattern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 13:44:52 +03:30
soroush.asadi dac59cd180 fix(ci): use --alias when connecting infra to meezi_default network
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 47s
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 48s
CI/CD / Deploy · all services (push) Successful in 1m36s
Without --alias, meezi-db joins meezi_default but is only reachable
as "meezi-db". The API uses Host=postgres — DNS lookup fails after
~5s, migration throws, container crashes.

Fix: disconnect first, then reconnect with service-name aliases
so "postgres" and "redis" resolve correctly on meezi_default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:36:03 +03:30
soroush.asadi c3ea07d6e4 fix(ci): attach infra containers to meezi_default network before deploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 36s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m2s
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 48s
CI/CD / Deploy · all services (push) Failing after 10s
postgres/redis were created before the compose project name was locked
to "meezi", so they're on a different Docker network. New app containers
join meezi_default — the API crashes immediately because it can't reach
Host=postgres.

Fix: create meezi_default if needed, then docker network connect
meezi-db and meezi-redis to it before starting the app containers.

Also dump API and admin-api logs on failure to make future failures
easier to diagnose.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:39:35 +03:30
soroush.asadi aea1d20fdc fix(admin): redirect to edit page after creating blog post
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 29s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Failing after 2m2s
Root cause: after successful creation the form stayed on /blog/new.
User couldn't tell it worked, clicked Save again, the second attempt
hit the unique slug constraint and showed an error — making it look
like creation was broken.

Fix: adminPost is now typed, onSuccess redirects to /blog/{id} on new
posts so the user lands on the edit page immediately.

Also fixes commentCount being undefined in the list (MapPost now
includes comment count via eager-loaded Comments).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:24:57 +03:30
soroush.asadi 9f002433c7 fix(ci): explicitly stop old app containers before redeploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
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 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Failing after 7s
Existing containers lack compose project labels so Compose cannot claim
them — it tries to CREATE alongside them and hits a name conflict.

Fix: stop + rm only the 6 meezi app containers by name before compose up.
Postgres, redis, and all other projects are never touched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 08:08:19 +03:30
soroush.asadi 5ba09c2ef1 fix(website): move launch countdown to 1 Tir 1405 (June 22, 2026)
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Failing after 1m27s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:59:22 +03:30
soroush.asadi 631cac8c3c fix(ci): never touch postgres/redis in deploy — app containers only
CI/CD / CI · API (dotnet build + test) (push) Successful in 54s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 45s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
The meezi-redis container was created before the name:meezi label existed
in docker-compose.yml, so Compose doesn't recognise it as its own project
container and tries to CREATE a new one, causing the name conflict.

Real fix: postgres and redis are persistent infrastructure — CI should
never restart them. Remove them from all deploy steps entirely.
Only api, web, website, koja, admin-api, admin-web are cycled on deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:56:16 +03:30
soroush.asadi 1a6a0dc495 fix(ci): split infra vs app services to prevent redis name conflict
CI/CD / CI · API (dotnet build + test) (push) Successful in 43s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
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) Failing after 10s
docker compose up without --no-recreate tries to recreate postgres/redis
when it detects a config change or finds a stopped container, which causes
"container name already in use" when the container is still running.

Fix: infrastructure (postgres, redis) uses --no-recreate so a healthy
container is never touched. App services (api, web, website, koja,
admin-api, admin-web) use --force-recreate so freshly-built images are
always applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 07:47:07 +03:30
soroush.asadi ffdc218e20 fix(admin): add missing useEffect import in admin-website-screens
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m8s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 54s
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 44s
CI/CD / CI · Koja (tsc) (push) Successful in 47s
CI/CD / Deploy · all services (push) Failing after 3m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:29:04 +03:30
soroush.asadi 260429afba feat: demo data seeder — one-click setup for new cafés
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Has been cancelled
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
Adds POST /api/cafes/{cafeId}/demo/seed (owner-only) that seeds:
- 9% default VAT tax
- 7 menu categories + 59+ items via DemoMenuSeeder
- 15 inventory ingredients (coffee shop staples)
- 10 tables across 3 floors on the first active branch

Frontend DemoDataBanner appears on menu, tables, and inventory
pages when the café is completely empty, so owners can populate
demo data in one click instead of entering everything manually.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:27:34 +03:30
soroush.asadi ae5896d440 fix: credentials lost on refresh + admin UI improvements + CI safe deploy
CI/CD / CI · API (dotnet build + test) (push) Successful in 39s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m2s
CI/CD / CI · Admin Web (tsc) (push) Failing after 35s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Has been skipped
- dashboard layout: wait for Zustand _hasHydrated before redirecting to /login
  (was redirecting on first render before localStorage was read)
- admin shell: same fix using new _hasHydrated on admin auth store
- admin-auth.store: add _hasHydrated + onRehydrateStorage to mirror merchant store
- AdminPlansScreen: replace direct cache mutation with per-plan PlanCard component
  that owns its own useState — fixes other plans disappearing after save
- AdminSettingsScreen: detect boolean values and render iOS-style Toggle switches
- AdminIntegrationsScreen: replace all <input type=checkbox> with Toggle switches;
  replace OpenAI model text input with <select> dropdown (gpt-4o-mini/4o/4-turbo/4/3.5)
- blog editor: fix form never syncing existing post data into state (editing was broken);
  all fields now use local form state, save uses form directly
- blog links: fix broken relative hrefs (website/blog/new → /admin/website/blog/new)
  and back button using proper Link components
- ci-cd: remove image prune step entirely — never removes containers or images

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:56:16 +03:30
soroush.asadi aec5b21f98 fix: lock compose project name to 'meezi', scope image prune to meezi only
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 1m22s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m0s
CI/CD / CI · Admin Web (tsc) (push) Successful in 33s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 47s
CI/CD / Deploy · all services (push) Failing after 2s
Prevents runner workspace collisions with other projects (DrSousan etc.)
causing containers to be treated as orphans and stopped on deploy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 23:45:07 +03:30
soroush.asadi 57c83185da fix: zarinpal silent failure + show payment error in checkout
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m15s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
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 43s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 4m45s
Previously the subscribe mutation had no onError handler, so any
payment initiation failure (wrong merchant ID, ZarinPal API error,
disabled payment method) would silently re-enable the button with
no user feedback. Now errors are shown below the Pay button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:40:04 +03:30
soroush.asadi cd1af30bbc fix: sidebar accordion + koja slug + support ticket LINQ crash
CI/CD / CI · API (dotnet build + test) (push) Successful in 5m50s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m3s
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 48s
CI/CD / Deploy · all services (push) Has been cancelled
Sidebar:
- All groups start collapsed on first load (v4 storage key resets old state)
- Opening one group closes all others (accordion)
- Navigating to a section opens only that section's group

Koja slug:
- SlugHelper: Persian->Latin transliteration, slug validation
- Registration accepts optional custom slug; auto-derives from cafe name
- Slug can be updated from dashboard Settings -> Profile
- Settings PATCH validates uniqueness (SLUG_TAKEN) and format (INVALID_SLUG)
- koja.meezi.ir/{slug} now redirects to /fa/cafe/{slug} (short URL support)

Bug fix:
- SupportTicketService: cafeId/status filters applied before Select() projection
  to fix EF "could not be translated" crash on the support tickets page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 22:28:25 +03:30
soroush.asadi 38e3f6a5a2 fix(admin-auth): normalize phone before OTP validation to fix 400 on verify-otp
CI/CD / CI · API (dotnet build + test) (push) Successful in 46s
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 34s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 27s
VerifyOtpRequestValidator was passing the raw phone string to
IsValidIranMobile which requires a pre-normalized 11-digit "09…" string.
Any other format (country code prefix, Persian digits, etc.) failed
validation instantly — causing verify-otp to return HTTP 400 in ~2ms
before the service logic could ever run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 21:00:37 +03:30
soroush.asadi 5ae350e25b fix: auto-create default branch on cafe registration + backfill existing
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
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 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
- VerifyRegisterAsync: create a Branch named after the café alongside
  the Café and Owner, so new owners can use the dashboard immediately
  without hitting the "select a branch" gate
- PlatformDataSeeder: EnsureDefaultBranchesAsync runs on every boot and
  creates a default branch for any existing café that has none (covers
  cafés registered before this fix)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:53:33 +03:30
soroush.asadi 255695e8ae fix(deploy): auto-migrate on boot + seed admin credentials from env
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 43s
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 46s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 3m36s
- docker-compose.admin.yml: RUN_MIGRATIONS was hardcoded false → now
  uses ${RUN_MIGRATIONS:-true} so migrations run automatically on deploy
- Both compose files: expose Seed__SystemAdminPhone/Username/Password
  env vars so the seeder sets admin credentials without manual SQL
- .env.example: document SEED_ADMIN_* variables

On next deploy: migrations run, Username='admin' is patched on the
existing admin, and password is hashed from SEED_ADMIN_PASSWORD.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:10:56 +03:30
soroush.asadi a4975cdb2d fix(seeder): patch existing admin username/password on every boot
CI/CD / CI · API (dotnet build + test) (push) Has been cancelled
CI/CD / CI · Admin API (dotnet build) (push) Has been cancelled
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled
EnsureOwnerAdminAsync now sets Username='admin' (configurable via
Seed:SystemAdminUsername) on any existing admin that has no username,
and hashes Seed:SystemAdminPassword if provided and no hash is stored.
Covers fresh deploys and existing prod admins created before credentials
were added.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 20:07:33 +03:30
soroush.asadi 639d5c305e feat: username/password authentication for admin and merchant panels
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
- Add PasswordHasher utility (PBKDF2/SHA-256, 100k iterations)
- Add Username + PasswordHash fields to Employee and SystemAdmin entities
- EF migration: AddPasswordLogin (nullable columns on both tables)
- Meezi.API: POST /api/auth/login (employee password login, CHOOSE_CAFE support)
- Meezi.API: PUT/DELETE /api/cafes/{id}/employees/{id}/credentials (Owner/Manager only)
- Meezi.Admin.API: POST /api/admin/auth/login + PUT /api/admin/auth/password
- Dashboard login page: OTP / Password tabs
- Admin login page: OTP / Password tabs
- HR screen: new Credentials tab for setting employee username/password
- PlatformDataSeeder: ensure system admin + integration settings in production
- Trial countdown banner: updated deadline to 1 Tir 1405 (Jun 22)
- i18n: fa/en/ar updated for all new UI strings

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 19:58:54 +03:30
soroush.asadi d0117f3171 fix(dashboard): sync lockfile and bump three to satisfy model-viewer peer
CI/CD / CI · API (dotnet build + test) (push) Successful in 59s
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 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 51s
CI/CD / Deploy · all services (push) Successful in 33s
npm ci failed in Docker because package-lock.json was stale (missing three
and the workbox/PWA deps) and @google/model-viewer@4.2.0 requires three@^0.182.0
while package.json pinned ^0.163.0. Bumped three and regenerated the lockfile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 14:16:42 +03:30
soroush.asadi 2c15ae0062 CI CD 6
CI/CD / CI · API (dotnet build + test) (push) Successful in 44s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 31s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m7s
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 48s
CI/CD / Deploy · all services (push) Failing after 59s
2026-05-31 13:01:38 +03:30
soroush.asadi 861b762e18 CI CD 5
CI/CD / CI · API (dotnet build + test) (push) Successful in 54s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 35s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
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 53s
CI/CD / Deploy · all services (push) Failing after 10m25s
2026-05-31 12:34:04 +03:30
soroush.asadi 234649c65e CI CD 4
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 47s
CI/CD / CI · Dashboard (tsc) (push) Successful in 2m3s
CI/CD / CI · Admin Web (tsc) (push) Successful in 44s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Failing after 14m30s
2026-05-31 12:03:46 +03:30
soroush.asadi a9222590ac CI CD 3
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 34s
CI/CD / CI · Admin Web (tsc) (push) Successful in 35s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m44s
CI/CD / CI · Website (tsc) (push) Successful in 5m27s
CI/CD / CI · Koja (tsc) (push) Successful in 4m16s
CI/CD / Deploy · all services (push) Failing after 1m19s
2026-05-31 11:36:48 +03:30
soroush.asadi aec68eff34 CI CD 2 2026-05-31 11:31:05 +03:30
85 changed files with 9048 additions and 572 deletions
+8
View File
@@ -68,6 +68,14 @@ REDIS_PORT=6381
# ── Migrations ────────────────────────────────────────────────────────────────
RUN_MIGRATIONS=true
# ── System admin seed (admin panel login) ─────────────────────────────────────
# On every boot the seeder ensures this admin exists with these credentials.
# Username defaults to "admin" if not set. Password is required to enable
# password login — leave blank to force OTP-only login.
SEED_ADMIN_PHONE=09190345606
SEED_ADMIN_USERNAME=admin
SEED_ADMIN_PASSWORD=change-me-strong-admin-password
# ── Payment: ZarinPal ─────────────────────────────────────────────────────────
# Get your merchant ID from: https://panel.zarinpal.com → API → MerchantID
ZARINPAL_MERCHANT_ID=
+117 -29
View File
@@ -158,7 +158,7 @@ jobs:
- name: Install dependencies
working-directory: web/dashboard
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check
working-directory: web/dashboard
@@ -188,7 +188,7 @@ jobs:
- name: Install dependencies
working-directory: web/admin
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check
working-directory: web/admin
@@ -218,7 +218,7 @@ jobs:
- name: Install dependencies
working-directory: web/website
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check
working-directory: web/website
@@ -248,7 +248,7 @@ jobs:
- name: Install dependencies
working-directory: web/koja
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/
run: npm install --legacy-peer-deps --ignore-scripts --registry https://mirror.soroushasadi.com/repository/npm-group/ --strict-ssl=false
- name: TypeScript check
working-directory: web/koja
@@ -310,45 +310,133 @@ jobs:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
- name: Start main services
- name: Stop old app containers
# The existing containers were created before compose labels were added,
# so Compose can't claim them and hits a name conflict on 'up'.
# This step removes only meezi's own 6 app containers — never touches
# postgres, redis, or any other project's containers.
run: |
docker compose up -d \
--no-deps \
postgres redis api web website koja
for name in meezi-api meezi-web meezi-website meezi-koja meezi-admin-api meezi-admin-web; do
docker stop "$name" 2>/dev/null || true
docker rm "$name" 2>/dev/null || true
done
- name: Start admin services
- name: Attach infrastructure to meezi network
# postgres/redis may be on a different network (created before name:meezi
# was in the compose file). Disconnect/reconnect with service-name aliases
# so the API can resolve "Host=postgres" and "redis:6379".
# App containers are stopped at this point so the brief disconnect is safe.
run: |
docker network inspect meezi_default >/dev/null 2>&1 \
|| docker network create meezi_default
docker network disconnect meezi_default meezi-db 2>/dev/null || true
docker network disconnect meezi_default meezi-redis 2>/dev/null || true
docker network connect --alias postgres meezi_default meezi-db
docker network connect --alias redis meezi_default meezi-redis
echo "=== infra network state ==="
docker inspect meezi-db --format='meezi-db networks={{json .NetworkSettings.Networks}}' 2>&1 || true
docker inspect meezi-redis --format='meezi-redis networks={{json .NetworkSettings.Networks}}' 2>&1 || true
- name: Start API
# --no-deps skips all depends_on checks so compose starts api immediately
# without trying to verify postgres/redis health (they're not compose-managed).
run: docker compose up -d --no-deps api
- name: Wait for API healthy
# Poll ourselves so we can detect crashes early and print logs before
# restart-policy smothers them. Mirrors healthcheck: start_period=40s,
# interval=10s, retries=12 → up to 3 min total.
# Also checks RestartCount: restart:unless-stopped hides crashes behind
# rapid restarts, so state=exited is fleeting — a rising count tells us.
run: |
echo "Waiting for meezi-api (up to 3 min)..."
for i in $(seq 1 36); do
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
STATE=$(docker inspect --format='{{.State.Status}}' meezi-api 2>/dev/null || echo "missing")
RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-api 2>/dev/null || echo "0")
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-api healthy" && break
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
echo "❌ meezi-api crashed (state=$STATE) — logs:"
docker logs meezi-api 2>&1 | tail -120
exit 1
fi
if [ "$RESTARTS" -gt 1 ]; then
echo "❌ meezi-api crash-loop (restarts=$RESTARTS) — logs:"
docker logs meezi-api 2>&1 | tail -120
exit 1
fi
[ "$i" = "36" ] && echo "❌ meezi-api timeout (3 min)" \
&& docker logs meezi-api 2>&1 | tail -80 && exit 1
sleep 5
done
- name: Start web services
# API is healthy at this point; start the three Next.js frontends.
run: docker compose up -d --no-deps web website koja
- name: Start admin API
run: |
docker compose \
-f docker-compose.yml \
-f docker-compose.admin.yml \
up -d \
--no-deps \
admin-api admin-web
- name: Wait for main API healthy
run: |
for i in $(seq 1 24); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-api 2>/dev/null || echo "missing")
echo " [$i/24] $STATUS"
[ "$STATUS" = "healthy" ] && echo "✅ meezi-api healthy" && break
[ "$i" = "24" ] && echo "❌ meezi-api timeout" && docker compose logs --tail=40 api && exit 1
sleep 5
done
up -d --no-deps admin-api
- name: Wait for admin API healthy
run: |
for i in $(seq 1 24); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
echo " [$i/24] $STATUS"
[ "$STATUS" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break
[ "$i" = "24" ] && echo "❌ meezi-admin-api timeout" && docker compose -f docker-compose.yml -f docker-compose.admin.yml logs --tail=40 admin-api && exit 1
echo "Waiting for meezi-admin-api (up to 3 min)..."
for i in $(seq 1 36); do
HEALTH=$(docker inspect --format='{{.State.Health.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
STATE=$(docker inspect --format='{{.State.Status}}' meezi-admin-api 2>/dev/null || echo "missing")
RESTARTS=$(docker inspect --format='{{.RestartCount}}' meezi-admin-api 2>/dev/null || echo "0")
echo " [$i/36] state=$STATE health=$HEALTH restarts=$RESTARTS"
[ "$HEALTH" = "healthy" ] && echo "✅ meezi-admin-api healthy" && break
if [ "$STATE" = "exited" ] || [ "$STATE" = "dead" ]; then
echo "❌ meezi-admin-api crashed (state=$STATE) — logs:"
docker logs meezi-admin-api 2>&1 | tail -80
exit 1
fi
if [ "$RESTARTS" -gt 1 ]; then
echo "❌ meezi-admin-api crash-loop (restarts=$RESTARTS) — logs:"
docker logs meezi-admin-api 2>&1 | tail -80
exit 1
fi
[ "$i" = "36" ] && echo "❌ meezi-admin-api timeout (3 min)" \
&& docker logs meezi-admin-api 2>&1 | tail -80 && exit 1
sleep 5
done
- name: Start admin web
run: |
docker compose \
-f docker-compose.yml \
-f docker-compose.admin.yml \
up -d --no-deps admin-web
- name: Show all running containers
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
- name: Prune old images
- name: Dump logs on failure
if: failure()
run: |
echo "=== meezi-api logs ==="
docker logs meezi-api --tail=120 2>&1 || true
echo "=== meezi-admin-api logs ==="
docker logs meezi-admin-api --tail=80 2>&1 || true
echo "=== meezi_default network ==="
docker network inspect meezi_default 2>&1 || true
echo "=== meezi-db network state ==="
docker inspect meezi-db --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
echo "=== meezi-redis network state ==="
docker inspect meezi-redis --format='{{json .NetworkSettings.Networks}}' 2>&1 || true
- name: Prune dangling images
if: success()
run: docker image prune -f
run: |
# Remove untagged (<none>) images left over from this and previous builds.
# --filter dangling=true only removes images with no tags; never touches
# other projects' named images (soroushasadi-site, drsousan, etc.).
docker image prune -f
echo "Disk after prune:"
df -h /
+3
View File
@@ -0,0 +1,3 @@
registry=https://mirror.soroushasadi.com/repository/npm-group/
strict-ssl=false
legacy-peer-deps=true
+4 -1
View File
@@ -28,7 +28,7 @@ services:
environment:
ASPNETCORE_ENVIRONMENT: "${ASPNETCORE_ENVIRONMENT:-Development}"
ASPNETCORE_URLS: http://+:8080
RUN_MIGRATIONS: "false"
RUN_MIGRATIONS: "${RUN_MIGRATIONS:-true}"
ConnectionStrings__DefaultConnection: "${DB_CONNECTION_STRING:-Host=postgres;Port=5432;Database=meezi;Username=meezi;Password=meezi_local_pass}"
ConnectionStrings__Redis: redis:6379
Jwt__Key: "${JWT_KEY:-dev-jwt-key-CHANGE-THIS-IN-PRODUCTION-min32chars}"
@@ -36,6 +36,9 @@ services:
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
ports:
- "${ADMIN_API_PORT:-5081}:8080"
healthcheck:
+5
View File
@@ -1,3 +1,5 @@
name: meezi # Lock project name — prevents runner workspace from overriding it
# Meezi — main stack (Postgres, Redis, API, Dashboard, Website, Koja)
#
# All images/packages served from Nexus at mirror.soroushasadi.com:
@@ -92,6 +94,9 @@ services:
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
Seed__SystemAdminPhone: "${SEED_ADMIN_PHONE:-}"
Seed__SystemAdminUsername: "${SEED_ADMIN_USERNAME:-admin}"
Seed__SystemAdminPassword: "${SEED_ADMIN_PASSWORD:-}"
ports:
- "${API_PORT:-5080}:8080"
volumes:
+18 -4
View File
@@ -1,27 +1,40 @@
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
# ==================== DEPS STAGE ====================
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY web/admin/package*.json ./
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Install deps then ensure Alpine (musl) SWC binary is present
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
RUN npm ci --legacy-peer-deps --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \
|| npm install --no-save --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
"@next/swc-linux-x64-musl@${NEXT_VER}"
# ==================== BUILDER STAGE ====================
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
ARG NEXT_PUBLIC_ADMIN_API_URL=http://localhost:5081
ENV NEXT_PUBLIC_ADMIN_API_URL=$NEXT_PUBLIC_ADMIN_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/admin/ .
RUN npm run build
# ==================== RUNNER STAGE ====================
FROM ${NODE_IMAGE} AS runner
WORKDIR /app
@@ -38,5 +51,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+1 -1
View File
@@ -4,7 +4,7 @@ FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY web/koja/package*.json ./
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} --strict-ssl=false
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
+23 -3
View File
@@ -1,22 +1,41 @@
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
# ==================== DEPS STAGE ====================
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY web/dashboard/package*.json ./
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY}
COPY web/dashboard/package*.json ./
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Use npm ci + ensure musl SWC binary (important on Alpine)
RUN npm ci --legacy-peer-deps --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|| npm install --no-save --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
"@next/swc-linux-x64-musl@${NEXT_VER}"
# ==================== BUILDER STAGE ====================
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
ARG NEXT_PUBLIC_API_URL=http://localhost:5080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/dashboard/ .
RUN npm run build
# ==================== RUNNER STAGE ====================
FROM ${NODE_IMAGE} AS runner
WORKDIR /app
@@ -33,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+17 -5
View File
@@ -1,29 +1,40 @@
ARG NODE_IMAGE=mirror.soroushasadi.com/node:20-alpine
# ==================== DEPS STAGE ====================
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
COPY web/website/package*.json ./
ARG NPM_REGISTRY=https://mirror.soroushasadi.com/repository/npm-group/
# Install deps then ensure Alpine (musl) SWC binary is present
RUN npm install --legacy-peer-deps --ignore-scripts --registry ${NPM_REGISTRY} \
&& NEXT_VER=$(node -e "process.stdout.write(require('./node_modules/next/package.json').version)") \
RUN npm ci --legacy-peer-deps --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
&& NEXT_VER=$(node -p "require('./node_modules/next/package.json').version") \
&& ls node_modules/@next/swc-linux-x64-musl 2>/dev/null \
|| npm install --no-save --ignore-scripts --registry ${NPM_REGISTRY} \
|| npm install --no-save --ignore-scripts \
--registry ${NPM_REGISTRY} \
--strict-ssl=false \
"@next/swc-linux-x64-musl@${NEXT_VER}"
# ==================== BUILDER STAGE ====================
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
ARG MEEZI_API_URL=http://api:8080
ENV MEEZI_API_URL=$MEEZI_API_URL
ARG NEXT_PUBLIC_SITE_URL=http://localhost:3010
ENV MEEZI_API_URL=$MEEZI_API_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY web/website/ .
RUN npm run build
# ==================== RUNNER STAGE ====================
FROM ${NODE_IMAGE} AS runner
WORKDIR /app
@@ -41,5 +52,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/src/content ./src/content
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
@@ -38,6 +38,26 @@ public class AuthController : ControllerBase
_verifyRegisterValidator = verifyRegisterValidator;
}
[HttpPost("login")]
[ProducesResponseType(typeof(ApiResponse<AuthTokenResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> LoginWithPassword(
[FromBody] LoginWithPasswordRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ValidationError("Username and password are required."));
var (success, data, code, message, choices) = await _authService.LoginWithPasswordAsync(request, cancellationToken);
if (!success && code == "CHOOSE_CAFE")
return Ok(new ApiResponse<CafeChoicesResponse>(false, choices, new ApiError("CHOOSE_CAFE", "Please select a café to continue.")));
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("send-otp")]
[EnableRateLimiting("auth-otp")]
[ProducesResponseType(typeof(ApiResponse<SendOtpResponse>), StatusCodes.Status200OK)]
@@ -193,6 +213,9 @@ public class AuthController : ControllerBase
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
private static ApiResponse<object> ValidationError(string message) =>
new(false, null, new ApiError("VALIDATION_ERROR", message));
private IActionResult ErrorResult(string code, string message) => code switch
{
"RATE_LIMITED" => StatusCode(StatusCodes.Status429TooManyRequests,
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Cafes;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Branding;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
@@ -57,6 +58,21 @@ public class CafeSettingsController : CafeApiControllerBase
if (cafe is null) return NotFoundError();
if (request.Name is not null) cafe.Name = request.Name.Trim();
if (request.Slug is not null)
{
var newSlug = request.Slug.Trim().ToLowerInvariant();
if (!SlugHelper.IsValidSlug(newSlug))
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_SLUG", "Slug must be 2-80 lowercase letters, digits, or hyphens.")));
var taken = await _db.Cafes.AnyAsync(c => c.Slug == newSlug && c.Id != cafeId, ct);
if (taken)
return Conflict(new ApiResponse<object>(false, null,
new ApiError("SLUG_TAKEN", "This Koja profile address is already in use. Please choose another.")));
cafe.Slug = newSlug;
}
if (request.Phone is not null) cafe.Phone = request.Phone.Trim();
if (request.Address is not null) cafe.Address = request.Address.Trim();
if (request.City is not null) cafe.City = request.City.Trim();
@@ -71,6 +87,21 @@ public class CafeSettingsController : CafeApiControllerBase
if (request.AllowBranchTaxOverride is bool allowTax)
cafe.AllowBranchTaxOverride = allowTax;
// Location: explicit null-clear flag OR new values
if (request.ClearLocation)
{
cafe.Latitude = null;
cafe.Longitude = null;
}
else if (request.Latitude.HasValue && request.Longitude.HasValue)
{
if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_LOCATION", "Latitude must be 90…90 and longitude 180…180.")));
cafe.Latitude = request.Latitude;
cafe.Longitude = request.Longitude;
}
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
}
@@ -90,5 +121,7 @@ public class CafeSettingsController : CafeApiControllerBase
cafe.PlanExpiresAt,
CafeThemeMapping.FromJson(cafe.ThemeJson),
cafe.DefaultTaxRate,
cafe.AllowBranchTaxOverride);
cafe.AllowBranchTaxOverride,
cafe.Latitude,
cafe.Longitude);
}
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.API.Services;
using Meezi.Core.Interfaces;
using Meezi.Shared;
namespace Meezi.API.Controllers;
[Route("api/cafes/{cafeId}/demo")]
[Authorize]
public class DemoSeedController : CafeApiControllerBase
{
private readonly IDemoSeedService _demoSeed;
public DemoSeedController(IDemoSeedService demoSeed)
{
_demoSeed = demoSeed;
}
/// <summary>Seeds demo menu, tables, and inventory for any café. Owner-only.</summary>
[HttpPost("seed")]
public async Task<IActionResult> Seed(
string cafeId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
var result = await _demoSeed.SeedAsync(cafeId, ct);
return Ok(new ApiResponse<DemoSeedResult>(true, result));
}
}
+69 -1
View File
@@ -1,9 +1,12 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Hr;
using Meezi.API.Services;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Core.Utilities;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -15,17 +18,20 @@ public class HrController : CafeApiControllerBase
private readonly IValidator<CreateLeaveRequest> _leaveValidator;
private readonly IValidator<ReviewLeaveRequest> _reviewValidator;
private readonly IValidator<CreateSalaryRequest> _salaryValidator;
private readonly AppDbContext _db;
public HrController(
IHrService hr,
IValidator<CreateLeaveRequest> leaveValidator,
IValidator<ReviewLeaveRequest> reviewValidator,
IValidator<CreateSalaryRequest> salaryValidator)
IValidator<CreateSalaryRequest> salaryValidator,
AppDbContext db)
{
_hr = hr;
_leaveValidator = leaveValidator;
_reviewValidator = reviewValidator;
_salaryValidator = salaryValidator;
_db = db;
}
[HttpGet("employees")]
@@ -201,4 +207,66 @@ public class HrController : CafeApiControllerBase
if (data is null) return NotFoundError();
return Ok(new ApiResponse<EmployeeSalaryDto>(true, data));
}
/// <summary>Set or update username/password credentials for an employee. Owner/Manager only.</summary>
[HttpPut("employees/{employeeId}/credentials")]
public async Task<IActionResult> SetCredentials(
string cafeId,
string employeeId,
[FromBody] SetEmployeeCredentialsRequest request,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var username = request.Username.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(username))
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Username is required.", "Username")));
if (request.Password.Length < 8)
return BadRequest(new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", "Password must be at least 8 characters.", "Password")));
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (employee is null) return NotFoundError();
// Check username uniqueness within the cafe (excluding the employee itself)
var conflict = await _db.Employees
.AnyAsync(e => e.CafeId == cafeId && e.Id != employeeId && e.DeletedAt == null
&& e.Username != null && e.Username.ToLower() == username, ct);
if (conflict)
return Conflict(new ApiResponse<object>(false, null, new ApiError("USERNAME_TAKEN", "This username is already in use by another employee.")));
employee.Username = username;
employee.PasswordHash = PasswordHasher.Hash(request.Password);
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
/// <summary>Remove username/password credentials from an employee. Owner/Manager only.</summary>
[HttpDelete("employees/{employeeId}/credentials")]
public async Task<IActionResult> RemoveCredentials(
string cafeId,
string employeeId,
ITenantContext tenant,
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureManager(tenant) is { } forbidden) return forbidden;
var employee = await _db.Employees
.FirstOrDefaultAsync(e => e.Id == employeeId && e.CafeId == cafeId && e.DeletedAt == null, ct);
if (employee is null) return NotFoundError();
employee.Username = null;
employee.PasswordHash = null;
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<object>(true, null));
}
}
+34 -1
View File
@@ -1,9 +1,12 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Menu;
using Meezi.API.Services;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -15,17 +18,25 @@ public class MenuController : CafeApiControllerBase
private readonly IMenuAi3dGenerationService _menuAi3d;
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
private readonly AppDbContext _db;
private const string CategoryLimitMessage =
"محدودیت دسته‌بندی پلن رایگان (۳ دسته). برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید.";
private const string ItemLimitMessage =
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
public MenuController(
IMenuService menuService,
IMenuAi3dGenerationService menuAi3d,
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
IValidator<CreateMenuItemRequest> createItemValidator)
IValidator<CreateMenuItemRequest> createItemValidator,
AppDbContext db)
{
_menuService = menuService;
_menuAi3d = menuAi3d;
_createCategoryValidator = createCategoryValidator;
_createItemValidator = createItemValidator;
_db = db;
}
[HttpGet("categories")]
@@ -47,6 +58,17 @@ public class MenuController : CafeApiControllerBase
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuCategories(tier);
if (max != int.MaxValue)
{
var count = await _db.MenuCategories.CountAsync(
c => c.CafeId == cafeId && c.DeletedAt == null, cancellationToken);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", CategoryLimitMessage)));
}
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
}
@@ -97,6 +119,17 @@ public class MenuController : CafeApiControllerBase
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuItems(tier);
if (max != int.MaxValue)
{
var count = await _db.MenuItems.CountAsync(
i => i.CafeId == cafeId && i.DeletedAt == null, cancellationToken);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ItemLimitMessage)));
}
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
if (data is null) return NotFoundError("Category not found.");
return Ok(new ApiResponse<MenuItemDto>(true, data));
@@ -367,4 +367,101 @@ public class PublicController : ControllerBase
return Ok(new ApiResponse<object>(true, null));
}
/// <summary>
/// Returns all cafés that have a known location (Latitude/Longitude set).
/// Used by the marketing website SVG map to render blinking dots.
/// </summary>
[HttpGet("map-markers")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetMapMarkers(
[FromServices] AppDbContext db,
CancellationToken ct)
{
var markers = await db.Cafes
.AsNoTracking()
.Where(c => c.DeletedAt == null && c.Latitude != null && c.Longitude != null)
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl
})
.ToListAsync(ct);
return Ok(new ApiResponse<object>(true, markers));
}
/// <summary>
/// Returns cafés near a given coordinate, sorted by distance ascending.
/// Used by Koja guest page to show "nearby cafés" section.
/// At most <paramref name="limit"/> results (default 5, max 20).
/// </summary>
[HttpGet("nearby")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetNearbyCafes(
[FromQuery] double lat,
[FromQuery] double lng,
[FromQuery] string? excludeSlug,
[FromQuery] int limit,
[FromServices] AppDbContext db,
CancellationToken ct)
{
limit = Math.Clamp(limit <= 0 ? 5 : limit, 1, 20);
// Pull all located cafés from DB (typically small set) and sort in memory with Haversine.
var cafes = await db.Cafes
.AsNoTracking()
.Where(c => c.DeletedAt == null
&& c.Latitude != null
&& c.Longitude != null
&& (excludeSlug == null || c.Slug != excludeSlug))
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl,
c.CoverImageUrl
})
.ToListAsync(ct);
static double ToRad(double deg) => deg * Math.PI / 180.0;
static double Haversine(double lat1, double lon1, double lat2, double lon2)
{
const double R = 6371; // km
var dLat = ToRad(lat2 - lat1);
var dLon = ToRad(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
+ Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2))
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
var nearby = cafes
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl,
c.CoverImageUrl,
DistanceKm = Math.Round(Haversine(lat, lng, c.Latitude!.Value, c.Longitude!.Value), 1)
})
.OrderBy(c => c.DistanceKm)
.Take(limit)
.ToList();
return Ok(new ApiResponse<object>(true, nearby));
}
}
@@ -91,6 +91,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<IQueueService, QueueService>();
services.AddScoped<IShiftService, ShiftService>();
services.AddScoped<IExpenseService, ExpenseService>();
services.AddScoped<IDemoSeedService, DemoSeedService>();
services.AddScoped<ReceiptBuilder>();
services.AddScoped<IPrinterService, NetworkPrinterService>();
services.AddHttpClient(nameof(PosDeviceService));
+5 -1
View File
@@ -2,6 +2,9 @@ namespace Meezi.API.Models.Auth;
public record SendOtpRequest(string Phone);
/// <summary>Username + password login (alternative to OTP). Optional cafeId to scope to a specific café.</summary>
public record LoginWithPasswordRequest(string Username, string Password, string? CafeId = null);
public record VerifyOtpRequest(string Phone, string Code, string? CafeId = null);
public record RefreshTokenRequest(string RefreshToken);
@@ -12,7 +15,8 @@ public record SwitchCafeRequest(string CafeId);
public record SwitchBranchRequest(string? BranchId);
/// <summary>Step 1 of self-registration: send OTP to a new phone number.</summary>
public record RegisterRequest(string Phone, string CafeName);
/// <param name="Slug">Optional custom Koja slug (e.g. "lamiz-enghelab"). Auto-derived from CafeName if omitted.</param>
public record RegisterRequest(string Phone, string CafeName, string? Slug = null);
/// <summary>Step 2 of self-registration: verify OTP and create the cafe account.</summary>
public record VerifyRegisterRequest(string Phone, string Code);
+12 -2
View File
@@ -15,10 +15,14 @@ public record CafeSettingsDto(
DateTime? PlanExpiresAt,
CafeThemeDto Theme,
decimal DefaultTaxRate,
bool AllowBranchTaxOverride);
bool AllowBranchTaxOverride,
double? Latitude,
double? Longitude);
public record PatchCafeSettingsRequest(
string? Name,
/// <summary>Custom Koja profile slug (e.g. "lamiz-enghelab"). Must be unique across all cafés.</summary>
string? Slug,
string? Phone,
string? Address,
string? City,
@@ -28,4 +32,10 @@ public record PatchCafeSettingsRequest(
string? SnappfoodVendorId,
CafeThemeDto? Theme,
decimal? DefaultTaxRate,
bool? AllowBranchTaxOverride);
bool? AllowBranchTaxOverride,
/// <summary>WGS-84 latitude. Send null to clear.</summary>
double? Latitude,
/// <summary>WGS-84 longitude. Send null to clear.</summary>
double? Longitude,
/// <summary>When true, Latitude and Longitude are explicitly being cleared (set to null).</summary>
bool ClearLocation = false);
+3
View File
@@ -59,3 +59,6 @@ public record CreateSalaryRequest(
decimal Deductions);
public record TodayShiftDto(ShiftType ShiftType, string Label);
/// <summary>Set or update username/password credentials for an employee.</summary>
public record SetEmployeeCredentialsRequest(string Username, string Password);
+110 -8
View File
@@ -303,8 +303,15 @@ public class AuthService : IAuthService
var otp = Random.Shared.Next(100000, 999999).ToString();
await redis.StringSetAsync($"otp:{phone}", otp, TimeSpan.FromSeconds(OtpTtlSeconds));
// Store the cafe name alongside the OTP so verify-register can create the cafe
await redis.StringSetAsync($"reg_meta:{phone}", cafeName, TimeSpan.FromSeconds(OtpTtlSeconds));
// Determine the requested slug: use provided slug, or auto-derive from café name.
// Format stored: "cafeName||slug" (double-pipe delimiter). Slug may be empty.
var requestedSlug = string.IsNullOrWhiteSpace(request.Slug)
? Meezi.Core.Utilities.SlugHelper.Slugify(cafeName)
: request.Slug.Trim().ToLowerInvariant();
var regMeta = $"{cafeName}||{requestedSlug}";
await redis.StringSetAsync($"reg_meta:{phone}", regMeta, TimeSpan.FromSeconds(OtpTtlSeconds));
try
{
@@ -342,10 +349,25 @@ public class AuthService : IAuthService
if (storedOtp.IsNullOrEmpty || storedOtp.ToString() != code)
return (false, null, "INVALID_OTP", "Invalid or expired verification code.");
var cafeName = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
if (string.IsNullOrWhiteSpace(cafeName))
var regMetaRaw = (await redis.StringGetAsync($"reg_meta:{phone}")).ToString();
if (string.IsNullOrWhiteSpace(regMetaRaw))
return (false, null, "REGISTRATION_EXPIRED", "Registration session expired. Please start again.");
// Parse "cafeName||slug" format (double-pipe delimiter)
string cafeName;
string? requestedSlug;
var sepIdx = regMetaRaw.IndexOf("||", StringComparison.Ordinal);
if (sepIdx >= 0)
{
cafeName = regMetaRaw[..sepIdx];
requestedSlug = regMetaRaw[(sepIdx + 2)..];
}
else
{
cafeName = regMetaRaw;
requestedSlug = null;
}
// Double-check no owner was created in the meantime (race condition guard)
var alreadyOwner = await _db.Employees
.AnyAsync(e => e.Phone == phone && e.Role == EmployeeRole.Owner && e.DeletedAt == null, cancellationToken);
@@ -356,8 +378,8 @@ public class AuthService : IAuthService
return (false, null, "ALREADY_REGISTERED", "An account already exists for this phone number. Please sign in.");
}
// Generate a unique slug
var slug = await GenerateUniqueSlugAsync(cancellationToken);
// Generate a unique slug (try requested slug first, fall back to random)
var slug = await GenerateUniqueSlugAsync(requestedSlug, cancellationToken);
var cafe = new Cafe
{
@@ -367,6 +389,15 @@ public class AuthService : IAuthService
PlanTier = PlanTier.Free,
};
// Auto-create a default main branch so the owner can start using the
// dashboard immediately without hitting the "select a branch" gate.
var defaultBranch = new Branch
{
CafeId = cafe.Id,
Name = cafeName,
IsActive = true,
};
var owner = new Employee
{
CafeId = cafe.Id,
@@ -376,6 +407,7 @@ public class AuthService : IAuthService
};
_db.Cafes.Add(cafe);
_db.Branches.Add(defaultBranch);
_db.Employees.Add(owner);
await _db.SaveChangesAsync(cancellationToken);
@@ -392,17 +424,87 @@ public class AuthService : IAuthService
return (true, tokens, null, null);
}
private async Task<string> GenerateUniqueSlugAsync(CancellationToken ct)
private async Task<string> GenerateUniqueSlugAsync(string? preferred, CancellationToken ct)
{
// Try the preferred/derived slug first
if (Meezi.Core.Utilities.SlugHelper.IsValidSlug(preferred))
{
if (!await _db.Cafes.AnyAsync(c => c.Slug == preferred, ct))
return preferred!;
// Preferred slug is taken — append a short random suffix
for (var i = 0; i < 10; i++)
{
var candidate = $"{preferred}-{Guid.NewGuid().ToString("N")[..4]}";
if (!await _db.Cafes.AnyAsync(c => c.Slug == candidate, ct))
return candidate;
}
}
// Full random fallback
string slug;
do
{
// e.g. "cafe-a3f9b2c"
slug = "cafe-" + Guid.NewGuid().ToString("N")[..7];
} while (await _db.Cafes.AnyAsync(c => c.Slug == slug, ct));
return slug;
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default)
{
var username = request.Username.Trim();
var candidates = await _db.Employees
.Include(e => e.Cafe)
.Where(e => e.Username == username
&& e.PasswordHash != null
&& e.DeletedAt == null
&& e.Cafe.DeletedAt == null)
.ToListAsync(cancellationToken);
if (candidates.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
// Constant-time verification (check all matches to avoid username enumeration)
var matched = candidates.Where(e => PasswordHasher.Verify(request.Password, e.PasswordHash!)).ToList();
if (matched.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
// Scope to a specific café if requested
if (!string.IsNullOrWhiteSpace(request.CafeId))
{
matched = matched.Where(e => e.CafeId == request.CafeId).ToList();
if (matched.Count == 0)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
}
// Multiple cafés — ask frontend to pick one
if (matched.Count > 1)
{
var choices = new CafeChoicesResponse(
matched
.Where(e => e.Cafe is not null)
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList());
return (false, null, "CHOOSE_CAFE", null, choices);
}
var employee = matched[0];
if (employee.Cafe is null)
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.", null);
var membershipDtos = matched
.Where(e => e.Cafe is not null)
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
.ToList();
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, null, cancellationToken);
return (true, tokens, null, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.Employee employee,
Core.Entities.Cafe cafe,
+174
View File
@@ -0,0 +1,174 @@
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Meezi.API.Services;
public record DemoSeedResult(
int CategoriesAdded,
int ItemsAdded,
int TablesAdded,
int IngredientsAdded,
bool TaxCreated);
public interface IDemoSeedService
{
Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default);
}
public class DemoSeedService : IDemoSeedService
{
private readonly AppDbContext _db;
private readonly ILogger<DemoSeedService> _logger;
public DemoSeedService(AppDbContext db, ILogger<DemoSeedService> logger)
{
_db = db;
_logger = logger;
}
public async Task<DemoSeedResult> SeedAsync(string cafeId, CancellationToken ct = default)
{
// 1. Ensure 9% default tax
var taxId = $"{cafeId}_demo_tax";
var taxCreated = false;
if (!await _db.Taxes.AnyAsync(t => t.CafeId == cafeId && t.IsDefault, ct))
{
_db.Taxes.Add(new Tax
{
Id = taxId,
CafeId = cafeId,
Name = "مالیات ارزش افزوده",
Rate = 9,
IsDefault = true,
IsRequired = true,
IsCompound = false
});
await _db.SaveChangesAsync(ct);
taxCreated = true;
}
else
{
taxId = await _db.Taxes
.Where(t => t.CafeId == cafeId && t.IsDefault)
.Select(t => t.Id)
.FirstAsync(ct);
}
// 2. Seed menu (categories + items) using café-agnostic seeder.
// useScopedIds=true prefixes all IDs with cafeId so multiple cafés
// can each have their own demo menu without primary-key collisions.
var beforeCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
var beforeItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
await DemoMenuSeeder.EnsureMenuAsync(_db, cafeId, taxId, _logger, useScopedIds: true);
var afterCats = await _db.MenuCategories.CountAsync(c => c.CafeId == cafeId, ct);
var afterItems = await _db.MenuItems.CountAsync(i => i.CafeId == cafeId, ct);
// 3. Seed ingredients if warehouse is empty
var ingredientsAdded = 0;
if (!await _db.Ingredients.AnyAsync(i => i.CafeId == cafeId, ct))
{
var demoIngredients = BuildDemoIngredients(cafeId);
_db.Ingredients.AddRange(demoIngredients);
await _db.SaveChangesAsync(ct);
ingredientsAdded = demoIngredients.Count;
}
// 4. Seed 10 tables if no tables exist for this café's first active branch
var tablesAdded = 0;
if (!await _db.Tables.AnyAsync(t => t.CafeId == cafeId, ct))
{
var branchId = await _db.Branches
.Where(b => b.CafeId == cafeId && b.IsActive && b.DeletedAt == null)
.OrderBy(b => b.Id)
.Select(b => b.Id)
.FirstOrDefaultAsync(ct);
if (branchId is not null)
{
var tables = BuildDemoTables(cafeId, branchId);
_db.Tables.AddRange(tables);
await _db.SaveChangesAsync(ct);
tablesAdded = tables.Count;
}
}
_logger.LogInformation(
"Demo seed complete for cafe {CafeId}: +{Cats} cats, +{Items} items, +{Tables} tables, +{Ing} ingredients, tax={TaxCreated}",
cafeId, afterCats - beforeCats, afterItems - beforeItems, tablesAdded, ingredientsAdded, taxCreated);
return new DemoSeedResult(
CategoriesAdded: afterCats - beforeCats,
ItemsAdded: afterItems - beforeItems,
TablesAdded: tablesAdded,
IngredientsAdded: ingredientsAdded,
TaxCreated: taxCreated);
}
private static List<Ingredient> BuildDemoIngredients(string cafeId) =>
[
Ingredient(cafeId, "قهوه اسپرسو", "گرم", 2000, 500, 80, 2000),
Ingredient(cafeId, "شیر", "میلی‌لیتر", 10000, 2000, 15, 10000),
Ingredient(cafeId, "شکر", "گرم", 5000, 1000, 5, 5000),
Ingredient(cafeId, "وانیل", "میلی‌لیتر", 500, 100, 50, 500),
Ingredient(cafeId, "شکلات تلخ", "گرم", 1000, 200, 120, 1000),
Ingredient(cafeId, "خامه", "میلی‌لیتر", 2000, 500, 30, 2000),
Ingredient(cafeId, "دارچین", "گرم", 300, 50, 40, 300),
Ingredient(cafeId, "چای سیاه", "گرم", 1000, 200, 60, 1000),
Ingredient(cafeId, "آب معدنی", "میلی‌لیتر", 20000, 5000, 3, 20000),
Ingredient(cafeId, "نان تست", "عدد", 100, 20, 8000, 100),
Ingredient(cafeId, "تخم‌مرغ", "عدد", 60, 12, 6000, 60),
Ingredient(cafeId, "کره", "گرم", 500, 100, 80, 500),
Ingredient(cafeId, "پنیر", "گرم", 1000, 200, 90, 1000),
Ingredient(cafeId, "اسپاتولا یخ", "عدد", 200, 50, 2000, 200),
Ingredient(cafeId, "سس کارامل", "میلی‌لیتر", 1000, 200, 60, 1000),
];
private static Ingredient Ingredient(
string cafeId, string name, string unit,
decimal qty, decimal reorder, decimal cost, decimal par) =>
new()
{
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
CafeId = cafeId,
Name = name,
Unit = unit,
QuantityOnHand = qty,
ReorderLevel = reorder,
UnitCost = cost,
ParLevel = par,
LowStockWarningPercent = 20m
};
private static List<Table> BuildDemoTables(string cafeId, string branchId)
{
var tables = new List<Table>();
// Floor 1: tables 1-4
for (var i = 1; i <= 4; i++)
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه اول", i));
// Floor 2: tables 5-8
for (var i = 5; i <= 8; i++)
tables.Add(Table(cafeId, branchId, i.ToString(), 4, "طبقه دوم", i));
// VIP: tables 9-10
for (var i = 9; i <= 10; i++)
tables.Add(Table(cafeId, branchId, i.ToString(), 6, "VIP", i));
return tables;
}
private static Table Table(
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
new()
{
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
CafeId = cafeId,
BranchId = branchId,
Number = number,
Capacity = capacity,
Floor = floor,
SortOrder = sortOrder,
QrCode = Guid.NewGuid().ToString("N"),
IsActive = true,
IsCleaning = false
};
}
+4
View File
@@ -16,6 +16,10 @@ public interface IAuthService
VerifyOtpRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage, CafeChoicesResponse? Choices)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> SwitchCafeAsync(
string employeeId, string targetCafeId,
CancellationToken cancellationToken = default);
@@ -1,6 +1,7 @@
using FluentValidation;
using Meezi.API.Models.Auth;
using Meezi.Core.Utilities;
using System.Text.RegularExpressions;
namespace Meezi.API.Validators;
@@ -51,6 +52,10 @@ public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
.NotEmpty()
.MaximumLength(100)
.WithMessage("Cafe name must be between 1 and 100 characters.");
RuleFor(x => x.Slug)
.Must(s => s == null || SlugHelper.IsValidSlug(s))
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).");
}
}
@@ -1,5 +1,6 @@
using FluentValidation;
using Meezi.API.Models.Cafes;
using Meezi.Core.Utilities;
namespace Meezi.API.Validators;
@@ -8,6 +9,10 @@ public class PatchCafeSettingsRequestValidator : AbstractValidator<PatchCafeSett
public PatchCafeSettingsRequestValidator()
{
RuleFor(x => x.Name).MaximumLength(200).When(x => x.Name is not null);
RuleFor(x => x.Slug)
.Must(s => s == null || SlugHelper.IsValidSlug(s))
.WithMessage("Slug must be 2-80 lowercase letters, digits, or hyphens (e.g. my-cafe).")
.When(x => x.Slug is not null);
RuleFor(x => x.Phone).MaximumLength(20).When(x => x.Phone is not null);
RuleFor(x => x.Address).MaximumLength(500).When(x => x.Address is not null);
RuleFor(x => x.City).MaximumLength(100).When(x => x.City is not null);
+2 -2
View File
@@ -12,7 +12,7 @@ public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCa
public CreateMenuCategoryRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
@@ -39,7 +39,7 @@ public class CreateMenuItemRequestValidator : AbstractValidator<CreateMenuItemRe
{
RuleFor(x => x.CategoryId).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
}
@@ -1,8 +1,11 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Meezi.Admin.API.Models;
using Meezi.Admin.API.Services;
using Meezi.Shared;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace Meezi.Admin.API.Controllers;
@@ -55,6 +58,39 @@ public class AdminAuthController : ControllerBase
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPost("login")]
public async Task<IActionResult> LoginWithPassword(
[FromBody] LoginWithPasswordRequest request,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(ValidationError("Username and password are required."));
var (success, data, code, message) = await _auth.LoginWithPasswordAsync(request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<AuthTokenResponse>(true, data));
}
[HttpPut("password")]
[Authorize]
public async Task<IActionResult> ChangePassword(
[FromBody] ChangePasswordRequest request,
CancellationToken cancellationToken)
{
var adminId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(adminId))
return Unauthorized();
var (success, code, message) = await _auth.ChangePasswordAsync(adminId, request, cancellationToken);
if (!success)
return ErrorResult(code!, message!);
return Ok(new ApiResponse<object>(true, null));
}
[HttpPost("refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshTokenRequest request, CancellationToken cancellationToken)
{
@@ -75,6 +111,9 @@ public class AdminAuthController : ControllerBase
return new ApiResponse<object>(false, null, new ApiError("VALIDATION_ERROR", first.ErrorMessage, first.PropertyName));
}
private static ApiResponse<object> ValidationError(string message) =>
new(false, null, new ApiError("VALIDATION_ERROR", message));
private IActionResult ErrorResult(string code, string message) =>
code switch
{
+4
View File
@@ -8,6 +8,10 @@ public record VerifyOtpRequest(string Phone, string Code);
public record RefreshTokenRequest(string RefreshToken);
public record LoginWithPasswordRequest(string Username, string Password);
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
public record AuthTokenResponse(
string AccessToken,
string RefreshToken,
@@ -19,6 +19,15 @@ public interface IAdminAuthService
VerifyOtpRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default);
Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> RefreshAsync(
RefreshTokenRequest request,
CancellationToken cancellationToken = default);
@@ -141,6 +150,49 @@ public class AdminAuthService : IAdminAuthService
return (true, tokens, null, null);
}
public async Task<(bool Success, AuthTokenResponse? Data, string? ErrorCode, string? ErrorMessage)> LoginWithPasswordAsync(
LoginWithPasswordRequest request,
CancellationToken cancellationToken = default)
{
var username = request.Username.Trim();
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Username == username && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null || string.IsNullOrWhiteSpace(admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
if (!PasswordHasher.Verify(request.Password, admin.PasswordHash))
return (false, null, "INVALID_CREDENTIALS", "Invalid username or password.");
var tokens = await IssueTokensAsync(admin, cancellationToken);
return (true, tokens, null, null);
}
public async Task<(bool Success, string? ErrorCode, string? ErrorMessage)> ChangePasswordAsync(
string adminId,
ChangePasswordRequest request,
CancellationToken cancellationToken = default)
{
var admin = await _db.SystemAdmins
.FirstOrDefaultAsync(a => a.Id == adminId && a.IsActive && a.DeletedAt == null, cancellationToken);
if (admin is null)
return (false, "NOT_FOUND", "Admin not found.");
// If a password is already set, require the current one
if (!string.IsNullOrWhiteSpace(admin.PasswordHash))
{
if (!PasswordHasher.Verify(request.CurrentPassword, admin.PasswordHash))
return (false, "INVALID_CREDENTIALS", "Current password is incorrect.");
}
if (string.IsNullOrWhiteSpace(request.NewPassword) || request.NewPassword.Length < 8)
return (false, "VALIDATION_ERROR", "New password must be at least 8 characters.");
admin.PasswordHash = PasswordHasher.Hash(request.NewPassword);
await _db.SaveChangesAsync(cancellationToken);
return (true, null, null);
}
private async Task<AuthTokenResponse> IssueTokensAsync(
Core.Entities.SystemAdmin admin,
CancellationToken cancellationToken)
@@ -15,7 +15,9 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
var q = db.WebsiteBlogPosts.AsQueryable();
if (published.HasValue) q = q.Where(p => p.IsPublished == published.Value);
var total = await q.CountAsync(ct);
var posts = await q.OrderByDescending(p => p.CreatedAt)
var posts = await q
.Include(p => p.Comments)
.OrderByDescending(p => p.CreatedAt)
.Skip((page - 1) * limit).Take(limit).ToListAsync(ct);
return new { Posts = posts.Select(MapPost), Total = total, Page = page, Limit = limit };
}
@@ -162,5 +164,6 @@ public class AdminWebsiteService(AppDbContext db) : IAdminWebsiteService
p.Id, p.Slug, p.TitleFa, p.TitleEn, p.ExcerptFa, p.ExcerptEn,
p.ContentFa, p.ContentEn, p.CategoryFa, p.CategoryEn, p.Author,
p.TagsJson, p.CoverImage, p.IsPublished, p.PublishedAt, p.ViewCount, p.CreatedAt,
CommentCount = p.Comments?.Count ?? 0,
};
}
@@ -8,7 +8,9 @@ public class SendOtpRequestValidator : AbstractValidator<SendOtpRequest>
{
public SendOtpRequestValidator()
{
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile).WithMessage("Invalid phone number.");
RuleFor(x => x.Phone)
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid phone number.");
}
}
@@ -16,7 +18,9 @@ public class VerifyOtpRequestValidator : AbstractValidator<VerifyOtpRequest>
{
public VerifyOtpRequestValidator()
{
RuleFor(x => x.Phone).Must(PhoneNormalizer.IsValidIranMobile);
RuleFor(x => x.Phone)
.Must(p => PhoneNormalizer.IsValidIranMobile(PhoneNormalizer.Normalize(p)))
.WithMessage("Invalid phone number.");
RuleFor(x => x.Code)
.Must(OtpNormalizer.IsValidSixDigitCode)
.WithMessage("OTP must be 6 digits.");
+20
View File
@@ -54,4 +54,24 @@ public static class PlanLimits
PlanTier.Enterprise => 100,
_ => 0
};
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
public static int MaxMenuCategories(PlanTier tier) => tier switch
{
PlanTier.Free => 3,
_ => int.MaxValue
};
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
public static int MaxMenuItems(PlanTier tier) => tier switch
{
PlanTier.Free => 30,
_ => int.MaxValue
};
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
}
+4
View File
@@ -37,6 +37,10 @@ public class Cafe : BaseEntity
public string? InstagramHandle { get; set; }
/// <summary>Cafe website URL, max 300 chars.</summary>
public string? WebsiteUrl { get; set; }
/// <summary>WGS-84 latitude (positive = north). Null until owner sets location.</summary>
public double? Latitude { get; set; }
/// <summary>WGS-84 longitude (positive = east). Null until owner sets location.</summary>
public double? Longitude { get; set; }
/// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary>
public decimal DefaultTaxRate { get; set; } = 9m;
public bool AllowBranchTaxOverride { get; set; }
+6
View File
@@ -12,6 +12,12 @@ public class Employee : TenantEntity
public decimal BaseSalary { get; set; }
public string? PinCode { get; set; }
/// <summary>Optional username for password-based dashboard/POS login (set by cafe admin).</summary>
public string? Username { get; set; }
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled for this employee.</summary>
public string? PasswordHash { get; set; }
public Cafe Cafe { get; set; } = null!;
public Branch? Branch { get; set; }
public ICollection<Order> Orders { get; set; } = [];
+6
View File
@@ -5,4 +5,10 @@ public class SystemAdmin : BaseEntity
public string Name { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
/// <summary>Optional username for password-based login (alternative to OTP).</summary>
public string? Username { get; set; }
/// <summary>PBKDF2/SHA-256 hash. Null means password login is not enabled.</summary>
public string? PasswordHash { get; set; }
}
@@ -0,0 +1,40 @@
using System.Security.Cryptography;
namespace Meezi.Core.Utilities;
/// <summary>
/// PBKDF2/SHA-256 password hashing with no external dependencies.
/// Format stored: "{iterations}.{salt_b64}.{hash_b64}"
/// </summary>
public static class PasswordHasher
{
private const int SaltSize = 16; // 128-bit salt
private const int HashSize = 32; // 256-bit hash
private const int Iterations = 100_000; // NIST-recommended minimum
private static readonly HashAlgorithmName Algo = HashAlgorithmName.SHA256;
public static string Hash(string password)
{
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = Rfc2898DeriveBytes.Pbkdf2(password, salt, Iterations, Algo, HashSize);
return $"{Iterations}.{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}";
}
public static bool Verify(string password, string storedHash)
{
var parts = storedHash.Split('.');
if (parts.Length != 3) return false;
if (!int.TryParse(parts[0], out var iterations)) return false;
byte[] salt, expectedHash;
try
{
salt = Convert.FromBase64String(parts[1]);
expectedHash = Convert.FromBase64String(parts[2]);
}
catch (FormatException) { return false; }
var actual = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, Algo, expectedHash.Length);
return CryptographicOperations.FixedTimeEquals(actual, expectedHash);
}
}
+87
View File
@@ -0,0 +1,87 @@
using System.Text;
using System.Text.RegularExpressions;
namespace Meezi.Core.Utilities;
/// <summary>
/// Converts Persian/Arabic café names to URL-safe Latin slugs.
/// Used for Koja profile URLs (koja.meezi.ir/fa/cafe/{slug}).
/// </summary>
public static partial class SlugHelper
{
private static readonly Dictionary<char, string> PersianToLatin = new()
{
// Alef variants
{ 'آ', "a" }, { 'ا', "a" }, { 'أ', "a" }, { 'إ', "a" },
// Ba, Pa, Ta, Tha
{ 'ب', "b" }, { 'پ', "p" }, { 'ت', "t" }, { 'ث', "s" },
// Jim, Che, He, Khe
{ 'ج', "j" }, { 'چ', "ch" }, { 'ح', "h" }, { 'خ', "kh" },
// Dal, Zal, Re, Ze, Zhe
{ 'د', "d" }, { 'ذ', "z" }, { 'ر', "r" }, { 'ز', "z" }, { 'ژ', "zh" },
// Sin, Shin, Sad, Zad
{ 'س', "s" }, { 'ش', "sh" }, { 'ص', "s" }, { 'ض', "z" },
// Ta, Za, Ain, Ghain
{ 'ط', "t" }, { 'ظ', "z" }, { 'ع', "a" }, { 'غ', "gh" },
// Fa, Ghaf, Kaf (Arabic+Persian), Gaf
{ 'ف', "f" }, { 'ق', "gh" }, { 'ک', "k" }, { 'ك', "k" }, { 'گ', "g" },
// Lam, Mim, Nun, Vav, He, Ye
{ 'ل', "l" }, { 'م', "m" }, { 'ن', "n" }, { 'و', "v" },
{ 'ه', "h" }, { 'ی', "i" }, { 'ي', "i" },
// Special
{ 'ئ', "y" }, { 'ء', "" }, { 'ة', "t" }, { 'ى', "a" }, { 'ؤ', "o" },
// Persian digits
{ '۰', "0" }, { '۱', "1" }, { '۲', "2" }, { '۳', "3" }, { '۴', "4" },
{ '۵', "5" }, { '۶', "6" }, { '۷', "7" }, { '۸', "8" }, { '۹', "9" },
// Arabic-Indic digits
{ '٠', "0" }, { '١', "1" }, { '٢', "2" }, { '٣', "3" }, { '٤', "4" },
{ '٥', "5" }, { '٦', "6" }, { '٧', "7" }, { '٨', "8" }, { '٩', "9" },
};
/// <summary>
/// Converts a café name (Persian or Latin) to a URL-safe lowercase slug.
/// Returns an empty string if no valid characters can be extracted.
/// </summary>
public static string Slugify(string input)
{
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
var sb = new StringBuilder(input.Length * 2);
foreach (var ch in input)
{
if (PersianToLatin.TryGetValue(ch, out var latin))
{
sb.Append(latin);
}
else if (char.IsAsciiLetterOrDigit(ch))
{
sb.Append(char.ToLowerInvariant(ch));
}
else if (ch is ' ' or '-' or '_' or '\t')
{
sb.Append('-');
}
// else: skip punctuation/unsupported characters
}
// Collapse consecutive hyphens and trim
return MultipleHyphen().Replace(sb.ToString(), "-").Trim('-');
}
/// <summary>
/// Returns true if the slug is a valid Koja URL slug:
/// 280 lowercase letters, digits, or internal hyphens. Must start and end with a letter/digit.
/// </summary>
public static bool IsValidSlug(string? slug)
{
if (string.IsNullOrWhiteSpace(slug)) return false;
if (slug.Length < 2 || slug.Length > 80) return false;
return ValidSlugPattern().IsMatch(slug);
}
[GeneratedRegex(@"-{2,}")]
private static partial Regex MultipleHyphen();
[GeneratedRegex(@"^[a-z0-9][a-z0-9\-]*[a-z0-9]$")]
private static partial Regex ValidSlugPattern();
}
@@ -6,8 +6,23 @@ namespace Meezi.Infrastructure.Data;
public static class DemoMenuSeeder
{
public static async Task EnsureMenuAsync(AppDbContext db, string cafeId, string taxId, ILogger logger)
/// <param name="useScopedIds">
/// When true, category and item IDs are prefixed with <paramref name="cafeId"/> so
/// multiple cafés can each have their own copy of the demo menu without a primary-key
/// collision. Pass false only for the legacy demo café (cafe_demo_001) whose IDs are
/// already in the database without a café prefix.
/// </param>
public static async Task EnsureMenuAsync(
AppDbContext db, string cafeId, string taxId, ILogger logger,
bool useScopedIds = false)
{
// When useScopedIds=true every row gets a deterministic ID that is unique per café:
// category → "{cafeId}_{catalogId}"
// item → "{cafeId}_{catalogId}"
// The catalog item's CategoryId is remapped through the same function.
string Scoped(string catalogId) =>
useScopedIds ? $"{cafeId}_{catalogId}" : catalogId;
if (!await db.Taxes.AnyAsync(t => t.Id == taxId && t.CafeId == cafeId))
{
db.Taxes.Add(new Tax
@@ -29,7 +44,8 @@ public static class DemoMenuSeeder
var categoriesAdded = 0;
foreach (var cat in DemoMenuCatalog.Categories)
{
if (existingCategoryIds.TryGetValue(cat.Id, out var row))
var catId = Scoped(cat.Id);
if (existingCategoryIds.TryGetValue(catId, out var row))
{
if (string.IsNullOrWhiteSpace(row.Icon) && !string.IsNullOrWhiteSpace(cat.Icon))
row.Icon = cat.Icon;
@@ -46,7 +62,7 @@ public static class DemoMenuSeeder
db.MenuCategories.Add(new MenuCategory
{
Id = cat.Id,
Id = catId,
CafeId = cafeId,
Name = cat.Name,
NameEn = cat.NameEn,
@@ -69,14 +85,15 @@ public static class DemoMenuSeeder
var itemsAdded = 0;
foreach (var item in DemoMenuCatalog.Items)
{
if (existingItemIds.Contains(item.Id))
var itemId = Scoped(item.Id);
if (existingItemIds.Contains(itemId))
continue;
db.MenuItems.Add(new MenuItem
{
Id = item.Id,
Id = itemId,
CafeId = cafeId,
CategoryId = item.CategoryId,
CategoryId = Scoped(item.CategoryId), // FK must point at scoped category ID
Name = item.Name,
NameEn = item.NameEn,
NameAr = item.NameAr,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,58 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPasswordLogin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "SystemAdmins",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Username",
table: "SystemAdmins",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "PasswordHash",
table: "Employees",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Username",
table: "Employees",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "SystemAdmins");
migrationBuilder.DropColumn(
name: "Username",
table: "SystemAdmins");
migrationBuilder.DropColumn(
name: "PasswordHash",
table: "Employees");
migrationBuilder.DropColumn(
name: "Username",
table: "Employees");
}
}
}
@@ -0,0 +1,42 @@
using Meezi.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Meezi.Infrastructure.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260601120000_AddCafeLocation")]
public partial class AddCafeLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "Latitude",
table: "Cafes",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Longitude",
table: "Cafes",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Latitude",
table: "Cafes");
migrationBuilder.DropColumn(
name: "Longitude",
table: "Cafes");
}
}
}
@@ -327,9 +327,15 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<bool>("IsVerified")
.HasColumnType("boolean");
b.Property<double?>("Latitude")
.HasColumnType("double precision");
b.Property<string>("LogoUrl")
.HasColumnType("text");
b.Property<double?>("Longitude")
.HasColumnType("double precision");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -929,6 +935,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<string>("NationalId")
.HasColumnType("text");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("text");
@@ -939,6 +948,9 @@ namespace Meezi.Infrastructure.Data.Migrations
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("BranchId");
@@ -2119,11 +2131,17 @@ namespace Meezi.Infrastructure.Data.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Username")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Phone")
@@ -3,7 +3,9 @@ using Meezi.Core.Constants;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Platform;
using Meezi.Core.Utilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -17,18 +19,25 @@ public static class PlatformDataSeeder
public static async Task SeedAsync(IServiceProvider services)
{
var env = services.GetRequiredService<IHostEnvironment>();
if (!env.IsDevelopment())
return;
var logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("PlatformDataSeeder");
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
await EnsureCatalogUpgradesAsync(db, logger);
// Production-safe: ensure the platform owner's system-admin account exists
// on every boot (ALL environments) so the admin panel is reachable on a
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger);
if (!env.IsDevelopment())
{
// Production: also ensure integration settings (Kavenegar enabled/template,
// etc.) exist so the admin Integrations page is populated. Idempotent.
await EnsureIntegrationSettingsAsync(db, logger);
return;
}
await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
@@ -36,6 +45,77 @@ public static class PlatformDataSeeder
await EnsureIntegrationSettingsAsync(db, logger);
}
/// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy.
/// The phone is configurable via "Seed:SystemAdminPhone" (env Seed__SystemAdminPhone)
/// and defaults to the platform owner's number. Idempotent — never duplicates.
/// </summary>
private static async Task EnsureOwnerAdminAsync(AppDbContext db, IConfiguration config, ILogger logger)
{
const string DefaultOwnerPhone = "09190345606";
const string DefaultAdminUsername = "admin";
var configuredPhone = config["Seed:SystemAdminPhone"];
var phone = PhoneNormalizer.Normalize(
string.IsNullOrWhiteSpace(configuredPhone) ? DefaultOwnerPhone : configuredPhone);
if (!PhoneNormalizer.IsValidIranMobile(phone))
{
logger.LogWarning("Owner system-admin seed skipped — invalid phone '{Phone}'", phone);
return;
}
var configuredUsername = config["Seed:SystemAdminUsername"];
var username = string.IsNullOrWhiteSpace(configuredUsername) ? DefaultAdminUsername : configuredUsername.Trim().ToLowerInvariant();
var defaultPassword = config["Seed:SystemAdminPassword"]; // optional — only set if provided
var existing = await db.SystemAdmins.FirstOrDefaultAsync(a => a.Phone == phone);
if (existing is null)
{
var admin = new SystemAdmin
{
Id = "sysadmin_owner",
Name = "مدیر سامانه",
Phone = phone,
IsActive = true,
Username = username,
PasswordHash = string.IsNullOrWhiteSpace(defaultPassword) ? null : PasswordHasher.Hash(defaultPassword)
};
db.SystemAdmins.Add(admin);
try
{
await db.SaveChangesAsync();
logger.LogInformation("Seeded owner system admin with phone {Phone}, username '{Username}'", phone, username);
}
catch (DbUpdateException)
{
logger.LogInformation("Owner system admin already seeded by another instance");
}
return;
}
// Patch existing admin: fill in missing username / password without overwriting set values
var patched = false;
if (string.IsNullOrWhiteSpace(existing.Username))
{
existing.Username = username;
patched = true;
}
if (string.IsNullOrWhiteSpace(existing.PasswordHash) && !string.IsNullOrWhiteSpace(defaultPassword))
{
existing.PasswordHash = PasswordHasher.Hash(defaultPassword);
patched = true;
}
if (patched)
{
await db.SaveChangesAsync();
logger.LogInformation("Patched owner system admin credentials (username/password)");
}
}
/// <summary>Idempotent plan/feature upgrades for all environments (including production).</summary>
public static async Task EnsureCatalogUpgradesAsync(IServiceProvider services)
{
@@ -45,8 +125,46 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger);
}
/// <summary>
/// Ensures every café has at least one active branch. Idempotent.
/// Creates a default branch named after the café for any café that has none.
/// </summary>
private static async Task EnsureDefaultBranchesAsync(AppDbContext db, ILogger logger)
{
// Load café IDs that have zero branches in one query
var cafeIdsWithBranches = await db.Branches
.Where(b => b.DeletedAt == null)
.Select(b => b.CafeId)
.Distinct()
.ToListAsync();
var cafesWithoutBranch = await db.Cafes
.Where(c => c.DeletedAt == null && !cafeIdsWithBranches.Contains(c.Id))
.Select(c => new { c.Id, c.Name })
.ToListAsync();
if (cafesWithoutBranch.Count == 0) return;
foreach (var cafe in cafesWithoutBranch)
{
db.Branches.Add(new Branch
{
CafeId = cafe.Id,
Name = cafe.Name,
IsActive = true,
});
}
await db.SaveChangesAsync();
logger.LogInformation("Created default branch for {Count} café(s) that had none", cafesWithoutBranch.Count);
}
private static async Task EnsureCatalogUpgradesAsync(AppDbContext db, ILogger logger)
{
// Ensure every café has at least one branch. Cafés registered before the
// auto-branch feature was added are patched on the first boot after upgrade.
await EnsureDefaultBranchesAsync(db, logger);
var featureAdds = new[]
{
("menu_3d", "منوی سه‌بعدی", "3D menu", "growth"),
@@ -126,7 +244,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
@@ -296,7 +414,7 @@ public static class PlatformDataSeeder
S("payment.vandar.enabled", "false", "payment", "فعال وندار"),
S("payment.vandar.sandbox", "true", "payment", "حالت تست وندار"),
S("integrations.kavenegar.enabled", "true", "integrations", "فعال کاوه‌نگار"),
S("integrations.kavenegar.otpTemplate", "verify", "integrations", "قالب OTP"),
S("integrations.kavenegar.otpTemplate", "meeziotp", "integrations", "قالب OTP"),
S("integrations.openai.enabled", "false", "integrations", "فعال OpenAI"),
S("integrations.openai.model", "gpt-4o-mini", "integrations", "مدل OpenAI"),
S("integrations.openai.coffeeAdvisor.enabled", "true", "integrations", "مشاور قهوه"),
@@ -50,8 +50,10 @@ public class SupportTicketService : ISupportTicketService
string cafeId,
CancellationToken cancellationToken = default)
{
return await QueryTickets()
.Where(t => t.CafeId == cafeId)
// NOTE: The Where MUST be applied on the EF entity set BEFORE the Select projection.
// Applying Where() after Select() onto a DTO record causes an EF translation error
// because EF can't translate "new SupportTicketDto(...).CafeId == x".
return await QueryTickets(cafeId)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync(cancellationToken);
}
@@ -119,11 +121,10 @@ public class SupportTicketService : ISupportTicketService
SupportTicketStatus? status,
CancellationToken cancellationToken = default)
{
var q = QueryTickets();
if (status.HasValue)
q = q.Where(t => t.Status == status.Value);
return await q.OrderByDescending(t => t.UpdatedAt).ToListAsync(cancellationToken);
// status filter is applied on the entity before projection — safe for EF translation.
return await QueryTickets(cafeId: null, status: status)
.OrderByDescending(t => t.UpdatedAt)
.ToListAsync(cancellationToken);
}
public async Task<SupportTicketDetailDto?> GetAdminAsync(
@@ -185,10 +186,23 @@ public class SupportTicketService : ISupportTicketService
return await GetAdminAsync(ticketId, cancellationToken);
}
private IQueryable<SupportTicketDto> QueryTickets() =>
_db.SupportTickets
.AsNoTracking()
.Select(t => new SupportTicketDto(
/// <summary>
/// Builds an EF-translatable query for support ticket list rows.
/// Filters are applied on the entity BEFORE the Select projection to avoid EF translation errors.
/// </summary>
private IQueryable<SupportTicketDto> QueryTickets(
string? cafeId = null,
SupportTicketStatus? status = null)
{
var q = _db.SupportTickets.AsNoTracking().AsQueryable();
// Apply entity-level filters BEFORE Select so EF can translate them.
if (cafeId is not null)
q = q.Where(t => t.CafeId == cafeId);
if (status.HasValue)
q = q.Where(t => t.Status == status.Value);
return q.Select(t => new SupportTicketDto(
t.Id,
t.CafeId,
t.Cafe != null ? t.Cafe.Name : "",
@@ -201,6 +215,7 @@ public class SupportTicketService : ISupportTicketService
t.CreatedAt,
t.UpdatedAt,
t.Messages.Count));
}
private static SupportTicketDto MapTicket(SupportTicket t) =>
new(
+3 -1
View File
@@ -1,5 +1,6 @@
using Meezi.API.Services;
using Meezi.Core.Discover;
using Xunit;
namespace Meezi.API.Tests;
@@ -44,7 +45,8 @@ public class DiscoverFilterTests
noise: "quiet",
priceTier: "mid",
size: null,
requireProfile: true);
requireProfile: true,
openNow: false);
Assert.Equal("تهران", f.City);
Assert.Equal(4, f.MinRating);
Assert.Contains("modern", f.Themes!);
@@ -0,0 +1,26 @@
using Meezi.API.Security;
namespace Meezi.API.Tests;
/// <summary>Test double that allows every action and has no captcha configured.</summary>
internal sealed class NoOpAbuseProtectionService : IAbuseProtectionService
{
public bool IsCaptchaConfigured => false;
public string? CaptchaSiteKey => null;
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAuthOtpByIpAsync(
string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckGuestOrderAsync(
string cafeId, string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckPublicWriteByIpAsync(
string clientIp, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
public Task<(bool Ok, string? ErrorCode, string? Message)> VerifyCaptchaAsync(
string? captchaToken, CancellationToken cancellationToken = default) =>
Task.FromResult<(bool, string?, string?)>((true, null, null));
}
@@ -16,9 +16,17 @@ internal sealed class NoOpInventoryService : IInventoryService
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null);
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, CancellationToken ct = default) =>
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
Task.FromResult<IngredientDto?>(null);
public Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
string cafeId,
string branchId,
DateOnly from,
DateOnly to,
CancellationToken ct = default) =>
Task.FromResult(new InventoryPurchasesSummaryDto(0, 0, []));
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
Task.FromResult<MenuItemRecipeDto?>(null);
@@ -1,4 +1,5 @@
using Meezi.API.Services;
using Meezi.Core.Entities;
namespace Meezi.API.Tests;
@@ -10,4 +11,11 @@ internal sealed class NoOpLoyaltyService : ILoyaltyService
decimal paidAmount,
CancellationToken ct = default) =>
Task.CompletedTask;
public Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
string cafeId,
Order order,
int pointsRequested,
CancellationToken ct = default) =>
Task.FromResult<(bool, LoyaltyRedeemResult?, string?)>((false, null, null));
}
@@ -0,0 +1,29 @@
using Meezi.API.Services;
using Microsoft.AspNetCore.Http;
namespace Meezi.API.Tests;
/// <summary>Test double that stores nothing and returns no URL.</summary>
internal sealed class NoOpMediaStorageService : IMediaStorageService
{
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
Task.FromResult<string?>(null);
}
@@ -11,4 +11,7 @@ internal sealed class NoOpOrderNotificationService : IOrderNotificationService
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
Task.CompletedTask;
public Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default) =>
Task.CompletedTask;
}
+1
View File
@@ -28,6 +28,7 @@ public class PrintingTests
218_000m,
0m,
DateTime.UtcNow,
1,
[
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
+7 -1
View File
@@ -1,11 +1,13 @@
using Meezi.API.Models.Menu;
using Meezi.API.Models.Orders;
using Meezi.API.Models.Public;
using Meezi.API.Security;
using Meezi.API.Services;
using Meezi.Core.Entities;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Xunit;
@@ -114,7 +116,11 @@ public class QrMenuTests
var tables = new TableService(db, config, kds, identity);
var shifts = new ShiftService(db);
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity);
var abuse = new NoOpAbuseProtectionService();
var http = new HttpContextAccessor();
var media = new NoOpMediaStorageService();
var reviews = new ReviewService(db, abuse, http, media);
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
}
+9 -1
View File
@@ -1057,6 +1057,7 @@
"fieldCategoryEn": "الفئة بالإنجليزية",
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
"fieldPublished": "منشور",
"commentsTitle": "إدارة التعليقات",
"noComments": "لا توجد تعليقات",
"approved": "موافق عليه",
@@ -1093,7 +1094,14 @@
"otp": "رمز التحقق",
"login": "دخول",
"error": "فشل تسجيل الدخول",
"devHint": "في التطوير يُطبع الرمز في سجل Admin API."
"devHint": "في التطوير يُطبع الرمز في سجل Admin API.",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة."
},
"dashboard": {
"title": "نظرة عامة",
+9 -1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "Category (English)",
"fieldContentFa": "Content (Persian, Markdown)",
"fieldContentEn": "Content (English, Markdown)",
"fieldPublished": "Published",
"commentsTitle": "Comment management",
"noComments": "No comments found",
"approved": "Approved",
@@ -1086,7 +1087,14 @@
"otp": "Verification code",
"login": "Sign in",
"error": "Login failed",
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP)."
"devHint": "In development the OTP is logged by Admin API (DEV admin OTP).",
"tabOtp": "One-time code",
"tabPassword": "Password",
"username": "Username",
"usernamePlaceholder": "Username",
"password": "Password",
"passwordPlaceholder": "Password",
"invalidCredentials": "Incorrect username or password."
},
"dashboard": {
"title": "Platform overview",
+9 -1
View File
@@ -1058,6 +1058,7 @@
"fieldCategoryEn": "دسته‌بندی انگلیسی",
"fieldContentFa": "محتوا فارسی (Markdown)",
"fieldContentEn": "محتوا انگلیسی (Markdown)",
"fieldPublished": "وضعیت انتشار",
"commentsTitle": "مدیریت نظرات",
"noComments": "نظری یافت نشد",
"approved": "تأیید شده",
@@ -1086,7 +1087,14 @@
"otp": "کد تأیید",
"login": "ورود",
"error": "خطا در ورود",
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP)."
"devHint": "در حالت توسعه کد در لاگ Admin API چاپ می‌شود (DEV admin OTP).",
"tabOtp": "کد یکبارمصرف",
"tabPassword": "رمز عبور",
"username": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"password": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است."
},
"dashboard": {
"title": "خلاصه سامانه",
+119 -8
View File
@@ -13,14 +13,25 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type LoginTab = "otp" | "password";
export default function AdminLoginPage() {
const t = useTranslations("admin.auth");
const tAuth = useTranslations("auth");
const router = useRouter();
const setAuth = useAdminAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<LoginTab>("otp");
// OTP state
const [phone, setPhone] = useState("09120000001");
const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone");
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
// Password state
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -34,6 +45,8 @@ export default function AdminLoginPage() {
case "INVALID_OTP":
case "VALIDATION_ERROR":
return tAuth("invalidOtp");
case "INVALID_TOKEN":
return t("invalidCredentials");
default:
return err.message;
}
@@ -46,7 +59,7 @@ export default function AdminLoginPage() {
setError(null);
try {
await adminPost("/api/admin/auth/send-otp", { phone });
setStep("otp");
setOtpStep("otp");
setCode("");
} catch (e) {
setError(authErrorMessage(e));
@@ -55,7 +68,7 @@ export default function AdminLoginPage() {
}
};
const verify = async () => {
const verifyOtp = async () => {
const normalized = normalizeOtpInput(code);
if (normalized.length !== 6) {
setError(tAuth("invalidOtp"));
@@ -77,6 +90,34 @@ export default function AdminLoginPage() {
}
};
const loginWithPassword = async () => {
if (!username.trim() || !password) {
setError(t("invalidCredentials"));
return;
}
setLoading(true);
setError(null);
try {
const data = await adminPost<AuthTokenResponse>("/api/admin/auth/login", {
username: username.trim(),
password,
});
setAuth(data);
router.push("/admin");
} catch (e) {
setError(authErrorMessage(e));
} finally {
setLoading(false);
}
};
const switchTab = (next: LoginTab) => {
setTab(next);
setError(null);
setOtpStep("phone");
setCode("");
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4" dir="rtl">
<Card className="w-full max-w-md">
@@ -87,8 +128,36 @@ export default function AdminLoginPage() {
<p className="text-center text-xs text-muted-foreground">{t("devHint")}</p>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{step === "phone" ? (
{/* Tab switcher */}
<div className="flex border-b px-6">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "otp"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("otp")}
>
{t("tabOtp")}
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "password"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("password")}
>
{t("tabPassword")}
</button>
</div>
<CardContent className="space-y-4 pt-4">
{/* ───── OTP tab ───── */}
{tab === "otp" && otpStep === "phone" && (
<form
className="space-y-4"
onSubmit={(e) => {
@@ -111,12 +180,14 @@ export default function AdminLoginPage() {
{loading ? "..." : t("sendOtp")}
</Button>
</form>
) : (
)}
{tab === "otp" && otpStep === "otp" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void verify();
if (!loading) void verifyOtp();
}}
>
<LabeledField label={t("otp")} htmlFor="admin-login-otp">
@@ -142,7 +213,7 @@ export default function AdminLoginPage() {
className="w-full"
disabled={loading}
onClick={() => {
setStep("phone");
setOtpStep("phone");
setCode("");
setError(null);
}}
@@ -151,6 +222,46 @@ export default function AdminLoginPage() {
</Button>
</form>
)}
{/* ───── Password tab ───── */}
{tab === "password" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void loginWithPassword();
}}
>
<LabeledField label={t("username")} htmlFor="admin-login-username">
<Input
id="admin-login-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="username"
autoFocus
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="admin-login-password">
<Input
id="admin-login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="current-password"
/>
</LabeledField>
<Button type="submit" className="w-full" disabled={loading || !username.trim() || !password}>
{loading ? "..." : t("login")}
</Button>
</form>
)}
{error ? <p className="text-center text-sm text-destructive">{error}</p> : null}
</CardContent>
</Card>
+164 -87
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import {
adminDelete,
adminGet,
@@ -37,6 +38,57 @@ import {
type TicketStatus,
} from "@/components/support/ticket-status-badge";
// iOS-style toggle switch used throughout this file
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
);
}
// Styled single-select indicator (replaces raw <input type="radio">).
function RadioDot({
selected,
onSelect,
disabled,
}: {
selected: boolean;
onSelect: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={onSelect}
className={cn(
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
selected ? "border-[#0F6E56]" : "border-muted-foreground/40 hover:border-muted-foreground/70"
)}
>
{selected ? <span className="h-2.5 w-2.5 rounded-full bg-[#0F6E56]" /> : null}
</button>
);
}
export function AdminDashboardScreen() {
const t = useTranslations("admin.dashboard");
const { data } = useQuery({
@@ -78,6 +130,50 @@ function StatCard({ label, value }: { label: string; value: number }) {
);
}
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
const t = useTranslations("admin.plans");
const [price, setPrice] = useState(plan.monthlyPriceToman);
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
// Sync server values if they change (e.g. after successful save + refetch)
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
const flush = () =>
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
return (
<Card className="rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
<p className="text-xs text-muted-foreground">{plan.tier}</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t("monthlyPrice")}
<Input
type="number"
className="mt-1"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
onBlur={flush}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
value={maxOrders}
onChange={(e) => setMaxOrders(Number(e.target.value))}
onBlur={flush}
/>
</label>
</CardContent>
</Card>
);
}
export function AdminPlansScreen() {
const t = useTranslations("admin.plans");
const qc = useQueryClient();
@@ -108,38 +204,7 @@ export function AdminPlansScreen() {
<div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1>
{plans.map((plan) => (
<Card key={plan.tier} className="rounded-xl border border-border/80">
<CardHeader className="pb-2">
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
<p className="text-xs text-muted-foreground">{plan.tier}</p>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2">
<label className="text-sm">
{t("monthlyPrice")}
<Input
type="number"
className="mt-1"
value={plan.monthlyPriceToman}
onChange={(e) => {
plan.monthlyPriceToman = Number(e.target.value);
}}
onBlur={() => save.mutate(plan)}
/>
</label>
<label className="text-sm">
{t("maxOrders")}
<Input
type="number"
className="mt-1"
defaultValue={plan.limits.maxOrdersPerDay}
onBlur={(e) => {
plan.limits.maxOrdersPerDay = Number(e.target.value);
save.mutate(plan);
}}
/>
</label>
</CardContent>
</Card>
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
))}
</div>
);
@@ -166,17 +231,34 @@ export function AdminSettingsScreen() {
<div className="space-y-4">
<h1 className="text-lg font-medium">{t("title")}</h1>
<div className="space-y-2">
{settings.map((s) => (
{settings.map((s) => {
const isBool = s.value === "true" || s.value === "false";
return (
<Card key={s.id} className="rounded-xl border border-border/80 p-4">
<p className="text-xs text-muted-foreground">{s.key}</p>
<div className={isBool ? "flex items-center justify-between gap-3" : undefined}>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{s.key}</p>
{s.descriptionFa ? (
<p className="text-[11px] text-muted-foreground">{s.descriptionFa}</p>
) : null}
</div>
{isBool ? (
<Toggle
checked={s.value === "true"}
onChange={(v) => save.mutate({ key: s.key, value: String(v) })}
disabled={save.isPending}
/>
) : (
<Input
className="mt-2"
defaultValue={s.value}
onBlur={(e) => save.mutate({ key: s.key, value: e.target.value })}
/>
)}
</div>
</Card>
))}
);
})}
</div>
</div>
);
@@ -577,34 +659,30 @@ export function AdminIntegrationsScreen() {
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<input
type="radio"
name="activeGateway"
checked={activeGateway === g.id}
onChange={() => setActiveGateway(g.id)}
<RadioDot
selected={activeGateway === g.id}
onSelect={() => setActiveGateway(g.id)}
/>
<span className="font-medium">{g.displayNameFa}</span>
{activeGateway === g.id ? (
<Badge className="bg-[#E1F5EE] text-[#0F6E56]">{t("active")}</Badge>
) : null}
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={g.isEnabled}
onChange={(e) => updateGateway(g.id, { isEnabled: e.target.checked })}
onChange={(v) => updateGateway(g.id, { isEnabled: v })}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Toggle
checked={g.sandbox}
onChange={(e) => updateGateway(g.id, { sandbox: e.target.checked })}
onChange={(v) => updateGateway(g.id, { sandbox: v })}
/>
{t("sandbox")}
</label>
<span>{t("sandbox")}</span>
</div>
{g.id === "zarinpal" ? (
<label className="block text-sm">
{t("merchantId")}
@@ -779,14 +857,13 @@ export function AdminIntegrationsScreen() {
{t("kavenegarTitle")}
</p>
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={kavenegar.isEnabled}
onChange={(e) => setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))}
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("apiKey")}
<Input
@@ -815,14 +892,13 @@ export function AdminIntegrationsScreen() {
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
<p className="text-sm font-medium">{t("openAiTitle")}</p>
<p className="text-xs text-muted-foreground">{t("openAiHint")}</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={openAi.isEnabled}
onChange={(e) => setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))}
onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("openAiApiKey")}
<Input
@@ -836,35 +912,37 @@ export function AdminIntegrationsScreen() {
</label>
<label className="block text-sm">
{t("openAiModel")}
<Input
className="mt-1"
<select
className="mt-1 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
dir="ltr"
value={openAi.model}
onChange={(e) => setOpenAi((o) => ({ ...o, model: e.target.value }))}
/>
>
<option value="gpt-4o-mini">gpt-4o-mini (fast, cheap)</option>
<option value="gpt-4o">gpt-4o (best quality)</option>
<option value="gpt-4-turbo">gpt-4-turbo</option>
<option value="gpt-4">gpt-4</option>
<option value="gpt-3.5-turbo">gpt-3.5-turbo (legacy)</option>
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={openAi.coffeeAdvisorEnabled}
onChange={(e) =>
setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
}
onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
/>
{t("coffeeAdvisorEnabled")}
</label>
<span>{t("coffeeAdvisorEnabled")}</span>
</div>
</Card>
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
<p className="text-sm font-medium">{t("meshyTitle")}</p>
<p className="text-xs text-muted-foreground">{t("meshyHint")}</p>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={meshy.isEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))}
onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
/>
{t("enabled")}
</label>
<span>{t("enabled")}</span>
</div>
<label className="block text-sm">
{t("meshyApiKey")}
<Input
@@ -876,14 +954,13 @@ export function AdminIntegrationsScreen() {
onChange={(e) => setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
<div className="flex items-center gap-2 text-sm">
<Toggle
checked={meshy.menu3dEnabled}
onChange={(e) => setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))}
onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
/>
{t("menu3dEnabled")}
</label>
<span>{t("menu3dEnabled")}</span>
</div>
</Card>
</section>
</div>
@@ -44,11 +44,12 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const user = useAdminAuthStore((s) => s.user);
const hasHydrated = useAdminAuthStore((s) => s._hasHydrated);
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
useEffect(() => {
if (!user?.accessToken) router.replace("/admin/login");
}, [user, router]);
if (hasHydrated && !user?.accessToken) router.replace("/admin/login");
}, [user, hasHydrated, router]);
return (
<div className="flex min-h-screen bg-muted/30" dir="rtl">
@@ -2,7 +2,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "@/i18n/routing";
import { adminDelete, adminGet, adminPatch, adminPost, adminPut } from "@/lib/api/admin-client";
import type {
AdminBlogPost,
@@ -15,6 +16,8 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/routing";
import {
CheckCircle2,
XCircle,
@@ -66,13 +69,13 @@ export function AdminBlogListScreen() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-medium">{t("blogTitle")}</h1>
<a
href="website/blog/new"
<Link
href="/admin/website/blog/new"
className="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
<Plus className="size-4" />
{t("newPost")}
</a>
</Link>
</div>
{isLoading ? (
@@ -113,7 +116,7 @@ export function AdminBlogListScreen() {
asChild
className="h-8 px-2 text-xs"
>
<a href={`website/blog/${post.id}`}>{t("edit")}</a>
<Link href={`/admin/website/blog/${post.id}`}>{t("edit")}</Link>
</Button>
<Button
size="sm"
@@ -147,6 +150,74 @@ export function AdminBlogListScreen() {
// ── Blog Post Editor ─────────────────────────────────────────────────────────
// iOS-style toggle (mirrors the one in admin-screens.tsx).
function BlogToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
checked ? "translate-x-5" : "translate-x-0"
)}
/>
</button>
);
}
// Module-level so it keeps a stable component identity across renders.
// (Previously defined inside the editor, which remounted every input on each
// keystroke and dropped focus after a single character.)
function BlogField({
label,
value,
onChange,
multiline,
dir,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
dir: "rtl" | "ltr";
}) {
return (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{multiline ? (
<textarea
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
dir={dir}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm"
dir={dir}
/>
)}
</div>
);
}
interface PostEditorProps {
postId?: string; // undefined = new post
}
@@ -154,6 +225,7 @@ interface PostEditorProps {
export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
const t = useTranslations("admin.website");
const qc = useQueryClient();
const router = useRouter();
const isNew = !postId;
const { data: post } = useQuery({
@@ -162,7 +234,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
enabled: !isNew,
});
const [form, setForm] = useState({
const emptyForm = {
slug: "",
titleFa: "",
titleEn: "",
@@ -173,85 +245,63 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
author: "تیم میزی",
categoryFa: "",
categoryEn: "",
});
isPublished: false,
};
// Sync fetched data into form once loaded
const initialised = !isNew && post;
const displayForm = initialised
? {
slug: post!.slug,
titleFa: post!.titleFa,
titleEn: post!.titleEn,
excerptFa: post!.excerptFa,
excerptEn: post!.excerptEn,
contentFa: post!.contentFa,
contentEn: post!.contentEn,
author: post!.author,
categoryFa: post!.categoryFa,
categoryEn: post!.categoryEn,
const [form, setForm] = useState(emptyForm);
const [formReady, setFormReady] = useState(isNew);
// Populate form from server data the first time it arrives
useEffect(() => {
if (post && !formReady) {
setForm({
slug: post.slug,
titleFa: post.titleFa,
titleEn: post.titleEn,
excerptFa: post.excerptFa,
excerptEn: post.excerptEn,
contentFa: post.contentFa,
contentEn: post.contentEn,
author: post.author,
categoryFa: post.categoryFa,
categoryEn: post.categoryEn,
isPublished: post.isPublished,
});
setFormReady(true);
}
: form;
}, [post, formReady]);
const setField = (key: keyof typeof form) => (v: string) =>
setForm((f) => ({ ...f, [key]: v }));
const saveMut = useMutation({
mutationFn: (data: typeof form) =>
mutationFn: () =>
isNew
? adminPost("/api/admin/website/posts", data)
: adminPut(`/api/admin/website/posts/${postId}`, data),
onSuccess: () => {
? adminPost<{ id: string }>("/api/admin/website/posts", form)
: adminPut<{ id: string }>(`/api/admin/website/posts/${postId}`, form),
onSuccess: (result) => {
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
notify.success(t("saved"));
// After creating a new post, go to its edit page so the user can
// continue editing and won't accidentally hit Save again (which would
// fail on the unique slug constraint).
if (isNew) router.push(`/admin/website/blog/${result.id}`);
},
onError: () => notify.error(t("errorGeneric")),
});
const Field = ({
label,
value,
onChange,
multiline,
}: {
label: string;
value: string;
onChange: (v: string) => void;
multiline?: boolean;
}) => (
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{multiline ? (
<textarea
rows={8}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="h-9 text-sm"
dir={label.toLowerCase().includes("fa") || label.includes("فارسی") ? "rtl" : "ltr"}
/>
)}
</div>
);
const current = initialised ? post! : form;
const setField = (key: keyof typeof form) => (v: string) => {
if (initialised) {
// We'd need local state override — keep it simple for demo
if (!isNew && !formReady) {
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
}
setForm((f) => ({ ...f, [key]: v }));
};
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<a href="." className="flex items-center gap-1.5">
<Link href="/admin/website/blog" className="flex items-center gap-1.5">
<ArrowLeft className="size-4" />
{t("backToBlog")}
</a>
</Link>
</Button>
<h1 className="text-lg font-medium">
{isNew ? t("newPost") : t("editPost")}
@@ -261,80 +311,35 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
<Card className="rounded-xl border-border/80">
<CardContent className="space-y-4 pt-5">
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t("fieldSlug")}
value={isNew ? form.slug : (post?.slug ?? "")}
onChange={setField("slug")}
/>
<Field
label={t("fieldAuthor")}
value={isNew ? form.author : (post?.author ?? "")}
onChange={setField("author")}
/>
<BlogField label={t("fieldSlug")} value={form.slug} onChange={setField("slug")} dir="ltr" />
<BlogField label={t("fieldAuthor")} value={form.author} onChange={setField("author")} dir="rtl" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t("fieldTitleFa")}
value={isNew ? form.titleFa : (post?.titleFa ?? "")}
onChange={setField("titleFa")}
/>
<Field
label={t("fieldTitleEn")}
value={isNew ? form.titleEn : (post?.titleEn ?? "")}
onChange={setField("titleEn")}
/>
<BlogField label={t("fieldTitleFa")} value={form.titleFa} onChange={setField("titleFa")} dir="rtl" />
<BlogField label={t("fieldTitleEn")} value={form.titleEn} onChange={setField("titleEn")} dir="ltr" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t("fieldExcerptFa")}
value={isNew ? form.excerptFa : (post?.excerptFa ?? "")}
onChange={setField("excerptFa")}
/>
<Field
label={t("fieldExcerptEn")}
value={isNew ? form.excerptEn : (post?.excerptEn ?? "")}
onChange={setField("excerptEn")}
/>
<BlogField label={t("fieldExcerptFa")} value={form.excerptFa} onChange={setField("excerptFa")} dir="rtl" />
<BlogField label={t("fieldExcerptEn")} value={form.excerptEn} onChange={setField("excerptEn")} dir="ltr" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Field
label={t("fieldCategoryFa")}
value={isNew ? form.categoryFa : (post?.categoryFa ?? "")}
onChange={setField("categoryFa")}
/>
<Field
label={t("fieldCategoryEn")}
value={isNew ? form.categoryEn : (post?.categoryEn ?? "")}
onChange={setField("categoryEn")}
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
</div>
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
<span className="text-sm font-medium">{t("fieldPublished")}</span>
<BlogToggle
checked={form.isPublished}
onChange={(v) => setForm((f) => ({ ...f, isPublished: v }))}
/>
</div>
<Field
label={t("fieldContentFa")}
value={isNew ? form.contentFa : (post?.contentFa ?? "")}
onChange={setField("contentFa")}
multiline
/>
<Field
label={t("fieldContentEn")}
value={isNew ? form.contentEn : (post?.contentEn ?? "")}
onChange={setField("contentEn")}
multiline
/>
<div className="flex justify-end pt-2">
<Button
onClick={() => saveMut.mutate(isNew ? form : {
slug: post?.slug ?? form.slug,
titleFa: post?.titleFa ?? form.titleFa,
titleEn: post?.titleEn ?? form.titleEn,
excerptFa: post?.excerptFa ?? form.excerptFa,
excerptEn: post?.excerptEn ?? form.excerptEn,
contentFa: post?.contentFa ?? form.contentFa,
contentEn: post?.contentEn ?? form.contentEn,
author: post?.author ?? form.author,
categoryFa: post?.categoryFa ?? form.categoryFa,
categoryEn: post?.categoryEn ?? form.categoryEn,
})}
onClick={() => saveMut.mutate()}
disabled={saveMut.isPending}
className="min-w-[100px]"
>
+11 -1
View File
@@ -4,15 +4,20 @@ import type { AuthTokenResponse } from "@/lib/api/types";
interface AdminAuthState {
user: AuthTokenResponse | null;
/** True once Zustand has finished rehydrating from localStorage. */
_hasHydrated: boolean;
setAuth: (user: AuthTokenResponse) => void;
clearAuth: () => void;
isAuthenticated: () => boolean;
_setHasHydrated: (v: boolean) => void;
}
export const useAdminAuthStore = create<AdminAuthState>()(
persist(
(set, get) => ({
user: null,
_hasHydrated: false,
_setHasHydrated: (v) => set({ _hasHydrated: v }),
setAuth: (user) => {
if (typeof window !== "undefined") {
localStorage.setItem("meezi_admin_access_token", user.accessToken);
@@ -29,6 +34,11 @@ export const useAdminAuthStore = create<AdminAuthState>()(
},
isAuthenticated: () => !!get().user?.accessToken,
}),
{ name: "meezi_admin_auth" }
{
name: "meezi_admin_auth",
onRehydrateStorage: () => (state) => {
state?._setHasHydrated(true);
},
}
)
);
File diff suppressed because one or more lines are too long
+38 -5
View File
@@ -45,7 +45,17 @@
"chooseCafe": "اختر المقهى",
"chooseCafeSubtitle": "هذا الرقم لديه صلاحية على عدة مقاهٍ. اختر واحداً للمتابعة.",
"createNewCafe": "إنشاء مقهى جديد",
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟"
"createNewCafeHint": "هل تريد بدء مقهاك الخاص بهذا الرقم؟",
"tabOtp": "رمز مؤقت",
"tabPassword": "كلمة المرور",
"username": "اسم المستخدم",
"usernamePlaceholder": "اسم المستخدم",
"password": "كلمة المرور",
"passwordPlaceholder": "كلمة المرور",
"invalidCredentials": "اسم المستخدم أو كلمة المرور غير صحيحة.",
"kojaSlug": "عنوان الملف الشخصي في كوجا",
"kojaSlugHint": "يجد الزوار مقهاكم على هذا العنوان",
"kojaSlugPlaceholder": "مثال: my-cafe"
},
"roles": {
"owner": "المالك",
@@ -386,7 +396,8 @@
"attendance": "الحضور",
"leave": "الإجازة",
"payroll": "الرواتب",
"access": "صلاحيات الفروع"
"access": "صلاحيات الفروع",
"credentials": "بيانات الدخول"
},
"myAttendance": "حضوري",
"clockIn": "تسجيل دخول",
@@ -396,7 +407,22 @@
"paid": "مدفوع",
"markPaid": "تسجيل الدفع",
"employeeCount": "الموظفون",
"monthYear": "شهر الرواتب"
"monthYear": "شهر الرواتب",
"credentials": {
"title": "بيانات دخول الموظفين",
"subtitle": "حدد اسم مستخدم وكلمة مرور لكل موظف حتى يتمكن من تسجيل الدخول دون رمز OTP.",
"selectEmployee": "اختر موظفاً أولاً",
"username": "اسم المستخدم",
"usernamePlaceholder": "مثال: ali_barista",
"password": "كلمة المرور (8 أحرف على الأقل)",
"passwordPlaceholder": "كلمة مرور جديدة",
"set": "حفظ بيانات الدخول",
"remove": "حذف بيانات الدخول",
"removeConfirm": "هل أنت متأكد؟ لن يتمكن الموظف من تسجيل الدخول بكلمة مرور بعد الآن.",
"saved": "تم حفظ بيانات الدخول.",
"removed": "تم حذف بيانات الدخول.",
"usernameTaken": "اسم المستخدم هذا مستخدم بالفعل."
}
},
"reviews": {
"title": "تقييمات العملاء",
@@ -993,7 +1019,8 @@
"total": "المبلغ المستحق",
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
"payTotal": "ادفع {total}",
"redirecting": "جارٍ التحويل إلى البوابة..."
"redirecting": "جارٍ التحويل إلى البوابة...",
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
}
},
"settings": {
@@ -1123,7 +1150,13 @@
"uploadLogo": "رفع الشعار",
"uploadCover": "رفع الغلاف",
"saved": "تم حفظ الملف.",
"reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم."
"reloginHint": "تم تحديث الخطة؛ سجّل الخروج والدخول إن لزم.",
"slug": "عنوان ملف كوجا",
"slugHint": "صفحة مقهاكم على كوجا — أحرف صغيرة وأرقام وشرطات فقط",
"slugPlaceholder": "my-cafe",
"slugTaken": "هذا العنوان مأخوذ. الرجاء اختيار عنوان آخر.",
"slugInvalid": "عنوان غير صالح. استخدم الأحرف الصغيرة والأرقام والشرطات فقط.",
"kojaUrl": "رابط كوجا"
},
"taraz": "تاراز (الضرائب)",
"tarazHint": "إرسال فواتير الأمس إلى تاراز (وضع تجريبي).",
+38 -5
View File
@@ -56,7 +56,17 @@
"chooseCafe": "Choose a café",
"chooseCafeSubtitle": "This number has access to several cafés. Pick one to continue.",
"createNewCafe": "Create a new café",
"createNewCafeHint": "Want to start your own café with this number?"
"createNewCafeHint": "Want to start your own café with this number?",
"tabOtp": "One-time code",
"tabPassword": "Password",
"username": "Username",
"usernamePlaceholder": "Username",
"password": "Password",
"passwordPlaceholder": "Password",
"invalidCredentials": "Incorrect username or password.",
"kojaSlug": "Koja profile address",
"kojaSlugHint": "Customers will find your cafe at this address",
"kojaSlugPlaceholder": "e.g. my-cafe"
},
"roles": {
"owner": "Owner",
@@ -405,7 +415,8 @@
"attendance": "Attendance",
"leave": "Leave",
"payroll": "Payroll",
"access": "Branch access"
"access": "Branch access",
"credentials": "Login credentials"
},
"myAttendance": "My attendance",
"clockIn": "Clock in",
@@ -415,7 +426,22 @@
"paid": "Paid",
"markPaid": "Mark paid",
"employeeCount": "Employees",
"monthYear": "Payroll month"
"monthYear": "Payroll month",
"credentials": {
"title": "Employee login credentials",
"subtitle": "Set a username and password for each employee so they can sign in without an OTP.",
"selectEmployee": "Select an employee first",
"username": "Username",
"usernamePlaceholder": "e.g. ali_barista",
"password": "Password (min 8 characters)",
"passwordPlaceholder": "New password",
"set": "Save credentials",
"remove": "Remove credentials",
"removeConfirm": "Are you sure? The employee will no longer be able to sign in with a password.",
"saved": "Credentials saved.",
"removed": "Credentials removed.",
"usernameTaken": "This username is already taken."
}
},
"reviews": {
"title": "Customer reviews",
@@ -1065,7 +1091,8 @@
"total": "Amount due",
"secureNote": "Payment is processed through a secure bank gateway.",
"payTotal": "Pay {total}",
"redirecting": "Redirecting to gateway..."
"redirecting": "Redirecting to gateway...",
"paymentFailed": "Payment failed. Please try again."
}
},
"settings": {
@@ -1205,7 +1232,13 @@
"uploadLogo": "Upload logo",
"uploadCover": "Upload cover",
"saved": "Profile saved.",
"reloginHint": "Plan updated; sign out and in again if the badge looks wrong."
"reloginHint": "Plan updated; sign out and in again if the badge looks wrong.",
"slug": "Koja profile address",
"slugHint": "Your cafe page on Koja — lowercase letters, digits, hyphens only",
"slugPlaceholder": "my-cafe",
"slugTaken": "This address is already taken. Please choose another.",
"slugInvalid": "Invalid address. Use lowercase letters, digits, and hyphens only.",
"kojaUrl": "Koja URL"
},
"taraz": "Taraz (tax system)",
"tarazHint": "Submit yesterday's invoices to Taraz (demo mode logs only).",
+38 -5
View File
@@ -56,7 +56,17 @@
"chooseCafe": "انتخاب کافه",
"chooseCafeSubtitle": "این شماره به چند کافه دسترسی دارد. یکی را انتخاب کنید.",
"createNewCafe": "ایجاد کافه جدید",
"createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟"
"createNewCafeHint": "می‌خواهید کافه خودتان را با همین شماره راه‌اندازی کنید؟",
"tabOtp": "کد یکبارمصرف",
"tabPassword": "رمز عبور",
"username": "نام کاربری",
"usernamePlaceholder": "نام کاربری",
"password": "رمز عبور",
"passwordPlaceholder": "رمز عبور",
"invalidCredentials": "نام کاربری یا رمز عبور اشتباه است.",
"kojaSlug": "آدرس پروفایل در کوجا",
"kojaSlugHint": "بازدیدکنندگان از این آدرس کافه شما را پیدا می‌کنند",
"kojaSlugPlaceholder": "مثال: cafe-roya"
},
"roles": {
"owner": "مالک",
@@ -405,7 +415,8 @@
"attendance": "حضور و غیاب",
"leave": "مرخصی",
"payroll": "حقوق",
"access": "دسترسی شعب"
"access": "دسترسی شعب",
"credentials": "رمز ورود"
},
"myAttendance": "حضور من",
"clockIn": "ورود",
@@ -415,7 +426,22 @@
"paid": "پرداخت شده",
"markPaid": "ثبت پرداخت",
"employeeCount": "تعداد کارمندان",
"monthYear": "ماه حقوق"
"monthYear": "ماه حقوق",
"credentials": {
"title": "مدیریت رمز ورود کارمندان",
"subtitle": "برای هر کارمند می‌توانید نام کاربری و رمز عبور تعریف کنید تا بدون نیاز به کد OTP وارد شوند.",
"selectEmployee": "ابتدا یک کارمند انتخاب کنید",
"username": "نام کاربری",
"usernamePlaceholder": "مثال: ali_barista",
"password": "رمز عبور (حداقل ۸ کاراکتر)",
"passwordPlaceholder": "رمز عبور جدید",
"set": "ذخیره رمز ورود",
"remove": "حذف رمز ورود",
"removeConfirm": "آیا مطمئنید؟ کارمند دیگر نمی‌تواند با رمز عبور وارد شود.",
"saved": "رمز ورود ذخیره شد.",
"removed": "رمز ورود حذف شد.",
"usernameTaken": "این نام کاربری قبلاً استفاده شده است."
}
},
"reviews": {
"title": "نظرات مشتریان",
@@ -1066,7 +1092,8 @@
"total": "مبلغ قابل پرداخت",
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام می‌شود.",
"payTotal": "پرداخت {total}",
"redirecting": "در حال انتقال به درگاه..."
"redirecting": "در حال انتقال به درگاه...",
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید."
}
},
"settings": {
@@ -1210,7 +1237,13 @@
"uploadLogo": "بارگذاری لوگو",
"uploadCover": "بارگذاری کاور",
"saved": "پروفایل ذخیره شد.",
"reloginHint": "پلن به‌روز شد؛ در صورت نیاز یک‌بار خارج و وارد شوید."
"reloginHint": "پلن به‌روز شد؛ در صورت نیاز یک‌بار خارج و وارد شوید.",
"slug": "آدرس پروفایل کوجا",
"slugHint": "آدرس صفحه کافه شما در کوجا — فقط حروف انگلیسی، اعداد و خط تیره",
"slugPlaceholder": "cafe-roya",
"slugTaken": "این آدرس قبلاً گرفته شده. آدرس دیگری انتخاب کنید.",
"slugInvalid": "آدرس نامعتبر است. فقط حروف انگلیسی کوچک، اعداد و خط تیره مجاز است.",
"kojaUrl": "آدرس کوجا"
},
"plans": {
"compareLabel": "مقایسه پلن‌ها",
+2583 -159
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@
},
"dependencies": {
"@google/model-viewer": "^4.2.0",
"three": "^0.163.0",
"three": "^0.182.0",
"@microsoft/signalr": "^8.0.7",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.2",
@@ -18,13 +18,17 @@ export default function DashboardLayout({
const locale = useLocale();
const router = useRouter();
const user = useAuthStore((s) => s.user);
const hasHydrated = useAuthStore((s) => s._hasHydrated);
useOfflineSync(); // register online/offline listeners + load queue count
useEffect(() => {
if (!user?.accessToken) {
// Wait for Zustand to finish reading localStorage before deciding to redirect.
// Without this guard, the effect fires while user is still null on first render,
// causing a spurious redirect to /login even when the token exists in storage.
if (hasHydrated && !user?.accessToken) {
router.replace("/login");
}
}, [user, router]);
}, [user, hasHydrated, router]);
const isRtl = locale !== "en";
+125 -6
View File
@@ -12,14 +12,24 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { OtpInput } from "@/components/ui/otp-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type LoginTab = "otp" | "password";
export default function LoginPage() {
const t = useTranslations("auth");
const router = useRouter();
const setAuth = useAuthStore((s) => s.setAuth);
const [tab, setTab] = useState<LoginTab>("otp");
// OTP state
const [phone, setPhone] = useState("09121234567");
const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "otp">("phone");
const [otpStep, setOtpStep] = useState<"phone" | "otp">("phone");
// Password state
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -32,6 +42,9 @@ export default function LoginPage() {
return t("smsFailed");
case "INVALID_OTP":
return t("invalidOtp");
case "INVALID_TOKEN":
case "NOT_FOUND":
return tab === "password" ? t("invalidCredentials") : t("notFound");
default:
return err.message;
}
@@ -44,7 +57,7 @@ export default function LoginPage() {
setError(null);
try {
await apiPost("/api/auth/send-otp", { phone });
setStep("otp");
setOtpStep("otp");
} catch (e) {
if (e instanceof ApiClientError && e.code === "NOT_FOUND") {
// No account → take them to register with phone pre-filled
@@ -74,6 +87,34 @@ export default function LoginPage() {
}
};
const loginWithPassword = async () => {
if (!username.trim() || !password) {
setError(t("invalidCredentials"));
return;
}
setLoading(true);
setError(null);
try {
const data = await apiPost<AuthTokenResponse>("/api/auth/login", {
username: username.trim(),
password,
});
setAuth(data);
router.push("/pos");
} catch (e) {
setError(authErrorMessage(e));
} finally {
setLoading(false);
}
};
const switchTab = (next: LoginTab) => {
setTab(next);
setError(null);
setOtpStep("phone");
setCode("");
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
<Card className="w-full max-w-md">
@@ -81,8 +122,36 @@ export default function LoginPage() {
<CardTitle className="text-center text-primary">{t("title")}</CardTitle>
<p className="text-center text-sm text-muted-foreground">{t("subtitle")}</p>
</CardHeader>
<CardContent className="space-y-4">
{step === "phone" ? (
{/* Tab switcher */}
<div className="flex border-b px-6">
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "otp"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("otp")}
>
{t("tabOtp")}
</button>
<button
type="button"
className={`flex-1 py-2 text-sm font-medium transition-colors cursor-pointer ${
tab === "password"
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
onClick={() => switchTab("password")}
>
{t("tabPassword")}
</button>
</div>
<CardContent className="space-y-4 pt-4">
{/* ───── OTP tab ───── */}
{tab === "otp" && otpStep === "phone" && (
<form
className="space-y-4"
onSubmit={(e) => {
@@ -105,7 +174,9 @@ export default function LoginPage() {
{loading ? "..." : t("sendOtp")}
</Button>
</form>
) : (
)}
{tab === "otp" && otpStep === "otp" && (
<form
className="space-y-4"
onSubmit={(e) => {
@@ -128,12 +199,60 @@ export default function LoginPage() {
type="button"
variant="ghost"
className="w-full"
onClick={() => setStep("phone")}
onClick={() => {
setOtpStep("phone");
setCode("");
setError(null);
}}
>
{t("resend")}
</Button>
</form>
)}
{/* ───── Password tab ───── */}
{tab === "password" && (
<form
className="space-y-4"
onSubmit={(e) => {
e.preventDefault();
if (!loading) void loginWithPassword();
}}
>
<LabeledField label={t("username")} htmlFor="login-username">
<Input
id="login-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="username"
autoFocus
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="login-password">
<Input
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="current-password"
/>
</LabeledField>
<Button
type="submit"
className="w-full"
disabled={loading || !username.trim() || !password}
>
{loading ? "..." : t("verify")}
</Button>
</form>
)}
{error && (
<p className="text-center text-sm text-destructive">{error}</p>
)}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { useRouter, Link } from "@/i18n/routing";
import { useSearchParams } from "next/navigation";
@@ -14,6 +14,46 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { OtpInput } from "@/components/ui/otp-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
/** Client-side Persian-to-Latin slugifier — mirrors SlugHelper.Slugify on the backend */
const PERSIAN_MAP: Record<string, string> = {
آ: "a", ا: "a", أ: "a", إ: "a",
ب: "b", پ: "p", ت: "t", ث: "s",
ج: "j", چ: "ch", ح: "h", خ: "kh",
د: "d", ذ: "z", ر: "r", ز: "z", ژ: "zh",
س: "s", ش: "sh", ص: "s", ض: "z",
ط: "t", ظ: "z", ع: "a", غ: "gh",
ف: "f", ق: "gh", ک: "k", ك: "k", گ: "g",
ل: "l", م: "m", ن: "n", و: "v",
ه: "h", ی: "i", ي: "i",
ئ: "y", ء: "", ة: "t", ى: "a", ؤ: "o",
"۰": "0", "۱": "1", "۲": "2", "۳": "3", "۴": "4",
"۵": "5", "۶": "6", "۷": "7", "۸": "8", "۹": "9",
};
function slugify(input: string): string {
let s = "";
for (const ch of input) {
if (ch in PERSIAN_MAP) {
s += PERSIAN_MAP[ch];
} else if (/[a-zA-Z0-9]/.test(ch)) {
s += ch.toLowerCase();
} else if (/[\s\-_]/.test(ch)) {
s += "-";
}
}
return s.replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
}
function isValidSlug(slug: string): boolean {
if (!slug || slug.length < 2 || slug.length > 80) return false;
return /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slug);
}
const KOJA_BASE =
typeof window !== "undefined" && window.location.hostname.includes("meezi.ir")
? "koja.meezi.ir"
: "koja.meezi.ir";
function RegisterForm() {
const t = useTranslations("auth");
const router = useRouter();
@@ -22,11 +62,22 @@ function RegisterForm() {
const [phone, setPhone] = useState(searchParams.get("phone") ?? "");
const [cafeName, setCafeName] = useState("");
const [slug, setSlug] = useState("");
const [slugEdited, setSlugEdited] = useState(false);
const [code, setCode] = useState("");
const [step, setStep] = useState<"info" | "otp">("info");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Auto-derive slug from café name unless the user has manually edited it
useEffect(() => {
if (!slugEdited) {
setSlug(slugify(cafeName));
}
}, [cafeName, slugEdited]);
const slugValid = isValidSlug(slug);
const errorMessage = (err: unknown) => {
if (err instanceof ApiClientError) {
switch (err.code) {
@@ -45,7 +96,11 @@ function RegisterForm() {
setLoading(true);
setError(null);
try {
await apiPost("/api/auth/register", { phone, cafeName });
await apiPost("/api/auth/register", {
phone,
cafeName,
slug: slugValid ? slug : undefined,
});
setStep("otp");
} catch (e) {
setError(errorMessage(e));
@@ -94,6 +149,31 @@ function RegisterForm() {
required
/>
</LabeledField>
{/* Koja slug / profile URL */}
<LabeledField
label={t("kojaSlug")}
htmlFor="reg-slug"
hint={t("kojaSlugHint")}
>
<Input
id="reg-slug"
value={slug}
onChange={(e) => {
setSlugEdited(true);
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
}}
placeholder={t("kojaSlugPlaceholder")}
dir="ltr"
className="text-start font-mono text-sm"
/>
{slug && (
<p className={`mt-1 text-xs font-mono ${slugValid ? "text-muted-foreground" : "text-destructive"}`}>
{KOJA_BASE}/{slug}
</p>
)}
</LabeledField>
<LabeledField label={t("phone")} htmlFor="reg-phone">
<Input
id="reg-phone"
@@ -0,0 +1,107 @@
"use client";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { apiPost } from "@/lib/api/client";
import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface DemoSeedResult {
categoriesAdded: number;
itemsAdded: number;
tablesAdded: number;
ingredientsAdded: number;
taxCreated: boolean;
}
interface Props {
/** Which queries to invalidate after seeding. */
invalidateKeys: string[][];
className?: string;
}
export function DemoDataBanner({ invalidateKeys, className }: Props) {
const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role);
const qc = useQueryClient();
const [done, setDone] = useState(false);
const [summary, setSummary] = useState<DemoSeedResult | null>(null);
const seed = useMutation({
mutationFn: () =>
apiPost<DemoSeedResult>(`/api/cafes/${cafeId}/demo/seed`, {}),
onSuccess: (result) => {
setSummary(result);
setDone(true);
for (const key of invalidateKeys) {
qc.invalidateQueries({ queryKey: key });
}
},
});
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
if (done && summary) {
const nothingAdded =
summary.categoriesAdded === 0 &&
summary.itemsAdded === 0 &&
summary.tablesAdded === 0 &&
summary.ingredientsAdded === 0 &&
!summary.taxCreated;
return (
<div
className={cn(
"flex items-center gap-3 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE] px-4 py-3 text-sm text-[#0F6E56]",
className
)}
>
<Sparkles className="size-4 shrink-0" />
<span>
{nothingAdded ? (
"همه داده‌های نمونه از قبل موجود بودند — موردی اضافه نشد."
) : (
<>
دادههای نمونه اضافه شد {summary.categoriesAdded} دسته،{" "}
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
{summary.ingredientsAdded} ماده اولیه
{summary.taxCreated ? "، مالیات ۹٪" : ""}.
</>
)}
</span>
</div>
);
}
return (
<div
className={cn(
"flex flex-col gap-3 rounded-xl border border-dashed border-[#0F6E56]/40 bg-[#E1F5EE]/40 px-5 py-4 sm:flex-row sm:items-center sm:justify-between",
className
)}
>
<div className="flex items-center gap-3">
<Sparkles className="size-5 shrink-0 text-[#0F6E56]" />
<div>
<p className="text-sm font-semibold text-[#0F6E56]">شروع سریع با دادههای نمونه</p>
<p className="text-xs text-muted-foreground mt-0.5">
۷ دسته، ۵۹+ آیتم منو، ۱۰ میز، ۱۵ ماده اولیه و مالیات ۹٪ بهصورت خودکار اضافه میشود.
</p>
</div>
</div>
<Button
size="sm"
className="shrink-0 bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={seed.isPending}
onClick={() => seed.mutate()}
>
{seed.isPending ? (
<Loader2 className="me-1.5 size-4 animate-spin" />
) : (
<Sparkles className="me-1.5 size-4" />
)}
افزودن دادههای نمونه
</Button>
</div>
);
}
@@ -0,0 +1,168 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { apiPut, apiDelete, ApiClientError } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface Employee {
id: string;
name: string;
phone: string;
role: string;
}
interface Props {
cafeId: string;
employees: Employee[];
}
export function EmployeeCredentialsPanel({ cafeId, employees }: Props) {
const t = useTranslations("hr.credentials");
const [selectedId, setSelectedId] = useState<string>("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [feedback, setFeedback] = useState<{ ok: boolean; msg: string } | null>(null);
const setMutation = useMutation({
mutationFn: () =>
apiPut(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`, {
username,
password,
}),
onSuccess: () => {
setFeedback({ ok: true, msg: t("saved") });
setPassword("");
},
onError: (err) => {
if (err instanceof ApiClientError && err.code === "USERNAME_TAKEN") {
setFeedback({ ok: false, msg: t("usernameTaken") });
} else {
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
}
},
});
const removeMutation = useMutation({
mutationFn: () =>
apiDelete(`/api/cafes/${cafeId}/employees/${selectedId}/credentials`),
onSuccess: () => {
setFeedback({ ok: true, msg: t("removed") });
setUsername("");
setPassword("");
},
onError: (err) => {
setFeedback({ ok: false, msg: err instanceof Error ? err.message : String(err) });
},
});
const handleRemove = () => {
if (!window.confirm(t("removeConfirm"))) return;
setFeedback(null);
removeMutation.mutate();
};
const isPending = setMutation.isPending || removeMutation.isPending;
return (
<div className="space-y-4">
<div>
<p className="text-sm text-muted-foreground mb-3">{t("subtitle")}</p>
</div>
{/* Employee selector */}
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{employees.map((emp) => (
<button
key={emp.id}
type="button"
onClick={() => {
setSelectedId(emp.id);
setUsername("");
setPassword("");
setFeedback(null);
}}
className={`rounded-lg border p-3 text-start transition-colors cursor-pointer ${
selectedId === emp.id
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<p className="font-medium text-sm">{emp.name}</p>
<p className="text-xs text-muted-foreground" dir="ltr">{emp.phone}</p>
</button>
))}
</div>
{/* Form */}
{selectedId && (
<Card>
<CardHeader>
<CardTitle className="text-base">
{employees.find((e) => e.id === selectedId)?.name}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LabeledField label={t("username")} htmlFor="cred-username">
<Input
id="cred-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("usernamePlaceholder")}
dir="ltr"
className="text-start"
autoComplete="off"
/>
</LabeledField>
<LabeledField label={t("password")} htmlFor="cred-password">
<Input
id="cred-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("passwordPlaceholder")}
dir="ltr"
className="text-start"
autoComplete="new-password"
/>
</LabeledField>
{feedback && (
<p className={`text-sm ${feedback.ok ? "text-green-600" : "text-destructive"}`}>
{feedback.msg}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
onClick={() => {
setFeedback(null);
setMutation.mutate();
}}
disabled={isPending || !username.trim() || password.length < 8}
>
{isPending ? "..." : t("set")}
</Button>
<Button
variant="outline"
onClick={handleRemove}
disabled={isPending}
>
{t("remove")}
</Button>
</div>
</CardContent>
</Card>
)}
{!selectedId && (
<p className="text-sm text-muted-foreground">{t("selectEmployee")}</p>
)}
</div>
);
}
@@ -12,6 +12,7 @@ import { LabeledField } from "@/components/ui/labeled-field";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { BranchAccessPanel } from "@/components/hr/branch-access-panel";
import { EmployeeCredentialsPanel } from "@/components/hr/employee-credentials-panel";
interface Employee {
id: string;
@@ -47,7 +48,7 @@ interface Salary {
isPaid: boolean;
}
type Tab = "attendance" | "leave" | "payroll" | "access";
type Tab = "attendance" | "leave" | "payroll" | "access" | "credentials";
export function HrScreen() {
const t = useTranslations("hr");
@@ -122,8 +123,8 @@ export function HrScreen() {
<h2 className="text-xl font-bold">{t("title")}</h2>
<div className="flex flex-wrap gap-2">
{((["attendance", "leave", "payroll", "access"] as Tab[]).filter(
(key) => key !== "access" || canManageAccess
{((["attendance", "leave", "payroll", "access", "credentials"] as Tab[]).filter(
(key) => (key !== "access" && key !== "credentials") || canManageAccess
)).map((key) => (
<Button
key={key}
@@ -230,6 +231,10 @@ export function HrScreen() {
)}
{tab === "access" && canManageAccess && <BranchAccessPanel cafeId={cafeId} />}
{tab === "credentials" && canManageAccess && (
<EmployeeCredentialsPanel cafeId={cafeId} employees={employees} />
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations, useLocale } from "next-intl";
import { Link } from "@/i18n/routing";
import { Pencil } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
import { useAuthStore } from "@/lib/stores/auth.store";
@@ -366,7 +367,17 @@ export function InventoryScreen() {
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : ingredients.length === 0 ? (
<div className="space-y-3">
<DemoDataBanner
invalidateKeys={[
["inventory", cafeId!],
["menu-categories", cafeId!],
["menu-items-all", cafeId!],
["tables-board", cafeId!],
]}
/>
<p className="text-sm text-muted-foreground">{t("empty")}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{ingredients.map((ing) => {
@@ -45,7 +45,8 @@ function buildDefaultOpenGroups(): OpenGroupsState {
const stored = readStoredOpenGroups();
const defaults: OpenGroupsState = {};
for (const g of NAV_GROUPS) {
defaults[g.id] = stored[g.id] ?? g.defaultOpen;
// Default ALL groups closed on first visit; only restore if user explicitly saved state.
defaults[g.id] = stored[g.id] ?? false;
}
return defaults;
}
@@ -238,20 +239,31 @@ export function Sidebar({ side }: { side: "left" | "right" }) {
[role, branchId, permissions]
);
/** Accordion: opening a group collapses all others. */
const setGroupOpen = useCallback((groupId: NavGroupId, open: boolean) => {
setOpenGroups((prev) => {
const next = { ...prev, [groupId]: open };
setOpenGroups((_prev) => {
const next: OpenGroupsState = {};
for (const g of NAV_GROUPS) {
// If opening: only the clicked group becomes true; everything else closes.
// If closing: just close the clicked group, leave others as-is.
next[g.id] = open ? g.id === groupId : g.id === groupId ? false : (_prev[g.id] ?? false);
}
persistOpenGroups(next);
return next;
});
}, []);
// When navigating to a new path, open only the group that contains that path (accordion).
useEffect(() => {
const activeGroup = findNavGroupForPath(pathname);
if (!activeGroup) return;
setOpenGroups((prev) => {
if (prev[activeGroup]) return prev;
const next = { ...prev, [activeGroup]: true };
if (prev[activeGroup]) return prev; // already open, nothing to do
// Accordion: open active group, close all others
const next: OpenGroupsState = {};
for (const g of NAV_GROUPS) {
next[g.id] = g.id === activeGroup;
}
persistOpenGroups(next);
return next;
});
@@ -5,8 +5,8 @@ import { useLocale } from "next-intl";
import { useRouter } from "@/i18n/routing";
import { Clock, X, Zap } from "lucide-react";
// 14 Khordad 1405 = June 4, 2026 (Tehran UTC+3:30)
const DEADLINE = new Date("2026-06-04T00:00:00+03:30");
// 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30)
const DEADLINE = new Date("2026-06-22T00:00:00+04:30");
const STORAGE_KEY = "meezi_trial_banner_v1";
interface TimeLeft {
@@ -78,11 +78,11 @@ export function TrialCountdownBanner() {
const textFa = expired
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
: "دوره آزمایشی رایگان تا ۱۴ خرداد ۱۴۰۵";
: "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵";
const textEn = expired
? "Your Meezi trial has ended. Choose a plan to continue."
: "Free trial ends 14 Khordad 1405 (Jun 4)";
: "Free trial ends 1 Tir 1405 (Jun 22)";
const Digit = ({ value, label }: { value: number; label: string }) => (
<div className="flex flex-col items-center">
@@ -5,13 +5,15 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocale, useTranslations } from "next-intl";
import { useIsRtl } from "@/lib/use-is-rtl";
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
import { CategoryVisual } from "@/components/menu/category-visual";
import { CategoryMediaFields } from "@/components/menu/category-media-fields";
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify";
import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format";
@@ -182,6 +184,11 @@ function Modal({
export function MenuAdminScreen() {
const t = useTranslations("menuAdmin");
const tCommon = useTranslations("common");
const tNotify = useTranslations("notify");
const showError = (err: unknown) =>
notify.error(
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
);
const isRtl = useIsRtl();
const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
@@ -266,6 +273,7 @@ export function MenuAdminScreen() {
setItemModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const updateItemMutation = useMutation({
@@ -283,12 +291,14 @@ export function MenuAdminScreen() {
setItemModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const toggleItemMutation = useMutation({
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
onSuccess: invalidateMenu,
onError: showError,
});
const addCategoryMutation = useMutation({
@@ -306,6 +316,7 @@ export function MenuAdminScreen() {
setCatModalOpen(false);
invalidateMenu();
},
onError: showError,
});
const updateCategoryMutation = useMutation({
@@ -321,6 +332,7 @@ export function MenuAdminScreen() {
setCatModalOpen(false);
invalidateMenu();
},
onError: showError,
});
// ── Modal openers ──────────────────────────────────────────────────────────
@@ -449,6 +461,18 @@ export function MenuAdminScreen() {
)
) : (
/* ── Catalog tab ─────────────────────────────────────────────────── */
<div className="flex min-h-0 flex-col gap-4">
{categories.length < 5 && items.length < 10 && (
<DemoDataBanner
invalidateKeys={[
["menu-categories", cafeId],
["menu-items-all", cafeId],
["menu-items", cafeId],
["tables-board", cafeId],
["inventory", cafeId],
]}
/>
)}
<div className="flex min-h-0 gap-4">
{/* ── Category Sidebar (desktop) ─────────────────────────────── */}
@@ -753,6 +777,7 @@ export function MenuAdminScreen() {
)}
</div>
</div>
</div>
)}
{/* ── Item Add / Edit Modal ─────────────────────────────────────────── */}
@@ -15,6 +15,23 @@ import { Input } from "@/components/ui/input";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
// ── Location map preview ──────────────────────────────────────────────────────
function LocationMapPreview({ lat, lng }: { lat: number; lng: number }) {
const zoom = 15;
const src = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01},${lat - 0.01},${lng + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lng}`;
return (
<div className="relative w-full overflow-hidden rounded-lg border" style={{ height: 220 }}>
<iframe
src={src}
title="location preview"
className="h-full w-full border-0"
loading="lazy"
allowFullScreen
/>
</div>
);
}
type SettingsShopPanelProps = {
cafeId: string;
};
@@ -24,6 +41,8 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugError, setSlugError] = useState<string | null>(null);
const [city, setCity] = useState("");
const [phone, setPhone] = useState("");
const [address, setAddress] = useState("");
@@ -31,12 +50,24 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
const [logoUrl, setLogoUrl] = useState("");
const [coverImageUrl, setCoverImageUrl] = useState("");
const [snappfoodVendorId, setSnappfoodVendorId] = useState("");
const [latInput, setLatInput] = useState("");
const [lngInput, setLngInput] = useState("");
const [locationError, setLocationError] = useState<string | null>(null);
const { data: cafeSettings } = useCafeSettings(cafeId);
const parsedLat = parseFloat(latInput);
const parsedLng = parseFloat(lngInput);
const hasValidLocation =
!isNaN(parsedLat) &&
!isNaN(parsedLng) &&
parsedLat >= 24 && parsedLat <= 40 &&
parsedLng >= 44 && parsedLng <= 64;
useEffect(() => {
if (!cafeSettings) return;
setName(cafeSettings.name ?? "");
setSlug(cafeSettings.slug ?? "");
setCity(cafeSettings.city ?? "");
setPhone(cafeSettings.phone ?? "");
setAddress(cafeSettings.address ?? "");
@@ -44,12 +75,21 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
setLogoUrl(cafeSettings.logoUrl ?? "");
setCoverImageUrl(cafeSettings.coverImageUrl ?? "");
setSnappfoodVendorId(cafeSettings.snappfoodVendorId ?? "");
setLatInput(cafeSettings.latitude != null ? String(cafeSettings.latitude) : "");
setLngInput(cafeSettings.longitude != null ? String(cafeSettings.longitude) : "");
}, [cafeSettings]);
const saveProfile = useMutation({
mutationFn: () =>
apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, {
mutationFn: () => {
setSlugError(null);
const slugTrimmed = slug.trim();
const isValidSlug = !slugTrimmed || /^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(slugTrimmed);
if (slugTrimmed && !isValidSlug) {
throw new Error("INVALID_SLUG");
}
return apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, {
name,
slug: slugTrimmed || undefined,
city,
phone,
address,
@@ -57,11 +97,45 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
logoUrl: logoUrl || null,
coverImageUrl: coverImageUrl || null,
snappfoodVendorId,
}),
});
},
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success(t("profile.saved"));
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INVALID_SLUG") {
setSlugError(t("profile.slugInvalid"));
} else if (msg.includes("SLUG_TAKEN")) {
setSlugError(t("profile.slugTaken"));
}
},
});
const saveLocation = useMutation({
mutationFn: () => {
setLocationError(null);
if (!hasValidLocation && (latInput || lngInput)) {
throw new Error("INVALID_LOCATION");
}
const body = latInput && lngInput && hasValidLocation
? { latitude: parsedLat, longitude: parsedLng }
: { clearLocation: true };
return apiPatch<CafeSettings>(`/api/cafes/${cafeId}/settings`, body);
},
onSuccess: (data) => {
queryClient.setQueryData(cafeSettingsQueryKey(cafeId), data);
notify.success("موقعیت ذخیره شد");
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg === "INVALID_LOCATION" || msg.includes("INVALID_LOCATION")) {
setLocationError("مختصات نامعتبر است. مثال: عرض جغرافیایی ۳۵.۶۸۹، طول جغرافیایی ۵۱.۳۸۹");
} else {
notify.error("خطا در ذخیره موقعیت");
}
},
});
const uploadLogo = useMutation({
@@ -129,6 +203,33 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
</label>
</div>
</div>
{/* Koja slug */}
<LabeledField
label={t("profile.slug")}
htmlFor="cafe-slug"
hint={t("profile.slugHint")}
>
<Input
id="cafe-slug"
value={slug}
onChange={(e) => {
setSlugError(null);
setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
}}
placeholder={t("profile.slugPlaceholder")}
dir="ltr"
className="font-mono text-sm"
/>
{slug && (
<p className={`text-xs font-mono ${slugError ? "text-destructive" : "text-muted-foreground"}`}>
koja.meezi.ir/{slug}
</p>
)}
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</LabeledField>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("profile.name")} htmlFor="cafe-name">
<Input id="cafe-name" value={name} onChange={(e) => setName(e.target.value)} />
@@ -203,6 +304,80 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
</Button>
</CardContent>
</Card>
{/* Location card */}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">موقعیت روی نقشه</CardTitle>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6 pt-0">
<p className="text-sm leading-relaxed text-muted-foreground">
موقعیت دقیق کافه/رستوران خود را وارد کنید تا مشتریان بتوانند آن را پیدا کنند.
برای دریافت مختصات دقیق میتوانید از{" "}
<a
href={`https://neshan.org/maps/@${parsedLat || 35.6892},${parsedLng || 51.389},15z`}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
نقشه نشان
</a>{" "}
استفاده کنید.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label="عرض جغرافیایی (Latitude)" htmlFor="cafe-lat">
<Input
id="cafe-lat"
value={latInput}
onChange={(e) => { setLatInput(e.target.value); setLocationError(null); }}
placeholder="مثال: ۳۵.۶۸۹۲"
dir="ltr"
className="text-end"
inputMode="decimal"
/>
</LabeledField>
<LabeledField label="طول جغرافیایی (Longitude)" htmlFor="cafe-lng">
<Input
id="cafe-lng"
value={lngInput}
onChange={(e) => { setLngInput(e.target.value); setLocationError(null); }}
placeholder="مثال: ۵۱.۳۸۹"
dir="ltr"
className="text-end"
inputMode="decimal"
/>
</LabeledField>
</div>
{locationError && (
<p className="text-xs text-destructive">{locationError}</p>
)}
{hasValidLocation && (
<LocationMapPreview lat={parsedLat} lng={parsedLng} />
)}
<div className="flex flex-wrap gap-2">
<Button
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={saveLocation.isPending}
onClick={() => saveLocation.mutate()}
>
ذخیره موقعیت
</Button>
{(latInput || lngInput) && (
<Button
variant="ghost"
size="sm"
onClick={() => { setLatInput(""); setLngInput(""); setLocationError(null); }}
>
پاک کردن موقعیت
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}
@@ -46,6 +46,7 @@ export function CheckoutScreen() {
const [months, setMonths] = useState(1);
const [paymentMethod, setPaymentMethod] = useState("");
const [payError, setPayError] = useState<string | null>(null);
const numberLocale =
typeof document !== "undefined" && document.documentElement.lang === "en"
@@ -76,8 +77,13 @@ export function CheckoutScreen() {
mutationFn: (body: { planTier: string; months: number; paymentMethod: string }) =>
apiPost<SubscribeResponse>("/api/billing/subscribe", body),
onSuccess: (data) => {
setPayError(null);
window.location.href = data.paymentUrl;
},
onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
setPayError(msg || tc("paymentFailed"));
},
});
if (!cafeId) return null;
@@ -255,10 +261,15 @@ export function CheckoutScreen() {
{/* Pay action */}
<div className="flex flex-col gap-3 border-t border-border/80 bg-muted/20 px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-1">
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-4 w-4 text-[#0F6E56]" aria-hidden />
{tc("secureNote")}
</p>
{payError && (
<p className="text-xs text-destructive">{payError}</p>
)}
</div>
<Button
className="bg-[#0F6E56] hover:bg-[#0c5a46]"
disabled={subscribe.isPending}
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import * as signalR from "@microsoft/signalr";
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { notify } from "@/lib/notify";
import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { PageHeader } from "@/components/layout/page-header";
@@ -122,7 +123,9 @@ export function TablesScreen() {
refresh();
},
onError: (err) => {
setActionMessage(err instanceof ApiClientError ? err.message : t("createError"));
const msg = err instanceof ApiClientError ? err.message : t("createError");
setActionMessage(msg);
notify.error(msg);
},
});
@@ -184,6 +187,11 @@ export function TablesScreen() {
setActionMessage(null);
refresh();
},
onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError");
setActionMessage(msg);
notify.error(msg);
},
});
const startEdit = (table: TableBoardItem) => {
@@ -346,9 +354,19 @@ export function TablesScreen() {
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon("loading")}</p>
) : tables.length === 0 ? (
<div className="space-y-3">
<DemoDataBanner
invalidateKeys={[
["tables-board", cafeId],
["menu-categories", cafeId],
["menu-items-all", cafeId],
["inventory", cafeId],
]}
/>
<p className="text-sm text-muted-foreground">
{branchId ? t("emptyBranch") : t("empty")}
</p>
</div>
) : (
<>
{actionMessage ? (
+56 -3
View File
@@ -1,6 +1,10 @@
import axios, { type AxiosError } from "axios";
import type { ApiResponse } from "./types";
import axios, {
type AxiosError,
type InternalAxiosRequestConfig,
} from "axios";
import type { ApiResponse, AuthTokenResponse } from "./types";
import { getOrCreateTerminalId } from "@/lib/terminal";
import { useAuthStore } from "@/lib/stores/auth.store";
const baseURL =
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
@@ -21,14 +25,63 @@ api.interceptors.request.use((config) => {
return config;
});
/**
* Shared in-flight refresh promise so that a burst of concurrent 401s triggers
* exactly one POST /api/auth/refresh instead of one per failed request.
*/
let refreshPromise: Promise<string | null> | null = null;
async function refreshAccessToken(): Promise<string | null> {
if (typeof window === "undefined") return null;
const refreshToken = localStorage.getItem("meezi_refresh_token");
if (!refreshToken) return null;
try {
// Bare axios call (not `api`) to avoid recursing through this interceptor.
const { data } = await axios.post<ApiResponse<AuthTokenResponse>>(
`${baseURL}/api/auth/refresh`,
{ refreshToken },
{ headers: { "Content-Type": "application/json" } }
);
if (!data.success || !data.data) return null;
useAuthStore.getState().setAuth(data.data);
return data.data.accessToken;
} catch {
return null;
}
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError<ApiResponse<unknown>>) => {
const status = error.response?.status;
const original = error.config as
| (InternalAxiosRequestConfig & { _retry?: boolean })
| undefined;
// Expired access token → try a one-time refresh, then replay the request.
if (
status === 401 &&
original &&
!original._retry &&
typeof window !== "undefined" &&
!original.url?.includes("/api/auth/")
) {
original._retry = true;
refreshPromise ??= refreshAccessToken().finally(() => {
refreshPromise = null;
});
const newToken = await refreshPromise;
if (newToken) {
original.headers.Authorization = `Bearer ${newToken}`;
return api(original);
}
}
const apiError = error.response?.data?.error;
if (apiError?.code) {
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
}
if (error.response?.status === 401 && typeof window !== "undefined") {
if (status === 401 && typeof window !== "undefined") {
const path = window.location.pathname;
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
const isAdmin = path.includes("/admin");
@@ -18,6 +18,8 @@ export type CafeSettings = {
theme: CafeTheme;
defaultTaxRate?: number;
allowBranchTaxOverride?: boolean;
latitude?: number | null;
longitude?: number | null;
};
export function cafeSettingsQueryKey(cafeId: string) {
+1 -1
View File
@@ -117,7 +117,7 @@ export const NAV_GROUPS: NavGroupDef[] = [
},
];
export const NAV_GROUPS_STORAGE_KEY = "meezi:nav-groups:v3";
export const NAV_GROUPS_STORAGE_KEY = "meezi:nav-groups:v4";
/** Branch-scoped staff only see daily operations. */
export const BRANCH_ONLY_NAV_GROUP: NavGroupId = "operations";
+10
View File
@@ -50,6 +50,16 @@ const nextConfig: NextConfig = {
{ protocol: "http", hostname: "**" },
],
},
async redirects() {
return [
// Short URL: koja.meezi.ir/my-cafe → koja.meezi.ir/fa/cafe/my-cafe
{
source: "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])",
destination: "/fa/cafe/:slug",
permanent: false,
},
];
},
};
export default withPWA(withNextIntl(nextConfig));
+2
View File
@@ -7,6 +7,7 @@ import { Hero } from "@/components/sections/hero";
import { Stats } from "@/components/sections/stats";
import { TrustBar } from "@/components/sections/trust-bar";
import { Features } from "@/components/sections/features";
import { IranMapSection } from "@/components/sections/iran-map-section";
import { HowItWorks } from "@/components/sections/how-it-works";
import { AppPromo } from "@/components/sections/app-promo";
import { Testimonials } from "@/components/sections/testimonials";
@@ -41,6 +42,7 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
<Hero />
<TrustBar />
<Stats />
<IranMapSection />
<Features />
<HowItWorks />
<AppPromo />
@@ -0,0 +1,260 @@
/**
* IranMapSection server component
* Fetches real café locations from the API and overlays them as blinking dots
* on a stylised SVG silhouette of Iran.
*/
import { Suspense } from "react";
// ── Types ─────────────────────────────────────────────────────────────────────
type MapMarker = {
id: string;
name: string;
city: string | null;
latitude: number;
longitude: number;
};
type MarkersApiResponse = {
success: boolean;
data: MapMarker[];
};
// ── Coordinate transform ──────────────────────────────────────────────────────
// Iran bounding box (degrees) — fitted to the real border extent
// (lng 44.1163.32, lat 25.0839.71) with a small margin so the
// silhouette fills the viewBox. Markers reproject with the same box,
// so they stay aligned with the outline.
const MIN_LNG = 43.6;
const MAX_LNG = 63.8;
const MIN_LAT = 24.6;
const MAX_LAT = 40.2;
const SVG_W = 600;
const SVG_H = 500;
const toX = (lng: number) =>
((lng - MIN_LNG) / (MAX_LNG - MIN_LNG)) * SVG_W;
const toY = (lat: number) =>
((MAX_LAT - lat) / (MAX_LAT - MIN_LAT)) * SVG_H;
function toPt([lng, lat]: [number, number]) {
return `${toX(lng).toFixed(1)},${toY(lat).toFixed(1)}`;
}
// ── Iran silhouette ────────────────────────────────────────────────────────────
// Real national border, simplified to 74 vertices (source: Natural Earth via
// world.geo.json). Coordinates are [longitude, latitude]; the ring starts on
// the Caspian (NE) and runs clockwise. Projected through toX/toY below, the
// same transform used for the café markers, so dots land in the right place.
const IRAN_OUTLINE: [number, number][] = [
[53.92, 37.20], [54.80, 37.39], [55.51, 37.96], [56.18, 37.94], [56.62, 38.12], [57.33, 38.03],
[58.44, 37.52], [59.23, 37.41], [60.38, 36.53], [61.12, 36.49], [61.21, 35.65], [60.80, 34.40],
[60.53, 33.68], [60.96, 33.53], [60.54, 32.98], [60.86, 32.18], [60.94, 31.55], [61.70, 31.38],
[61.78, 30.74], [60.87, 29.83], [61.37, 29.30], [61.77, 28.70], [62.73, 28.26], [62.76, 27.38],
[63.23, 27.22], [63.32, 26.76], [61.87, 26.24], [61.50, 25.08], [59.62, 25.38], [58.53, 25.61],
[57.40, 25.74], [56.97, 26.97], [56.49, 27.14], [55.72, 26.96], [54.72, 26.48], [53.49, 26.81],
[52.48, 27.58], [51.52, 27.87], [50.85, 28.81], [50.12, 30.15], [49.58, 29.99], [48.94, 30.32],
[48.57, 29.93], [48.01, 30.45], [48.00, 30.99], [47.69, 30.98], [47.85, 31.71], [47.33, 32.47],
[46.11, 33.02], [45.42, 33.97], [45.65, 34.75], [46.15, 35.09], [46.08, 35.68], [45.42, 35.98],
[44.77, 37.17], [44.23, 37.97], [44.42, 38.28], [44.11, 39.43], [44.79, 39.71], [44.95, 39.34],
[45.46, 38.87], [46.14, 38.74], [46.51, 38.77], [47.69, 39.51], [48.06, 39.58], [48.36, 39.29],
[48.01, 38.79], [48.63, 38.27], [48.88, 38.32], [49.20, 37.58], [50.15, 37.37], [50.84, 36.87],
[52.26, 36.70], [53.83, 36.97],
];
const IRAN_PATH =
"M " +
IRAN_OUTLINE.map(toPt).join(" L ") +
" Z";
// A handful of major cities shown as faint reference dots
const MAJOR_CITIES: { name: string; lng: number; lat: number }[] = [
{ name: "تهران", lng: 51.389, lat: 35.689 },
{ name: "مشهد", lng: 59.608, lat: 36.297 },
{ name: "اصفهان", lng: 51.668, lat: 32.661 },
{ name: "شیراز", lng: 52.531, lat: 29.594 },
{ name: "تبریز", lng: 46.291, lat: 38.08 },
{ name: "اهواز", lng: 48.683, lat: 31.318 },
];
// ── Data fetcher ──────────────────────────────────────────────────────────────
async function fetchMarkers(): Promise<MapMarker[]> {
try {
const apiBase =
process.env.MEEZI_API_URL ?? "https://api.meezi.ir";
const res = await fetch(`${apiBase}/api/public/map-markers`, {
next: { revalidate: 3600 },
});
if (!res.ok) return [];
const json = (await res.json()) as MarkersApiResponse;
return json.data ?? [];
} catch {
return [];
}
}
// ── Sub-components ────────────────────────────────────────────────────────────
async function IranMapSvg() {
const markers = await fetchMarkers();
return (
<div className="relative mx-auto w-full max-w-lg select-none">
<svg
viewBox={`0 0 ${SVG_W} ${SVG_H}`}
aria-label="نقشه ایران با موقعیت کافه‌ها"
className="w-full drop-shadow-lg"
>
{/* Glow filter */}
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<radialGradient id="mapGrad" cx="50%" cy="50%" r="60%">
<stop offset="0%" stopColor="#e8f5f1" />
<stop offset="100%" stopColor="#d1ece5" />
</radialGradient>
</defs>
{/* Iran silhouette */}
<path
d={IRAN_PATH}
fill="url(#mapGrad)"
stroke="#0F6E56"
strokeWidth="2"
strokeLinejoin="round"
opacity="0.9"
/>
{/* Major city reference dots (faint) */}
{MAJOR_CITIES.map((city) => (
<g key={city.name}>
<circle
cx={toX(city.lng)}
cy={toY(city.lat)}
r={3}
fill="#0F6E56"
opacity={0.25}
/>
</g>
))}
{/* Café markers each glows slowly on and off like a small lamp.
Halo and core brighten/dim together (ease-in-out), staggered so the
map twinkles organically rather than pulsing in unison. */}
{markers.map((m, idx) => {
const cx = toX(m.longitude);
const cy = toY(m.latitude);
const delay = `${((idx * 0.7) % 3.6).toFixed(2)}s`;
const dur = "3.6s";
// ease-in-out for a smooth lamp-like fade
const ease = "0.4 0 0.6 1; 0.4 0 0.6 1";
return (
<g key={m.id} filter="url(#glow)">
{/* Soft halo */}
<circle cx={cx} cy={cy} r={9} fill="#0F6E56">
<animate
attributeName="opacity"
values="0.45;0.04;0.45"
keyTimes="0;0.5;1"
calcMode="spline"
keySplines={ease}
dur={dur}
begin={delay}
repeatCount="indefinite"
/>
</circle>
{/* Core dot — turns on (bright, slightly larger) and off (dim) */}
<circle cx={cx} cy={cy} r={4.5} fill="#0F6E56">
<animate
attributeName="opacity"
values="1;0.2;1"
keyTimes="0;0.5;1"
calcMode="spline"
keySplines={ease}
dur={dur}
begin={delay}
repeatCount="indefinite"
/>
<animate
attributeName="r"
values="4.5;5.6;4.5"
keyTimes="0;0.5;1"
calcMode="spline"
keySplines={ease}
dur={dur}
begin={delay}
repeatCount="indefinite"
/>
</circle>
</g>
);
})}
</svg>
{/* Floating legend */}
{markers.length > 0 && (
<div className="absolute bottom-3 start-3 flex items-center gap-2 rounded-full bg-white/90 px-3 py-1.5 text-xs shadow-md backdrop-blur-sm">
<span className="flex h-2 w-2 rounded-full bg-brand-600 ring-2 ring-brand-200" />
<span className="font-medium text-brand-700">{markers.length} کافه و رستوران</span>
</div>
)}
</div>
);
}
// ── Export ────────────────────────────────────────────────────────────────────
export function IranMapSection() {
return (
<section className="relative overflow-hidden bg-gradient-to-b from-white to-brand-50/40 py-20 sm:py-28">
{/* Subtle background pattern */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
backgroundImage:
"radial-gradient(circle, #0f6e56 1px, transparent 1px)",
backgroundSize: "32px 32px",
}}
/>
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
{/* Heading */}
<div className="mb-12 text-center">
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-brand-200 bg-brand-50 px-3 py-1.5 text-xs font-semibold text-brand-700">
<span className="flex h-1.5 w-1.5 rounded-full bg-brand-500" />
پراکنش جغرافیایی
</div>
<h2 className="text-3xl font-extrabold tracking-tight text-gray-900 sm:text-4xl">
میزی در سراسر ایران
</h2>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-gray-500">
از تهران تا مشهد، از تبریز تا شیراز کافهها و رستورانهای بیشتری هر روز به میزی میپیوندند.
</p>
</div>
{/* Map */}
<div className="flex justify-center">
<Suspense
fallback={
<div
className="w-full max-w-lg animate-pulse rounded-2xl bg-brand-50"
style={{ aspectRatio: "6/5" }}
/>
}
>
<IranMapSvg />
</Suspense>
</div>
</div>
</section>
);
}
@@ -5,9 +5,9 @@ import { X, Rocket } from "lucide-react";
import { useLocale } from "next-intl";
import { cn } from "@/lib/utils";
// 14 Khordad 1405 = June 4, 2026 (Tehran, UTC+3:30)
const LAUNCH_DATE = new Date("2026-06-04T00:00:00+03:30");
const DISMISS_KEY = "meezi_launch_banner_v2";
// 1 Tir 1405 = June 22, 2026 (Tehran, UTC+3:30)
const LAUNCH_DATE = new Date("2026-06-22T00:00:00+03:30");
const DISMISS_KEY = "meezi_launch_banner_v3";
interface TimeLeft {
days: number;
@@ -88,8 +88,8 @@ export function LaunchCountdownSection() {
</span>
<p className="max-w-2xl text-base font-medium text-gray-900 sm:text-lg">
{isFa
? "میزی رسماً ۱۴ خرداد ۱۴۰۵ برای همه کاربران راه‌اندازی می‌شود"
: "Meezi officially launches for all users on June 4, 2026"}
? "میزی رسماً ۱ تیر ۱۴۰۵ برای همه کاربران راه‌اندازی می‌شود"
: "Meezi officially launches for all users on June 22, 2026"}
</p>
</div>