Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce0b3e3e8 | |||
| b5a6b1b68d | |||
| f813cc4854 | |||
| 024a455ab3 |
@@ -12,7 +12,7 @@ public class CreateMenuCategoryRequestValidator : AbstractValidator<CreateMenuCa
|
|||||||
public CreateMenuCategoryRequestValidator()
|
public CreateMenuCategoryRequestValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
|
||||||
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
RuleFor(x => x.SortOrder).GreaterThanOrEqualTo(0);
|
||||||
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
||||||
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
|
RuleFor(x => x.Icon).MaximumLength(32).When(x => x.Icon is not null);
|
||||||
@@ -39,7 +39,7 @@ public class CreateMenuItemRequestValidator : AbstractValidator<CreateMenuItemRe
|
|||||||
{
|
{
|
||||||
RuleFor(x => x.CategoryId).NotEmpty();
|
RuleFor(x => x.CategoryId).NotEmpty();
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
|
||||||
RuleFor(x => x.NameEn).NotEmpty().MaximumLength(200);
|
RuleFor(x => x.NameEn).MaximumLength(200).When(x => !string.IsNullOrWhiteSpace(x.NameEn));
|
||||||
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
|
RuleFor(x => x.Price).GreaterThanOrEqualTo(0);
|
||||||
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
RuleFor(x => x.DiscountPercent).InclusiveBetween(0, 100);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Discover;
|
using Meezi.Core.Discover;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
namespace Meezi.API.Tests;
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ public class DiscoverFilterTests
|
|||||||
noise: "quiet",
|
noise: "quiet",
|
||||||
priceTier: "mid",
|
priceTier: "mid",
|
||||||
size: null,
|
size: null,
|
||||||
requireProfile: true);
|
requireProfile: true,
|
||||||
|
openNow: false);
|
||||||
Assert.Equal("تهران", f.City);
|
Assert.Equal("تهران", f.City);
|
||||||
Assert.Equal(4, f.MinRating);
|
Assert.Equal(4, f.MinRating);
|
||||||
Assert.Contains("modern", f.Themes!);
|
Assert.Contains("modern", f.Themes!);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Meezi.API.Security;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
/// <summary>Test double that allows every action and has no captcha configured.</summary>
|
||||||
|
internal sealed class NoOpAbuseProtectionService : IAbuseProtectionService
|
||||||
|
{
|
||||||
|
public bool IsCaptchaConfigured => false;
|
||||||
|
public string? CaptchaSiteKey => null;
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckAuthOtpByIpAsync(
|
||||||
|
string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckGuestOrderAsync(
|
||||||
|
string cafeId, string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Allowed, string? ErrorCode, string? Message)> CheckPublicWriteByIpAsync(
|
||||||
|
string clientIp, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
|
||||||
|
public Task<(bool Ok, string? ErrorCode, string? Message)> VerifyCaptchaAsync(
|
||||||
|
string? captchaToken, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<(bool, string?, string?)>((true, null, null));
|
||||||
|
}
|
||||||
@@ -16,9 +16,17 @@ internal sealed class NoOpInventoryService : IInventoryService
|
|||||||
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
public Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IngredientDto?>(null);
|
Task.FromResult<IngredientDto?>(null);
|
||||||
|
|
||||||
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, CancellationToken ct = default) =>
|
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, CancellationToken ct = default) =>
|
||||||
Task.FromResult<IngredientDto?>(null);
|
Task.FromResult<IngredientDto?>(null);
|
||||||
|
|
||||||
|
public Task<InventoryPurchasesSummaryDto> GetPurchasesSummaryAsync(
|
||||||
|
string cafeId,
|
||||||
|
string branchId,
|
||||||
|
DateOnly from,
|
||||||
|
DateOnly to,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(new InventoryPurchasesSummaryDto(0, 0, []));
|
||||||
|
|
||||||
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
|
public Task<MenuItemRecipeDto?> GetRecipeAsync(string cafeId, string menuItemId, CancellationToken ct = default) =>
|
||||||
Task.FromResult<MenuItemRecipeDto?>(null);
|
Task.FromResult<MenuItemRecipeDto?>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
|
||||||
namespace Meezi.API.Tests;
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
@@ -10,4 +11,11 @@ internal sealed class NoOpLoyaltyService : ILoyaltyService
|
|||||||
decimal paidAmount,
|
decimal paidAmount,
|
||||||
CancellationToken ct = default) =>
|
CancellationToken ct = default) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task<(bool Success, LoyaltyRedeemResult? Data, string? ErrorCode)> RedeemOnOrderAsync(
|
||||||
|
string cafeId,
|
||||||
|
Order order,
|
||||||
|
int pointsRequested,
|
||||||
|
CancellationToken ct = default) =>
|
||||||
|
Task.FromResult<(bool, LoyaltyRedeemResult?, string?)>((false, null, null));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Meezi.API.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
/// <summary>Test double that stores nothing and returns no URL.</summary>
|
||||||
|
internal sealed class NoOpMediaStorageService : IMediaStorageService
|
||||||
|
{
|
||||||
|
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) =>
|
||||||
|
Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -11,4 +11,7 @@ internal sealed class NoOpOrderNotificationService : IOrderNotificationService
|
|||||||
|
|
||||||
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
|
public Task NotifyOrderStatusChangedAsync(Order order, CancellationToken ct = default) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task NotifyCallWaiterAsync(string cafeId, string tableId, string tableNumber, CancellationToken ct = default) =>
|
||||||
|
Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class PrintingTests
|
|||||||
218_000m,
|
218_000m,
|
||||||
0m,
|
0m,
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
|
1,
|
||||||
[
|
[
|
||||||
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
|
new OrderItemDto("i1", "m1", "Espresso", 2, 100_000m, null),
|
||||||
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
|
new OrderItemDto("i2", "m2", "Void Latte", 1, 50_000m, null, true)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using Meezi.API.Models.Menu;
|
using Meezi.API.Models.Menu;
|
||||||
using Meezi.API.Models.Orders;
|
using Meezi.API.Models.Orders;
|
||||||
using Meezi.API.Models.Public;
|
using Meezi.API.Models.Public;
|
||||||
|
using Meezi.API.Security;
|
||||||
using Meezi.API.Services;
|
using Meezi.API.Services;
|
||||||
using Meezi.Core.Entities;
|
using Meezi.Core.Entities;
|
||||||
using Meezi.Core.Enums;
|
using Meezi.Core.Enums;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
using Meezi.Infrastructure.Data;
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -114,7 +116,11 @@ public class QrMenuTests
|
|||||||
var tables = new TableService(db, config, kds, identity);
|
var tables = new TableService(db, config, kds, identity);
|
||||||
var shifts = new ShiftService(db);
|
var shifts = new ShiftService(db);
|
||||||
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
var orders = new OrderService(db, kds, new NoOpSnappfood(), new NoOpDeliverySync(), shifts, TestServiceScopeFactory.Create(), new NoOpOrderNotificationService(), new NoOpInventoryService(), new NoOpLoyaltyService());
|
||||||
var publicSvc = new PublicService(db, orders, new ReviewService(db), kds, branchMenu, identity);
|
var abuse = new NoOpAbuseProtectionService();
|
||||||
|
var http = new HttpContextAccessor();
|
||||||
|
var media = new NoOpMediaStorageService();
|
||||||
|
var reviews = new ReviewService(db, abuse, http, media);
|
||||||
|
var publicSvc = new PublicService(db, orders, reviews, kds, branchMenu, identity, abuse, http);
|
||||||
|
|
||||||
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
return (db, tables, publicSvc, cafeId, branchId, tableId, itemA, itemB, qrCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1057,6 +1057,7 @@
|
|||||||
"fieldCategoryEn": "الفئة بالإنجليزية",
|
"fieldCategoryEn": "الفئة بالإنجليزية",
|
||||||
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
"fieldContentFa": "المحتوى بالفارسية (Markdown)",
|
||||||
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
"fieldContentEn": "المحتوى بالإنجليزية (Markdown)",
|
||||||
|
"fieldPublished": "منشور",
|
||||||
"commentsTitle": "إدارة التعليقات",
|
"commentsTitle": "إدارة التعليقات",
|
||||||
"noComments": "لا توجد تعليقات",
|
"noComments": "لا توجد تعليقات",
|
||||||
"approved": "موافق عليه",
|
"approved": "موافق عليه",
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@
|
|||||||
"fieldCategoryEn": "Category (English)",
|
"fieldCategoryEn": "Category (English)",
|
||||||
"fieldContentFa": "Content (Persian, Markdown)",
|
"fieldContentFa": "Content (Persian, Markdown)",
|
||||||
"fieldContentEn": "Content (English, Markdown)",
|
"fieldContentEn": "Content (English, Markdown)",
|
||||||
|
"fieldPublished": "Published",
|
||||||
"commentsTitle": "Comment management",
|
"commentsTitle": "Comment management",
|
||||||
"noComments": "No comments found",
|
"noComments": "No comments found",
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
|
|||||||
@@ -1058,6 +1058,7 @@
|
|||||||
"fieldCategoryEn": "دستهبندی انگلیسی",
|
"fieldCategoryEn": "دستهبندی انگلیسی",
|
||||||
"fieldContentFa": "محتوا فارسی (Markdown)",
|
"fieldContentFa": "محتوا فارسی (Markdown)",
|
||||||
"fieldContentEn": "محتوا انگلیسی (Markdown)",
|
"fieldContentEn": "محتوا انگلیسی (Markdown)",
|
||||||
|
"fieldPublished": "وضعیت انتشار",
|
||||||
"commentsTitle": "مدیریت نظرات",
|
"commentsTitle": "مدیریت نظرات",
|
||||||
"noComments": "نظری یافت نشد",
|
"noComments": "نظری یافت نشد",
|
||||||
"approved": "تأیید شده",
|
"approved": "تأیید شده",
|
||||||
|
|||||||
@@ -62,6 +62,33 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Styled single-select indicator (replaces raw <input type="radio">).
|
||||||
|
function RadioDot({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
selected ? "border-[#0F6E56]" : "border-muted-foreground/40 hover:border-muted-foreground/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected ? <span className="h-2.5 w-2.5 rounded-full bg-[#0F6E56]" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminDashboardScreen() {
|
export function AdminDashboardScreen() {
|
||||||
const t = useTranslations("admin.dashboard");
|
const t = useTranslations("admin.dashboard");
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
@@ -632,11 +659,9 @@ export function AdminIntegrationsScreen() {
|
|||||||
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
|
<Card key={g.id} className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<RadioDot
|
||||||
type="radio"
|
selected={activeGateway === g.id}
|
||||||
name="activeGateway"
|
onSelect={() => setActiveGateway(g.id)}
|
||||||
checked={activeGateway === g.id}
|
|
||||||
onChange={() => setActiveGateway(g.id)}
|
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{g.displayNameFa}</span>
|
<span className="font-medium">{g.displayNameFa}</span>
|
||||||
{activeGateway === g.id ? (
|
{activeGateway === g.id ? (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -149,6 +150,74 @@ export function AdminBlogListScreen() {
|
|||||||
|
|
||||||
// ── Blog Post Editor ─────────────────────────────────────────────────────────
|
// ── Blog Post Editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// iOS-style toggle (mirrors the one in admin-screens.tsx).
|
||||||
|
function BlogToggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out",
|
||||||
|
checked ? "translate-x-5" : "translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level so it keeps a stable component identity across renders.
|
||||||
|
// (Previously defined inside the editor, which remounted every input on each
|
||||||
|
// keystroke and dropped focus after a single character.)
|
||||||
|
function BlogField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
multiline,
|
||||||
|
dir,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
multiline?: boolean;
|
||||||
|
dir: "rtl" | "ltr";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
||||||
|
{multiline ? (
|
||||||
|
<textarea
|
||||||
|
rows={8}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
dir={dir}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
dir={dir}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface PostEditorProps {
|
interface PostEditorProps {
|
||||||
postId?: string; // undefined = new post
|
postId?: string; // undefined = new post
|
||||||
}
|
}
|
||||||
@@ -176,6 +245,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
author: "تیم میزی",
|
author: "تیم میزی",
|
||||||
categoryFa: "",
|
categoryFa: "",
|
||||||
categoryEn: "",
|
categoryEn: "",
|
||||||
|
isPublished: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
@@ -195,6 +265,7 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
author: post.author,
|
author: post.author,
|
||||||
categoryFa: post.categoryFa,
|
categoryFa: post.categoryFa,
|
||||||
categoryEn: post.categoryEn,
|
categoryEn: post.categoryEn,
|
||||||
|
isPublished: post.isPublished,
|
||||||
});
|
});
|
||||||
setFormReady(true);
|
setFormReady(true);
|
||||||
}
|
}
|
||||||
@@ -219,39 +290,6 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
onError: () => notify.error(t("errorGeneric")),
|
onError: () => notify.error(t("errorGeneric")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const Field = ({
|
|
||||||
label,
|
|
||||||
fieldKey,
|
|
||||||
multiline,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
fieldKey: keyof typeof form;
|
|
||||||
multiline?: boolean;
|
|
||||||
}) => {
|
|
||||||
const isFa = label.toLowerCase().includes("fa") || label.includes("فارسی") || label.includes("فا");
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
|
||||||
{multiline ? (
|
|
||||||
<textarea
|
|
||||||
rows={8}
|
|
||||||
value={form[fieldKey]}
|
|
||||||
onChange={(e) => setField(fieldKey)(e.target.value)}
|
|
||||||
className="w-full resize-y rounded-lg border border-border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-primary/30"
|
|
||||||
dir={isFa ? "rtl" : "ltr"}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
value={form[fieldKey]}
|
|
||||||
onChange={(e) => setField(fieldKey)(e.target.value)}
|
|
||||||
className="h-9 text-sm"
|
|
||||||
dir={isFa ? "rtl" : "ltr"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isNew && !formReady) {
|
if (!isNew && !formReady) {
|
||||||
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||||
}
|
}
|
||||||
@@ -273,23 +311,31 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
|
|||||||
<Card className="rounded-xl border-border/80">
|
<Card className="rounded-xl border-border/80">
|
||||||
<CardContent className="space-y-4 pt-5">
|
<CardContent className="space-y-4 pt-5">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field label={t("fieldSlug")} fieldKey="slug" />
|
<BlogField label={t("fieldSlug")} value={form.slug} onChange={setField("slug")} dir="ltr" />
|
||||||
<Field label={t("fieldAuthor")} fieldKey="author" />
|
<BlogField label={t("fieldAuthor")} value={form.author} onChange={setField("author")} dir="rtl" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field label={t("fieldTitleFa")} fieldKey="titleFa" />
|
<BlogField label={t("fieldTitleFa")} value={form.titleFa} onChange={setField("titleFa")} dir="rtl" />
|
||||||
<Field label={t("fieldTitleEn")} fieldKey="titleEn" />
|
<BlogField label={t("fieldTitleEn")} value={form.titleEn} onChange={setField("titleEn")} dir="ltr" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field label={t("fieldExcerptFa")} fieldKey="excerptFa" />
|
<BlogField label={t("fieldExcerptFa")} value={form.excerptFa} onChange={setField("excerptFa")} dir="rtl" />
|
||||||
<Field label={t("fieldExcerptEn")} fieldKey="excerptEn" />
|
<BlogField label={t("fieldExcerptEn")} value={form.excerptEn} onChange={setField("excerptEn")} dir="ltr" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<Field label={t("fieldCategoryFa")} fieldKey="categoryFa" />
|
<BlogField label={t("fieldCategoryFa")} value={form.categoryFa} onChange={setField("categoryFa")} dir="rtl" />
|
||||||
<Field label={t("fieldCategoryEn")} fieldKey="categoryEn" />
|
<BlogField label={t("fieldCategoryEn")} value={form.categoryEn} onChange={setField("categoryEn")} dir="ltr" />
|
||||||
|
</div>
|
||||||
|
<BlogField label={t("fieldContentFa")} value={form.contentFa} onChange={setField("contentFa")} dir="rtl" multiline />
|
||||||
|
<BlogField label={t("fieldContentEn")} value={form.contentEn} onChange={setField("contentEn")} dir="ltr" multiline />
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border/80 px-3 py-2">
|
||||||
|
<span className="text-sm font-medium">{t("fieldPublished")}</span>
|
||||||
|
<BlogToggle
|
||||||
|
checked={form.isPublished}
|
||||||
|
onChange={(v) => setForm((f) => ({ ...f, isPublished: v }))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Field label={t("fieldContentFa")} fieldKey="contentFa" multiline />
|
|
||||||
<Field label={t("fieldContentEn")} fieldKey="contentEn" multiline />
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2">
|
<div className="flex justify-end pt-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
|||||||
|
|
||||||
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
|
if (!cafeId || (role !== "Owner" && role !== "Manager")) return null;
|
||||||
if (done && summary) {
|
if (done && summary) {
|
||||||
|
const nothingAdded =
|
||||||
|
summary.categoriesAdded === 0 &&
|
||||||
|
summary.itemsAdded === 0 &&
|
||||||
|
summary.tablesAdded === 0 &&
|
||||||
|
summary.ingredientsAdded === 0 &&
|
||||||
|
!summary.taxCreated;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -52,10 +58,16 @@ export function DemoDataBanner({ invalidateKeys, className }: Props) {
|
|||||||
>
|
>
|
||||||
<Sparkles className="size-4 shrink-0" />
|
<Sparkles className="size-4 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
دادههای نمونه اضافه شد — {summary.categoriesAdded} دسته،{" "}
|
{nothingAdded ? (
|
||||||
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
|
"همه دادههای نمونه از قبل موجود بودند — موردی اضافه نشد."
|
||||||
{summary.ingredientsAdded} ماده اولیه
|
) : (
|
||||||
{summary.taxCreated ? "، مالیات ۹٪" : ""}.
|
<>
|
||||||
|
دادههای نمونه اضافه شد — {summary.categoriesAdded} دسته،{" "}
|
||||||
|
{summary.itemsAdded} آیتم، {summary.tablesAdded} میز،{" "}
|
||||||
|
{summary.ingredientsAdded} ماده اولیه
|
||||||
|
{summary.taxCreated ? "، مالیات ۹٪" : ""}.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ 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 { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
import { ApiClientError, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||||
|
import { notify } from "@/lib/notify";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { 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";
|
||||||
@@ -183,6 +184,11 @@ 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 showError = (err: unknown) =>
|
||||||
|
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";
|
||||||
@@ -267,6 +273,7 @@ export function MenuAdminScreen() {
|
|||||||
setItemModalOpen(false);
|
setItemModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateItemMutation = useMutation({
|
const updateItemMutation = useMutation({
|
||||||
@@ -284,12 +291,14 @@ export function MenuAdminScreen() {
|
|||||||
setItemModalOpen(false);
|
setItemModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleItemMutation = useMutation({
|
const toggleItemMutation = useMutation({
|
||||||
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
|
mutationFn: ({ id, isAvailable }: { id: string; isAvailable: boolean }) =>
|
||||||
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
|
apiPatch(`/api/cafes/${cafeId}/menu/items/${id}/availability`, { isAvailable }),
|
||||||
onSuccess: invalidateMenu,
|
onSuccess: invalidateMenu,
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addCategoryMutation = useMutation({
|
const addCategoryMutation = useMutation({
|
||||||
@@ -307,6 +316,7 @@ export function MenuAdminScreen() {
|
|||||||
setCatModalOpen(false);
|
setCatModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCategoryMutation = useMutation({
|
const updateCategoryMutation = useMutation({
|
||||||
@@ -322,6 +332,7 @@ export function MenuAdminScreen() {
|
|||||||
setCatModalOpen(false);
|
setCatModalOpen(false);
|
||||||
invalidateMenu();
|
invalidateMenu();
|
||||||
},
|
},
|
||||||
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Modal openers ──────────────────────────────────────────────────────────
|
// ── Modal openers ──────────────────────────────────────────────────────────
|
||||||
@@ -451,7 +462,7 @@ export function MenuAdminScreen() {
|
|||||||
) : (
|
) : (
|
||||||
/* ── Catalog tab ─────────────────────────────────────────────────── */
|
/* ── Catalog tab ─────────────────────────────────────────────────── */
|
||||||
<div className="flex min-h-0 flex-col gap-4">
|
<div className="flex min-h-0 flex-col gap-4">
|
||||||
{categories.length === 0 && items.length === 0 && (
|
{categories.length < 5 && items.length < 10 && (
|
||||||
<DemoDataBanner
|
<DemoDataBanner
|
||||||
invalidateKeys={[
|
invalidateKeys={[
|
||||||
["menu-categories", cafeId],
|
["menu-categories", cafeId],
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ export function TablesScreen() {
|
|||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setActionMessage(err instanceof ApiClientError ? err.message : t("createError"));
|
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
||||||
|
setActionMessage(msg);
|
||||||
|
notify.error(msg);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +187,11 @@ export function TablesScreen() {
|
|||||||
setActionMessage(null);
|
setActionMessage(null);
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const msg = err instanceof ApiClientError ? err.message : t("createError");
|
||||||
|
setActionMessage(msg);
|
||||||
|
notify.error(msg);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const startEdit = (table: TableBoardItem) => {
|
const startEdit = (table: TableBoardItem) => {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import axios, { type AxiosError } from "axios";
|
import axios, {
|
||||||
import type { ApiResponse } from "./types";
|
type AxiosError,
|
||||||
|
type InternalAxiosRequestConfig,
|
||||||
|
} from "axios";
|
||||||
|
import type { ApiResponse, AuthTokenResponse } from "./types";
|
||||||
import { getOrCreateTerminalId } from "@/lib/terminal";
|
import { getOrCreateTerminalId } from "@/lib/terminal";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
|
||||||
const baseURL =
|
const baseURL =
|
||||||
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208";
|
||||||
@@ -21,14 +25,63 @@ api.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared in-flight refresh promise so that a burst of concurrent 401s triggers
|
||||||
|
* exactly one POST /api/auth/refresh instead of one per failed request.
|
||||||
|
*/
|
||||||
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const refreshToken = localStorage.getItem("meezi_refresh_token");
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
// Bare axios call (not `api`) to avoid recursing through this interceptor.
|
||||||
|
const { data } = await axios.post<ApiResponse<AuthTokenResponse>>(
|
||||||
|
`${baseURL}/api/auth/refresh`,
|
||||||
|
{ refreshToken },
|
||||||
|
{ headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
if (!data.success || !data.data) return null;
|
||||||
|
useAuthStore.getState().setAuth(data.data);
|
||||||
|
return data.data.accessToken;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error: AxiosError<ApiResponse<unknown>>) => {
|
async (error: AxiosError<ApiResponse<unknown>>) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const original = error.config as
|
||||||
|
| (InternalAxiosRequestConfig & { _retry?: boolean })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// Expired access token → try a one-time refresh, then replay the request.
|
||||||
|
if (
|
||||||
|
status === 401 &&
|
||||||
|
original &&
|
||||||
|
!original._retry &&
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
!original.url?.includes("/api/auth/")
|
||||||
|
) {
|
||||||
|
original._retry = true;
|
||||||
|
refreshPromise ??= refreshAccessToken().finally(() => {
|
||||||
|
refreshPromise = null;
|
||||||
|
});
|
||||||
|
const newToken = await refreshPromise;
|
||||||
|
if (newToken) {
|
||||||
|
original.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return api(original);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const apiError = error.response?.data?.error;
|
const apiError = error.response?.data?.error;
|
||||||
if (apiError?.code) {
|
if (apiError?.code) {
|
||||||
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
return Promise.reject(new ApiClientError(apiError.code, apiError.message));
|
||||||
}
|
}
|
||||||
if (error.response?.status === 401 && typeof window !== "undefined") {
|
if (status === 401 && typeof window !== "undefined") {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
|
const isPublicGuest = path.startsWith("/q/") || path.startsWith("/q");
|
||||||
const isAdmin = path.includes("/admin");
|
const isAdmin = path.includes("/admin");
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ type MarkersApiResponse = {
|
|||||||
|
|
||||||
// ── Coordinate transform ──────────────────────────────────────────────────────
|
// ── Coordinate transform ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Iran bounding box (degrees)
|
// Iran bounding box (degrees) — fitted to the real border extent
|
||||||
const MIN_LNG = 44;
|
// (lng 44.11–63.32, lat 25.08–39.71) with a small margin so the
|
||||||
const MAX_LNG = 64;
|
// silhouette fills the viewBox. Markers reproject with the same box,
|
||||||
const MIN_LAT = 24;
|
// so they stay aligned with the outline.
|
||||||
const MAX_LAT = 41;
|
const MIN_LNG = 43.6;
|
||||||
|
const MAX_LNG = 63.8;
|
||||||
|
const MIN_LAT = 24.6;
|
||||||
|
const MAX_LAT = 40.2;
|
||||||
const SVG_W = 600;
|
const SVG_W = 600;
|
||||||
const SVG_H = 500;
|
const SVG_H = 500;
|
||||||
|
|
||||||
@@ -41,34 +44,24 @@ function toPt([lng, lat]: [number, number]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Iran silhouette ────────────────────────────────────────────────────────────
|
// ── Iran silhouette ────────────────────────────────────────────────────────────
|
||||||
// Simplified 40-point polygon; approximate but recognisable.
|
// Real national border, simplified to 74 vertices (source: Natural Earth via
|
||||||
// Coordinates are [longitude, latitude] going clockwise from NW.
|
// world.geo.json). Coordinates are [longitude, latitude]; the ring starts on
|
||||||
|
// the Caspian (NE) and runs clockwise. Projected through toX/toY below, the
|
||||||
|
// same transform used for the café markers, so dots land in the right place.
|
||||||
const IRAN_OUTLINE: [number, number][] = [
|
const IRAN_OUTLINE: [number, number][] = [
|
||||||
// NW corner / Turkey-Armenia-Azerbaijan
|
[53.92, 37.20], [54.80, 37.39], [55.51, 37.96], [56.18, 37.94], [56.62, 38.12], [57.33, 38.03],
|
||||||
[44.8, 39.6], [45.5, 39.2], [46.2, 38.9],
|
[58.44, 37.52], [59.23, 37.41], [60.38, 36.53], [61.12, 36.49], [61.21, 35.65], [60.80, 34.40],
|
||||||
[46.8, 39.1], [47.6, 38.9],
|
[60.53, 33.68], [60.96, 33.53], [60.54, 32.98], [60.86, 32.18], [60.94, 31.55], [61.70, 31.38],
|
||||||
// Caspian coast (the concave notch heading south then north again)
|
[61.78, 30.74], [60.87, 29.83], [61.37, 29.30], [61.77, 28.70], [62.73, 28.26], [62.76, 27.38],
|
||||||
[48.3, 38.4], [49.0, 37.5], [49.9, 37.2],
|
[63.23, 27.22], [63.32, 26.76], [61.87, 26.24], [61.50, 25.08], [59.62, 25.38], [58.53, 25.61],
|
||||||
[51.0, 36.9], [52.2, 36.8], [53.0, 36.7],
|
[57.40, 25.74], [56.97, 26.97], [56.49, 27.14], [55.72, 26.96], [54.72, 26.48], [53.49, 26.81],
|
||||||
[54.0, 37.1], [54.7, 37.5],
|
[52.48, 27.58], [51.52, 27.87], [50.85, 28.81], [50.12, 30.15], [49.58, 29.99], [48.94, 30.32],
|
||||||
// NE / Turkmenistan
|
[48.57, 29.93], [48.01, 30.45], [48.00, 30.99], [47.69, 30.98], [47.85, 31.71], [47.33, 32.47],
|
||||||
[55.6, 37.4], [56.9, 37.1], [57.7, 36.8],
|
[46.11, 33.02], [45.42, 33.97], [45.65, 34.75], [46.15, 35.09], [46.08, 35.68], [45.42, 35.98],
|
||||||
[58.7, 37.5], [59.4, 36.8], [60.1, 36.7],
|
[44.77, 37.17], [44.23, 37.97], [44.42, 38.28], [44.11, 39.43], [44.79, 39.71], [44.95, 39.34],
|
||||||
// East / Afghanistan
|
[45.46, 38.87], [46.14, 38.74], [46.51, 38.77], [47.69, 39.51], [48.06, 39.58], [48.36, 39.29],
|
||||||
[61.2, 36.5], [61.3, 35.7], [62.0, 35.5],
|
[48.01, 38.79], [48.63, 38.27], [48.88, 38.32], [49.20, 37.58], [50.15, 37.37], [50.84, 36.87],
|
||||||
[62.5, 34.0], [63.0, 33.0], [63.2, 31.5],
|
[52.26, 36.70], [53.83, 36.97],
|
||||||
// SE / Pakistan – Oman Sea
|
|
||||||
[61.8, 29.8], [60.9, 29.5], [60.0, 27.5],
|
|
||||||
[59.0, 25.9], [58.5, 25.4],
|
|
||||||
// South coast (Persian Gulf, west-bound)
|
|
||||||
[57.5, 25.3], [56.4, 25.9], [55.6, 26.0],
|
|
||||||
[54.5, 27.0], [53.4, 27.3], [52.4, 28.0],
|
|
||||||
[51.1, 28.4], [50.4, 29.1], [49.0, 29.6],
|
|
||||||
[48.5, 30.2], [48.2, 30.8],
|
|
||||||
// West / Iraq border
|
|
||||||
[47.7, 31.0], [47.2, 32.0], [46.8, 33.2],
|
|
||||||
[46.2, 34.4], [45.5, 36.0], [45.0, 37.0],
|
|
||||||
[44.8, 38.1], [44.5, 38.9], [44.8, 39.6],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const IRAN_PATH =
|
const IRAN_PATH =
|
||||||
@@ -153,42 +146,50 @@ async function IranMapSvg() {
|
|||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Café blinking dots */}
|
{/* Café markers — each glows slowly on and off like a small lamp.
|
||||||
|
Halo and core brighten/dim together (ease-in-out), staggered so the
|
||||||
|
map twinkles organically rather than pulsing in unison. */}
|
||||||
{markers.map((m, idx) => {
|
{markers.map((m, idx) => {
|
||||||
const cx = toX(m.longitude);
|
const cx = toX(m.longitude);
|
||||||
const cy = toY(m.latitude);
|
const cy = toY(m.latitude);
|
||||||
// Stagger animation delay so dots don't all pulse in sync
|
const delay = `${((idx * 0.7) % 3.6).toFixed(2)}s`;
|
||||||
const delay = `${(idx * 0.4) % 2}s`;
|
const dur = "3.6s";
|
||||||
|
// ease-in-out for a smooth lamp-like fade
|
||||||
|
const ease = "0.4 0 0.6 1; 0.4 0 0.6 1";
|
||||||
return (
|
return (
|
||||||
<g key={m.id} filter="url(#glow)">
|
<g key={m.id} filter="url(#glow)">
|
||||||
{/* Outer pulse ring */}
|
{/* Soft halo */}
|
||||||
<circle cx={cx} cy={cy} r={10} fill="#0F6E56" opacity={0.2}>
|
<circle cx={cx} cy={cy} r={9} fill="#0F6E56">
|
||||||
<animate
|
|
||||||
attributeName="r"
|
|
||||||
values="8;16;8"
|
|
||||||
dur="2.4s"
|
|
||||||
begin={delay}
|
|
||||||
repeatCount="indefinite"
|
|
||||||
/>
|
|
||||||
<animate
|
<animate
|
||||||
attributeName="opacity"
|
attributeName="opacity"
|
||||||
values="0.25;0;0.25"
|
values="0.45;0.04;0.45"
|
||||||
dur="2.4s"
|
keyTimes="0;0.5;1"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines={ease}
|
||||||
|
dur={dur}
|
||||||
begin={delay}
|
begin={delay}
|
||||||
repeatCount="indefinite"
|
repeatCount="indefinite"
|
||||||
/>
|
/>
|
||||||
</circle>
|
</circle>
|
||||||
{/* Core dot */}
|
{/* Core dot — turns on (bright, slightly larger) and off (dim) */}
|
||||||
<circle
|
<circle cx={cx} cy={cy} r={4.5} fill="#0F6E56">
|
||||||
cx={cx}
|
|
||||||
cy={cy}
|
|
||||||
r={5}
|
|
||||||
fill="#0F6E56"
|
|
||||||
>
|
|
||||||
<animate
|
<animate
|
||||||
attributeName="opacity"
|
attributeName="opacity"
|
||||||
values="1;0.5;1"
|
values="1;0.2;1"
|
||||||
dur="2.4s"
|
keyTimes="0;0.5;1"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines={ease}
|
||||||
|
dur={dur}
|
||||||
|
begin={delay}
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
<animate
|
||||||
|
attributeName="r"
|
||||||
|
values="4.5;5.6;4.5"
|
||||||
|
keyTimes="0;0.5;1"
|
||||||
|
calcMode="spline"
|
||||||
|
keySplines={ease}
|
||||||
|
dur={dur}
|
||||||
begin={delay}
|
begin={delay}
|
||||||
repeatCount="indefinite"
|
repeatCount="indefinite"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user