Compare commits

..

4 Commits

Author SHA1 Message Date
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
9 changed files with 131 additions and 8 deletions
@@ -25,7 +25,9 @@ public class DemoSeedController : CafeApiControllerBase
CancellationToken ct)
{
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
if (EnsureOwner(tenant) is { } ownerDenied) return ownerDenied;
// Demo data is a setup helper; Owner or Manager may run it (matches the
// dashboard banner, which is shown to both roles).
if (EnsureManager(tenant) is { } managerDenied) return managerDenied;
var result = await _demoSeed.SeedAsync(cafeId, ct);
return Ok(new ApiResponse<DemoSeedResult>(true, result));
@@ -11,6 +11,28 @@ namespace Meezi.Infrastructure.Data;
/// <summary>Seeds 30 Persian showcase cafés for public discover (development only).</summary>
public static class DiscoverShowcaseSeeder
{
// Approximate city centres. Each café is scattered around its city with a
// small deterministic offset (derived from its id) so the marketing map
// shows a realistic cluster of blinking lights instead of one stacked dot.
private static readonly Dictionary<string, (double Lat, double Lng, double Spread)> CityGeo = new()
{
["تهران"] = (35.70, 51.39, 0.13),
["کرج"] = (35.83, 50.99, 0.07),
};
private static (double Lat, double Lng) GeoFor(string id, string city)
{
var (lat, lng, spread) = CityGeo.TryGetValue(city, out var g) ? g : (35.70, 51.39, 0.13);
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
private static readonly string[] ReviewAuthors = ["سارا", "علی", "مینا", "رضا", "نازنین"];
private static readonly string[] ReviewComments =
[
@@ -27,6 +49,7 @@ public static class DiscoverShowcaseSeeder
foreach (var spec in DiscoverShowcaseCatalog.Cafes)
{
var cafe = await db.Cafes.FirstOrDefaultAsync(c => c.Id == spec.Id);
var (geoLat, geoLng) = GeoFor(spec.Id, spec.City);
if (cafe is null)
{
cafe = new Cafe
@@ -37,6 +60,8 @@ public static class DiscoverShowcaseSeeder
Slug = spec.Slug,
City = spec.City,
Address = spec.Address,
Latitude = geoLat,
Longitude = geoLng,
Description = spec.Description,
PlanTier = spec.PlanTier,
PreferredLanguage = "fa",
@@ -100,6 +125,12 @@ public static class DiscoverShowcaseSeeder
cafe.IsVerified = true;
changed = true;
}
if (cafe.Latitude is null || cafe.Longitude is null)
{
cafe.Latitude = geoLat;
cafe.Longitude = geoLng;
changed = true;
}
if (changed)
await db.SaveChangesAsync();
}
@@ -29,6 +29,10 @@ public static class PlatformDataSeeder
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
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);
if (!env.IsDevelopment())
{
// Production: also ensure integration settings (Kavenegar enabled/template,
@@ -45,6 +49,79 @@ public static class PlatformDataSeeder
await EnsureIntegrationSettingsAsync(db, logger);
}
// Approximate centres for the major Iranian cities cafés sign up from.
private static readonly Dictionary<string, (double Lat, double Lng)> CityCentres = new(StringComparer.Ordinal)
{
["تهران"] = (35.70, 51.39),
["کرج"] = (35.84, 50.99),
["مشهد"] = (36.30, 59.61),
["اصفهان"] = (32.66, 51.67),
["شیراز"] = (29.59, 52.53),
["تبریز"] = (38.08, 46.29),
["قم"] = (34.64, 50.88),
["اهواز"] = (31.32, 48.67),
["کرمانشاه"] = (34.31, 47.07),
["رشت"] = (37.28, 49.58),
["ارومیه"] = (37.55, 45.07),
["همدان"] = (34.80, 48.52),
["یزد"] = (31.90, 54.37),
["اراک"] = (34.09, 49.69),
["کرمان"] = (30.28, 57.08),
["بندرعباس"] = (27.18, 56.27),
["قزوین"] = (36.28, 50.00),
["ساری"] = (36.57, 53.06),
["گرگان"] = (36.84, 54.44),
["زنجان"] = (36.68, 48.49),
["کیش"] = (26.56, 53.98),
};
/// <summary>
/// Gives cafés that have no map pin an approximate location at their city
/// centre (plus a small deterministic per-café offset so multiple cafés in
/// one city don't stack on a single point). Only fills rows where Latitude or
/// Longitude is null and the city is recognised; owners can drop an exact pin
/// later from Settings. Idempotent — never overwrites an existing pin.
/// </summary>
private static async Task BackfillCafeLocationsAsync(AppDbContext db, ILogger logger)
{
var cafes = await db.Cafes
.Where(c => c.DeletedAt == null
&& (c.Latitude == null || c.Longitude == null)
&& c.City != null)
.ToListAsync();
if (cafes.Count == 0) return;
var updated = 0;
foreach (var cafe in cafes)
{
var city = cafe.City!.Trim();
if (!CityCentres.TryGetValue(city, out var centre)) continue;
var (lat, lng) = ScatterAround(cafe.Id, centre.Lat, centre.Lng, 0.05);
cafe.Latitude = lat;
cafe.Longitude = lng;
updated++;
}
if (updated > 0)
{
await db.SaveChangesAsync();
logger.LogInformation(
"Cafe location backfill: set approximate coordinates for {Count} café(s) from city centre", updated);
}
}
private static (double Lat, double Lng) ScatterAround(string id, double lat, double lng, double spread)
{
unchecked
{
var h = 17;
foreach (var ch in id) h = (h * 31) + ch;
var ox = (((h & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
var oy = ((((h >> 16) & 0xFFFF) / 65535.0) - 0.5) * 2 * spread;
return (Math.Round(lat + oy, 5), Math.Round(lng + ox, 5));
}
}
/// <summary>
/// Ensures the platform owner's system-admin account exists in EVERY environment
/// (including production), so the admin panel is reachable on a fresh deploy.
@@ -3,7 +3,8 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { apiPost } from "@/lib/api/client";
import { ApiClientError, apiPost } from "@/lib/api/client";
import { notify } from "@/lib/notify";
import { useAuthStore } from "@/lib/stores/auth.store";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -39,6 +40,13 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
qc.invalidateQueries({ queryKey: key });
}
},
onError: (err) => {
notify.error(
err instanceof ApiClientError
? err.message
: "افزودن داده‌های نمونه ناموفق بود. دوباره تلاش کنید."
);
},
});
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
+5
View File
@@ -4,6 +4,11 @@ import { createNavigation } from "next-intl/navigation";
export const routing = defineRouting({
locales: ["fa", "ar", "en"],
defaultLocale: "fa",
// Iran-first: don't auto-pick the locale from the browser's Accept-Language
// (Persian users often have an en-US browser). A locale-less URL defaults to
// fa; the locale is otherwise taken from the URL prefix or the marketing-site
// link (e.g. app.meezi.ir/fa/login).
localeDetection: false,
});
export const { Link, redirect, usePathname, useRouter } =
@@ -41,7 +41,7 @@ const fa = {
desc: "از داشبورد میزی در دسترس است",
value: "چت زنده",
cta: "ورود به داشبورد",
href: "https://app.meezi.ir",
href: "https://app.meezi.ir/fa",
},
],
officeTitle: "دفتر مرکزی",
@@ -79,7 +79,7 @@ const en = {
desc: "Available inside the Meezi dashboard",
value: "Live chat",
cta: "Go to dashboard",
href: "https://app.meezi.ir",
href: "https://app.meezi.ir/en",
},
],
officeTitle: "Head Office",
+2 -2
View File
@@ -93,7 +93,7 @@ export function Navbar() {
{locale === "fa" ? "EN" : "فا"}
</button>
<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"
>
{t("login")}
@@ -143,7 +143,7 @@ export function Navbar() {
</ul>
<div className="mt-3 flex flex-col gap-2 border-t border-gray-100 pt-3">
<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"
>
{t("login")}
@@ -101,7 +101,7 @@ export function LaunchCountdownSection() {
</div>
<a
href="https://app.meezi.ir/register"
href={`https://app.meezi.ir/${locale}/register`}
className={cn(
"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"
@@ -34,7 +34,7 @@ export function PricingSection() {
priceNote: t("freePriceNote"),
desc: t("freeDesc"),
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")],
popular: false,
variant: "outline",