Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 087563bce7 | |||
| e839db7331 | |||
| a83edf7667 | |||
| 75d5bbc84a | |||
| 7519f474f3 | |||
| 35494d8b32 | |||
| 4c7783884c | |||
| 8ce0b3e3e8 |
@@ -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)}
|
||||||
|
|||||||
@@ -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": "ميزي"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 } =
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user