Compare commits

...

8 Commits

Author SHA1 Message Date
soroush.asadi 087563bce7 feat(settings): use-my-current-location button; surface ticket-load error
CI/CD / CI · Admin API (dotnet build) (push) Successful in 52s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m5s
CI/CD / CI · Admin Web (tsc) (push) Successful in 36s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / CI · API (dotnet build + test) (push) Successful in 41s
CI/CD / Deploy · all services (push) Failing after 2m34s
Location card gets a 'موقعیت فعلی من' button that fills lat/lng from the browser's geolocation. Support ticket list now shows the resolved (localized) error instead of a generic message, so a failure is diagnosable.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:00:14 +03:30
24 changed files with 319 additions and 43 deletions
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
CancellationToken ct) CancellationToken ct)
{ {
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied; // Demo data is a setup helper; Owner or Manager may run it (matches the
// dashboard banner, which is shown to both roles).
if (EnsureManager(tenant) is { } managerDenied) return managerDenied;
var result = await _demoSeed.SeedAsync(cafeId, ct); var result = await _demoSeed.SeedAsync(cafeId, ct);
return Ok(new ApiResponse<DemoSeedResult>(true, result)); return Ok(new ApiResponse<DemoSeedResult>(true, result));
@@ -11,6 +11,28 @@ namespace Meezi.Infrastructure.Data;
/// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary> /// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary>
public static class DiscoverShowcaseSeeder public static class DiscoverShowcaseSeeder
{ {
// Approximate city centres. Each café is scattered around its city with a
// small deterministic offset (derived from its id) so the marketing map
// shows a realistic cluster of blinking lights instead of one stacked dot.
private static readonly Dictionary<string, (double Lat, double Lng, double Spread)> CityGeo = new()
{
["تهران"] = (35.70, 51.39, 0.13),
["کرج"] = (35.83, 50.99, 0.07),
};
private static (double Lat, double Lng) GeoFor(string id, string city)
{
var (lat, lng, spread) = CityGeo.TryGetValue(city, out var g) ? g : (35.70, 51.39, 0.13);
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"]; private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"];
private static readonly string[] ReviewComments = private static readonly string[] ReviewComments =
[ [
@@ -27,6 +49,7 @@ public static class DiscoverShowcaseSeeder
foreach (var spec in DiscoverShowcaseCatalog.Cafes) foreach (var spec in DiscoverShowcaseCatalog.Cafes)
{ {
var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id); var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id);
var (geoLat, geoLng) = GeoFor(spec.Id, spec.City);
if (cafe is null) if (cafe is null)
{ {
cafe = new Cafe cafe = new Cafe
@@ -37,6 +60,8 @@ public static class DiscoverShowcaseSeeder
Slug = spec.Slug, Slug = spec.Slug,
City = spec.City, City = spec.City,
Address = spec.Address, Address = spec.Address,
Latitude = geoLat,
Longitude = geoLng,
Description = spec.Description, Description = spec.Description,
PlanTier = spec.PlanTier, PlanTier = spec.PlanTier,
PreferredLanguage = "fa", PreferredLanguage = "fa",
@@ -100,6 +125,12 @@ public static class DiscoverShowcaseSeeder
cafe.IsVerified = true; cafe.IsVerified = true;
changed = true; changed = true;
} }
if (cafe.Latitude is null || cafe.Longitude is null)
{
cafe.Latitude = geoLat;
cafe.Longitude = geoLng;
changed = true;
}
if (changed) if (changed)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
} }
@@ -29,6 +29,16 @@ public static class PlatformDataSeeder
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone". // fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger); await EnsureOwnerAdminAsync(db, config, logger);
// Production-safe: give cafés without a map pin an approximate location
// from their city, so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger);
// Subscription plans + feature flags are platform config the admin panel
// reads in every environment. Idempotent: adds any rows that are missing
// (so prod, which only had the Free plan, gets Pro/Business/Enterprise).
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
if (!env.IsDevelopment()) if (!env.IsDevelopment())
{ {
// Production: also ensure integration settings (Kavenegar enabled/template, // Production: also ensure integration settings (Kavenegar enabled/template,
@@ -39,12 +49,83 @@ public static class PlatformDataSeeder
await EnsureCatalogUpgradesAsync(db, logger); await EnsureCatalogUpgradesAsync(db, logger);
await SeedSystemAdminAsync(db, logger); await SeedSystemAdminAsync(db, logger);
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
await SeedSettingsAsync(db, logger); await SeedSettingsAsync(db, logger);
await EnsureIntegrationSettingsAsync(db, logger); await EnsureIntegrationSettingsAsync(db, logger);
} }
// Approximate centres for the major Iranian cities cafés sign up from.
private static readonly Dictionary<string, (double Lat, double Lng)> CityCentres = new(StringComparer.Ordinal)
{
["تهران"] = (35.70, 51.39),
["کرج"] = (35.84, 50.99),
["مشهد"] = (36.30, 59.61),
["اصفهان"] = (32.66, 51.67),
["شیراز"] = (29.59, 52.53),
["تبریز"] = (38.08, 46.29),
["قم"] = (34.64, 50.88),
["اهواز"] = (31.32, 48.67),
["کرمانشاه"] = (34.31, 47.07),
["رشت"] = (37.28, 49.58),
["ارومیه"] = (37.55, 45.07),
["همدان"] = (34.80, 48.52),
["یزد"] = (31.90, 54.37),
["اراک"] = (34.09, 49.69),
["کرمان"] = (30.28, 57.08),
["بندرعباس"] = (27.18, 56.27),
["قزوین"] = (36.28, 50.00),
["ساری"] = (36.57, 53.06),
["گرگان"] = (36.84, 54.44),
["زنجان"] = (36.68, 48.49),
["کیش"] = (26.56, 53.98),
};
/// <summary>
/// Gives cafés that have no map pin an approximate location at their city
/// centre (plus a small deterministic per-café offset so multiple cafés in
/// one city don't stack on a single point). Only fills rows where Latitude or
/// Longitude is null and the city is recognised; owners can drop an exact pin
/// later from Settings. Idempotent — never overwrites an existing pin.
/// </summary>
private static async Task BackfillCafeLocationsAsync(AppDbContext db, ILogger logger)
{
var cafes = await db.Cafes
.Where(c => c.DeletedAt == null
&& (c.Latitude == null || c.Longitude == null)
&& c.City != null)
.ToListAsync();
if (cafes.Count == 0) return;
var updated = 0;
foreach (var cafe in cafes)
{
var city = cafe.City!.Trim();
if (!CityCentres.TryGetValue(city, out var centre)) continue;
var (lat, lng) = ScatterAround(cafe.Id, centre.Lat, centre.Lng, 0.05);
cafe.Latitude = lat;
cafe.Longitude = lng;
updated++;
}
if (updated > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Cafe location backfill: set approximate coordinates for {Count} café(s) from city centre", updated);
}
}
private static (double Lat, double Lng) ScatterAround(string id, double lat, double lng, double spread)
{
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
/// <summary> /// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment /// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy. /// (including production), so the admin panel is reachable on a fresh deploy.
@@ -280,9 +361,6 @@ public static class PlatformDataSeeder
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger) private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformPlanDefinitions.AnyAsync())
return;
var plans = new[] var plans = new[]
{ {
new PlatformPlanDefinition new PlatformPlanDefinition
@@ -344,16 +422,18 @@ public static class PlatformDataSeeder
} }
}; };
db.PlatformPlanDefinitions.AddRange(plans); var existingIds = (await db.PlatformPlanDefinitions.Select(p => p.Id).ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = plans.Where(p => !existingIds.Contains(p.Id)).ToArray();
if (missing.Length == 0) return;
db.PlatformPlanDefinitions.AddRange(missing);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} subscription plans", plans.Length); logger.LogInformation("Platform seed: +{Count} subscription plans", missing.Length);
} }
private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger) private static async Task SeedFeaturesAsync(AppDbContext db, ILogger logger)
{ {
if (await db.PlatformFeatures.AnyAsync())
return;
var features = new[] var features = new[]
{ {
F("pos", "صندوق", "POS", "core"), F("pos", "صندوق", "POS", "core"),
@@ -379,9 +459,14 @@ public static class PlatformDataSeeder
F("discover_profile", "پروفایل کشف", "Discover profile", "growth") F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
}; };
db.PlatformFeatures.AddRange(features); var existingIds = (await db.PlatformFeatures.Select(f => f.Id).ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = features.Where(f => !existingIds.Contains(f.Id)).ToArray();
if (missing.Length == 0) return;
db.PlatformFeatures.AddRange(missing);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
logger.LogInformation("Platform seed: {Count} feature flags", features.Length); logger.LogInformation("Platform seed: +{Count} feature flags", missing.Length);
} }
private static PlatformFeature F(string key, string fa, string en, string group) => new() private static PlatformFeature F(string key, string fa, string en, string group) => new()
@@ -44,6 +44,7 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
<button <button
type="button" type="button"
role="switch" role="switch"
dir="ltr"
aria-checked={checked} aria-checked={checked}
disabled={disabled} disabled={disabled}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
+27
View File
@@ -20,6 +20,33 @@
"saved": "تم الحفظ", "saved": "تم الحفظ",
"errorGeneric": "حدث خطأ. حاول مرة أخرى." "errorGeneric": "حدث خطأ. حاول مرة أخرى."
}, },
"errors": {
"generic": "حدث خطأ. حاول مرة أخرى.",
"REQUEST_FAILED": "فشل الطلب. حاول مرة أخرى.",
"VALIDATION_ERROR": "البيانات المدخلة غير صالحة.",
"FORBIDDEN": "ليس لديك إذن للقيام بذلك.",
"OWNER_REQUIRED": "يمكن لمالك المقهى فقط القيام بذلك.",
"MANAGER_REQUIRED": "يتطلب هذا الإجراء صلاحية المدير.",
"PLAN_LIMIT_REACHED": "لقد بلغت حد باقتك. قم بالترقية للمتابعة.",
"PLAN_FEATURE_DISABLED": "هذه الميزة غير متاحة في باقتك الحالية.",
"NOT_FOUND": "غير موجود.",
"ORDER_NOT_FOUND": "الطلب غير موجود.",
"ITEM_NOT_FOUND": "العنصر غير موجود.",
"ITEM_ALREADY_VOIDED": "تم إلغاء هذا العنصر بالفعل.",
"ORDER_ALREADY_CLOSED": "هذا الطلب مغلق بالفعل.",
"TABLE_OCCUPIED": "هذه الطاولة مشغولة حاليًا.",
"TABLE_CLEANING": "هذه الطاولة قيد التنظيف.",
"TABLE_NOT_FOUND": "الطاولة غير موجودة.",
"TABLE_HAS_OPEN_ORDER": "هذه الطاولة لديها طلب مفتوح ولا يمكن حذفها.",
"TABLE_SECTION_HAS_TABLES": "يحتوي هذا القسم على طاولات ولا يمكن حذفه.",
"BRANCH_NOT_FOUND": "الفرع غير موجود.",
"SECTION_NOT_FOUND": "القسم غير موجود.",
"RATE_LIMITED": "طلبات كثيرة جدًا. يرجى الانتظار قليلاً.",
"SMS_FAILED": "تعذّر إرسال الرسالة القصيرة. حاول مرة أخرى.",
"INVALID_OTP": "رمز التحقق غير صالح أو منتهي الصلاحية.",
"TICKET_CLOSED": "هذه التذكرة مغلقة ولا يمكنها استقبال الرسائل.",
"ALREADY_REGISTERED": "يوجد حساب بالفعل لهذا الرقم. يرجى تسجيل الدخول."
},
"brand": { "brand": {
"name": "ميزي" "name": "ميزي"
}, },
+27
View File
@@ -20,6 +20,33 @@
"saved": "Saved", "saved": "Saved",
"errorGeneric": "Something went wrong. Please try again." "errorGeneric": "Something went wrong. Please try again."
}, },
"errors": {
"generic": "Something went wrong. Please try again.",
"REQUEST_FAILED": "Request failed. Please try again.",
"VALIDATION_ERROR": "The information entered is invalid.",
"FORBIDDEN": "You don't have permission to do this.",
"OWNER_REQUIRED": "Only the café owner can do this.",
"MANAGER_REQUIRED": "This action requires manager access.",
"PLAN_LIMIT_REACHED": "You've reached your plan limit. Upgrade to continue.",
"PLAN_FEATURE_DISABLED": "This feature isn't available on your current plan.",
"NOT_FOUND": "Not found.",
"ORDER_NOT_FOUND": "Order not found.",
"ITEM_NOT_FOUND": "Item not found.",
"ITEM_ALREADY_VOIDED": "This item is already voided.",
"ORDER_ALREADY_CLOSED": "This order is already closed.",
"TABLE_OCCUPIED": "This table is currently occupied.",
"TABLE_CLEANING": "This table is being cleaned.",
"TABLE_NOT_FOUND": "Table not found.",
"TABLE_HAS_OPEN_ORDER": "This table has an open order and can't be removed.",
"TABLE_SECTION_HAS_TABLES": "This section has tables and can't be removed.",
"BRANCH_NOT_FOUND": "Branch not found.",
"SECTION_NOT_FOUND": "Section not found.",
"RATE_LIMITED": "Too many requests. Please wait a moment.",
"SMS_FAILED": "Could not send the SMS. Please try again.",
"INVALID_OTP": "Invalid or expired verification code.",
"TICKET_CLOSED": "This ticket is closed and can't receive messages.",
"ALREADY_REGISTERED": "An account already exists for this number. Please sign in."
},
"brand": { "brand": {
"name": "Meezi" "name": "Meezi"
}, },
+27
View File
@@ -20,6 +20,33 @@
"saved": "ذخیره شد", "saved": "ذخیره شد",
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید." "errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
}, },
"errors": {
"generic": "خطایی رخ داد. دوباره تلاش کنید.",
"REQUEST_FAILED": "درخواست ناموفق بود. دوباره تلاش کنید.",
"VALIDATION_ERROR": "اطلاعات واردشده نامعتبر است.",
"FORBIDDEN": "شما اجازه این کار را ندارید.",
"OWNER_REQUIRED": "فقط مالک کافه می‌تواند این کار را انجام دهد.",
"MANAGER_REQUIRED": "این عملیات نیاز به دسترسی مدیر دارد.",
"PLAN_LIMIT_REACHED": "محدودیت پلن شما پر شده است. برای ادامه پلن را ارتقا دهید.",
"PLAN_FEATURE_DISABLED": "این قابلیت در پلن فعلی شما فعال نیست.",
"NOT_FOUND": "مورد موردنظر یافت نشد.",
"ORDER_NOT_FOUND": "سفارش یافت نشد.",
"ITEM_NOT_FOUND": "آیتم یافت نشد.",
"ITEM_ALREADY_VOIDED": "این آیتم قبلاً ابطال شده است.",
"ORDER_ALREADY_CLOSED": "این سفارش بسته شده است.",
"TABLE_OCCUPIED": "این میز هم‌اکنون مشغول است.",
"TABLE_CLEANING": "این میز در حال نظافت است.",
"TABLE_NOT_FOUND": "میز یافت نشد.",
"TABLE_HAS_OPEN_ORDER": "این میز سفارش باز دارد و قابل حذف نیست.",
"TABLE_SECTION_HAS_TABLES": "این بخش دارای میز است و قابل حذف نیست.",
"BRANCH_NOT_FOUND": "شعبه یافت نشد.",
"SECTION_NOT_FOUND": "بخش یافت نشد.",
"RATE_LIMITED": "تعداد درخواست بیش از حد مجاز است. کمی صبر کنید.",
"SMS_FAILED": "ارسال پیامک ناموفق بود. دوباره تلاش کنید.",
"INVALID_OTP": "کد تأیید نامعتبر یا منقضی شده است.",
"TICKET_CLOSED": "این تیکت بسته شده و امکان ارسال پیام ندارد.",
"ALREADY_REGISTERED": "برای این شماره قبلاً حساب ساخته شده است. وارد شوید."
},
"brand": { "brand": {
"name": "میزی" "name": "میزی"
}, },
@@ -4,6 +4,8 @@ import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react"; import { Sparkles, Loader2 } from "lucide-react";
import { apiPost } from "@/lib/api/client"; import { apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -26,6 +28,7 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const role = useAuthStore((s) => s.user?.role); const role = useAuthStore((s) => s.user?.role);
const qc = useQueryClient(); const qc = useQueryClient();
const apiError = useApiError();
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [summary, setSummary] = useState<DemoSeedResult | null>(null); const [summary, setSummary] = useState<DemoSeedResult | null>(null);
@@ -39,6 +42,9 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
qc.invalidateQueries({ queryKey: key }); qc.invalidateQueries({ queryKey: key });
} }
}, },
onError: (err) => {
notify.error(apiError(err));
},
}); });
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null; if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
@@ -12,8 +12,9 @@ import { CategoryVisual } from "@/components/menu/category-visual";
import { CategoryMediaFields } from "@/components/menu/category-media-fields"; import { CategoryMediaFields } from "@/components/menu/category-media-fields";
import type { CategoryIconSelection } from "@/components/menu/category-preset-picker"; import type { CategoryIconSelection } from "@/components/menu/category-preset-picker";
import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets"; import { DEFAULT_CATEGORY_ICON_STYLE } from "@/lib/category-icon-presets";
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client"; import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useBranchStore } from "@/lib/stores/branch.store"; import { useBranchStore } from "@/lib/stores/branch.store";
import { formatCurrency, formatNumber } from "@/lib/format"; import { formatCurrency, formatNumber } from "@/lib/format";
@@ -184,11 +185,8 @@ function Modal({
export function MenuAdminScreen() { export function MenuAdminScreen() {
const t = useTranslations("menuAdmin"); const t = useTranslations("menuAdmin");
const tCommon = useTranslations("common"); const tCommon = useTranslations("common");
const tNotify = useTranslations("notify"); const apiError = useApiError();
const showError = (err: unknown) => const showError = (err: unknown) => notify.error(apiError(err));
notify.error(
err instanceof ApiClientError ? err.message : tNotify("errorGeneric")
);
const isRtl = useIsRtl(); const isRtl = useIsRtl();
const locale = useLocale(); const locale = useLocale();
const numberLocale = locale === "en" ? "en-US" : "fa-IR"; const numberLocale = locale === "en" ? "en-US" : "fa-IR";
@@ -366,6 +366,26 @@ export function SettingsShopPanel({ cafeId }: SettingsShopPanelProps) {
> >
ذخیره موقعیت ذخیره موقعیت
</Button> </Button>
<Button
variant="outline"
onClick={() => {
if (typeof navigator === "undefined" || !navigator.geolocation) {
notify.error("مرورگر شما موقعیت‌یابی را پشتیبانی نمی‌کند");
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatInput(pos.coords.latitude.toFixed(5));
setLngInput(pos.coords.longitude.toFixed(5));
setLocationError(null);
},
() => notify.error("دسترسی به موقعیت امکان‌پذیر نبود. لطفاً اجازه دسترسی بدهید."),
{ enableHighAccuracy: true, timeout: 10000 }
);
}}
>
موقعیت فعلی من
</Button>
{(latInput || lngInput) && ( {(latInput || lngInput) && (
<Button <Button
variant="ghost" variant="ghost"
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useApiError } from "@/lib/use-api-error";
import { useRouter } from "@/i18n/routing"; import { useRouter } from "@/i18n/routing";
import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react"; import { ArrowRight, ArrowLeft, ShieldCheck } from "lucide-react";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
@@ -34,6 +35,7 @@ export function CheckoutScreen() {
const t = useTranslations("subscription"); const t = useTranslations("subscription");
const tc = useTranslations("subscription.checkout"); const tc = useTranslations("subscription.checkout");
const tPlans = useTranslations("settings.plans"); const tPlans = useTranslations("settings.plans");
const apiError = useApiError();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@@ -81,8 +83,7 @@ export function CheckoutScreen() {
window.location.href = data.paymentUrl; window.location.href = data.paymentUrl;
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const msg = err instanceof Error ? err.message : String(err); setPayError(apiError(err, tc("paymentFailed")));
setPayError(msg || tc("paymentFailed"));
}, },
}); });
@@ -6,6 +6,7 @@ import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing"; import { Link } from "@/i18n/routing";
import { apiGet, apiPost } from "@/lib/api/client"; import { apiGet, apiPost } from "@/lib/api/client";
import { useApiError } from "@/lib/use-api-error";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -52,6 +53,7 @@ function formatDate(iso: string) {
export function SupportScreen() { export function SupportScreen() {
const t = useTranslations("support"); const t = useTranslations("support");
const apiError = useApiError();
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [body, setBody] = useState(""); const [body, setBody] = useState("");
@@ -61,6 +63,7 @@ export function SupportScreen() {
data: tickets = [], data: tickets = [],
isLoading, isLoading,
isError, isError,
error,
refetch, refetch,
} = useQuery({ } = useQuery({
queryKey: ["support", cafeId], queryKey: ["support", cafeId],
@@ -135,7 +138,7 @@ export function SupportScreen() {
</p> </p>
{isError ? ( {isError ? (
<Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive"> <Card className="rounded-xl border border-destructive/30 p-4 text-sm text-destructive">
<p>{t("loadFailed")}</p> <p>{apiError(error, t("loadFailed"))}</p>
<Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}> <Button variant="outline" size="sm" className="mt-2" onClick={() => void refetch()}>
{t("retry")} {t("retry")}
</Button> </Button>
@@ -7,6 +7,7 @@ import * as signalR from "@microsoft/signalr";
import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react"; import { Plus, QrCode, Pencil, Video, Trash2, Copy, ExternalLink } from "lucide-react";
import { DemoDataBanner } from "@/components/demo/demo-data-banner"; import { DemoDataBanner } from "@/components/demo/demo-data-banner";
import { notify } from "@/lib/notify"; import { notify } from "@/lib/notify";
import { useApiError } from "@/lib/use-api-error";
import { MediaPairUpload } from "@/components/media/media-pair-upload"; import { MediaPairUpload } from "@/components/media/media-pair-upload";
import { PageHeader } from "@/components/layout/page-header"; import { PageHeader } from "@/components/layout/page-header";
import { import {
@@ -53,6 +54,7 @@ export function TablesScreen() {
const branchId = useBranchStore((s) => s.branchId); const branchId = useBranchStore((s) => s.branchId);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const confirmDialog = useConfirm(); const confirmDialog = useConfirm();
const apiError = useApiError();
const [actionMessage, setActionMessage] = useState<string | null>(null); const [actionMessage, setActionMessage] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [number, setNumber] = useState(""); const [number, setNumber] = useState("");
@@ -123,7 +125,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError"); const msg = apiError(err, t("createError"));
setActionMessage(msg); setActionMessage(msg);
notify.error(msg); notify.error(msg);
}, },
@@ -142,7 +144,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
setActionMessage(err instanceof ApiClientError ? err.message : t("cleaningError")); setActionMessage(apiError(err, t("cleaningError")));
}, },
}); });
@@ -158,7 +160,7 @@ export function TablesScreen() {
setActionMessage(t("tableHasOpenOrder")); setActionMessage(t("tableHasOpenOrder"));
return; return;
} }
setActionMessage(err instanceof ApiClientError ? err.message : t("deleteError")); setActionMessage(apiError(err, t("deleteError")));
}, },
}); });
@@ -188,7 +190,7 @@ export function TablesScreen() {
refresh(); refresh();
}, },
onError: (err) => { onError: (err) => {
const msg = err instanceof ApiClientError ? err.message : t("createError"); const msg = apiError(err, t("createError"));
setActionMessage(msg); setActionMessage(msg);
notify.error(msg); notify.error(msg);
}, },
+5
View File
@@ -4,6 +4,11 @@ import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["fa", "ar", "en"], locales: ["fa", "ar", "en"],
defaultLocale: "fa", defaultLocale: "fa",
// Iran-first: don't auto-pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). A locale-less URL defaults to
// fa; the locale is otherwise taken from the URL prefix or the marketing-site
// link (e.g. app.meezi.ir/fa/login).
localeDetection: false,
}); });
export const { Link, redirect, usePathname, useRouter } = export const { Link, redirect, usePathname, useRouter } =
+4 -1
View File
@@ -46,7 +46,10 @@ export const notify = {
}; };
export function getErrorMessage(err: unknown, fallback: string): string { export function getErrorMessage(err: unknown, fallback: string): string {
if (err instanceof ApiClientError) return err.message; // ApiClientError.message is the raw (usually English) backend message; prefer
// the caller's localized fallback. For code-specific localized text, use the
// useApiError() hook instead of this helper.
if (err instanceof ApiClientError) return fallback;
if (err instanceof Error && err.message) return err.message; if (err instanceof Error && err.message) return err.message;
return fallback; return fallback;
} }
+21
View File
@@ -0,0 +1,21 @@
import { useTranslations } from "next-intl";
import { ApiClientError } from "@/lib/api/client";
/**
* Returns a resolver that turns any caught error into a localized, user-facing
* message using the "errors" namespace. Known ApiClientError codes map to their
* translated message; otherwise the provided fallback is used, then a generic
* localized message. Never surfaces the raw (English) backend message.
*
* const apiError = useApiError();
* onError: (err) => notify.error(apiError(err))
*/
export function useApiError() {
const t = useTranslations("errors");
return (err: unknown, fallback?: string): string => {
if (err instanceof ApiClientError && err.code && t.has(err.code)) {
return t(err.code);
}
return fallback ?? t("generic");
};
}
+18 -5
View File
@@ -70,16 +70,29 @@ export default async function CafePage({
const t = await getTranslations({ locale, namespace: "cafe" }); const t = await getTranslations({ locale, namespace: "cafe" });
const isFa = locale === "fa"; const isFa = locale === "fa";
const [cafe, menu, reviews] = await Promise.all([ // Resolve the café first so an unknown slug 404s cleanly instead of doing
getCafe(slug), // (and potentially erroring on) the menu/review fetches.
const cafe = await getCafe(slug);
if (!cafe) notFound();
const [menu, reviews] = await Promise.all([
getCafeMenu(slug), getCafeMenu(slug),
getCafeReviews(slug), getCafeReviews(slug),
]); ]);
if (!cafe) notFound();
const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name); const name = isFa ? cafe.name : (cafe.nameEn ?? cafe.name);
const profile = cafe.discoverProfile; // discoverProfile may be absent for cafés that never filled it in — fall back
// to an empty profile so the page renders instead of throwing a 500.
const profile = cafe.discoverProfile ?? {
themes: [],
size: null,
floors: null,
vibes: [],
occasions: [],
spaceFeatures: [],
noiseLevel: null,
priceTier: null,
};
const priceTier = profile.priceTier; const priceTier = profile.priceTier;
// Similar cafes // Similar cafes
+4 -3
View File
@@ -11,7 +11,8 @@ interface Props {
export function CafeCard({ cafe, locale, href }: Props) { export function CafeCard({ cafe, locale, href }: Props) {
const isFa = locale === "fa"; const isFa = locale === "fa";
const name = isFa ? cafe.name : (cafe.name); const name = isFa ? cafe.name : (cafe.name);
const priceTier = cafe.discoverProfile.priceTier; const priceTier = cafe.discoverProfile?.priceTier ?? null;
const themes = cafe.discoverProfile?.themes ?? [];
const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null; const priceLabel = priceTier ? (PRICE_TIER_LABELS[priceTier]?.[isFa ? "fa" : "en"] ?? priceTier) : null;
return ( return (
@@ -72,9 +73,9 @@ export function CafeCard({ cafe, locale, href }: Props) {
)} )}
{/* Tags */} {/* Tags */}
{cafe.discoverProfile.themes.length > 0 && ( {themes.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{cafe.discoverProfile.themes.slice(0, 3).map((tag) => ( {themes.slice(0, 3).map((tag) => (
<span <span
key={tag} key={tag}
className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700" className="rounded-full bg-brand-50 px-2 py-0.5 text-[10px] font-medium text-brand-700"
+2 -2
View File
@@ -46,11 +46,11 @@ export function CafeJsonLd({ cafe, locale, baseUrl }: Props) {
worstRating: "1", worstRating: "1",
}, },
} : {}), } : {}),
...(cafe.discoverProfile.themes.length ? { ...(cafe.discoverProfile?.themes?.length ? {
servesCuisine: cafe.discoverProfile.themes, servesCuisine: cafe.discoverProfile.themes,
} : {}), } : {}),
priceRange: (() => { priceRange: (() => {
const tier = cafe.discoverProfile.priceTier; const tier = cafe.discoverProfile?.priceTier;
if (tier === "budget") return "﷼"; if (tier === "budget") return "﷼";
if (tier === "moderate") return "﷼﷼"; if (tier === "moderate") return "﷼﷼";
if (tier === "upscale") return "﷼﷼﷼"; if (tier === "upscale") return "﷼﷼﷼";
+3
View File
@@ -3,4 +3,7 @@ import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({ export const routing = defineRouting({
locales: ["fa", "en"], locales: ["fa", "en"],
defaultLocale: "fa", defaultLocale: "fa",
// Iran-first: don't pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). Locale-less URLs default to fa.
localeDetection: false,
}); });
@@ -41,7 +41,7 @@ const fa = {
desc: "از داشبورد میزی در دسترس است", desc: "از داشبورد میزی در دسترس است",
value: "چت زنده", value: "چت زنده",
cta: "ورود به داشبورد", cta: "ورود به داشبورد",
href: "https://app.meezi.ir", href: "https://app.meezi.ir/fa",
}, },
], ],
officeTitle: "دفتر مرکزی", officeTitle: "دفتر مرکزی",
@@ -79,7 +79,7 @@ const en = {
desc: "Available inside the Meezi dashboard", desc: "Available inside the Meezi dashboard",
value: "Live chat", value: "Live chat",
cta: "Go to dashboard", cta: "Go to dashboard",
href: "https://app.meezi.ir", href: "https://app.meezi.ir/en",
}, },
], ],
officeTitle: "Head Office", officeTitle: "Head Office",
+2 -2
View File
@@ -93,7 +93,7 @@ export function Navbar() {
{locale === "fa" ? "EN" : "فا"} {locale === "fa" ? "EN" : "فا"}
</button> </button>
<a <a
href="https://app.meezi.ir/login" href={`https://app.meezi.ir/${locale}/login`}
className="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900" className="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
> >
{t("login")} {t("login")}
@@ -143,7 +143,7 @@ export function Navbar() {
</ul> </ul>
<div className="mt-3 flex flex-col gap-2 border-t border-gray-100 pt-3"> <div className="mt-3 flex flex-col gap-2 border-t border-gray-100 pt-3">
<a <a
href="https://app.meezi.ir/login" href={`https://app.meezi.ir/${locale}/login`}
className="block rounded-lg px-3 py-2.5 text-center text-sm font-medium text-gray-600 hover:bg-gray-50" className="block rounded-lg px-3 py-2.5 text-center text-sm font-medium text-gray-600 hover:bg-gray-50"
> >
{t("login")} {t("login")}
@@ -101,7 +101,7 @@ export function LaunchCountdownSection() {
</div> </div>
<a <a
href="https://app.meezi.ir/register" href={`https://app.meezi.ir/${locale}/register`}
className={cn( className={cn(
"inline-flex items-center justify-center rounded-xl bg-brand-700 px-6 py-3 text-sm font-semibold text-white", "inline-flex items-center justify-center rounded-xl bg-brand-700 px-6 py-3 text-sm font-semibold text-white",
"transition hover:bg-brand-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-700 focus-visible:ring-offset-2" "transition hover:bg-brand-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-700 focus-visible:ring-offset-2"
@@ -34,7 +34,7 @@ export function PricingSection() {
priceNote: t("freePriceNote"), priceNote: t("freePriceNote"),
desc: t("freeDesc"), desc: t("freeDesc"),
cta: t("ctaFree"), cta: t("ctaFree"),
href: "https://app.meezi.ir/register", href: `https://app.meezi.ir/${locale}/register`,
features: [t("f1"), t("f2"), t("f3"), t("f4"), t("f5")], features: [t("f1"), t("f2"), t("f3"), t("f4"), t("f5")],
popular: false, popular: false,
variant: "outline", variant: "outline",