Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b468b48d9 | |||
| f4583f5169 | |||
| 132f0921e0 | |||
| bb0be19dac | |||
| 15def7ff1c | |||
| 60e2ac1355 | |||
| a37d93f6cd | |||
| 7122df57b2 | |||
| 72f95aa0db | |||
| bab3453e41 | |||
| 24da1e0522 | |||
| 2203ecbdaf | |||
| 1aaab6c593 | |||
| 09bba5f8cd | |||
| 3b8dcf3af6 | |||
| 087563bce7 | |||
| e839db7331 | |||
| a83edf7667 | |||
| 75d5bbc84a | |||
| 7519f474f3 | |||
| 35494d8b32 | |||
| 4c7783884c | |||
| 8ce0b3e3e8 |
@@ -198,7 +198,10 @@ public class AuthController : ControllerBase
|
|||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
UserId: userId,
|
UserId: userId,
|
||||||
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
|
CafeId: User.FindFirstValue(MeeziClaimTypes.CafeId) ?? string.Empty,
|
||||||
Role: User.FindFirstValue(MeeziClaimTypes.Role) ?? string.Empty,
|
// .NET remaps the short "role" claim to ClaimTypes.Role on inbound; read both.
|
||||||
|
Role: User.FindFirstValue(MeeziClaimTypes.Role)
|
||||||
|
?? User.FindFirstValue(System.Security.Claims.ClaimTypes.Role)
|
||||||
|
?? string.Empty,
|
||||||
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
|
PlanTier: User.FindFirstValue(MeeziClaimTypes.PlanTier) ?? string.Empty,
|
||||||
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
Language: User.FindFirstValue(MeeziClaimTypes.Language) ?? string.Empty,
|
||||||
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
Actor: User.FindFirstValue(MeeziClaimTypes.Actor) ?? MeeziActorKinds.Merchant,
|
||||||
|
|||||||
@@ -103,4 +103,25 @@ public class BillingController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new ApiResponse<BillingStatusDto>(true, data));
|
return Ok(new ApiResponse<BillingStatusDto>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Authorize]
|
||||||
|
[HttpDelete("api/billing/queued/{paymentId}")]
|
||||||
|
public async Task<IActionResult> CancelQueued(string paymentId, ITenantContext tenant, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(tenant.CafeId))
|
||||||
|
return Unauthorized();
|
||||||
|
if (tenant.Role != Core.Enums.EmployeeRole.Owner)
|
||||||
|
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||||
|
new ApiError("OWNER_REQUIRED", "Only the cafe owner can manage subscription billing.")));
|
||||||
|
|
||||||
|
var (ok, code, message) = await _billing.CancelQueuedAsync(tenant.CafeId, paymentId, ct);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
return code == "NOT_FOUND"
|
||||||
|
? NotFound(new ApiResponse<object>(false, null, new ApiError(code, message ?? "Not found.")))
|
||||||
|
: BadRequest(new ApiResponse<object>(false, null, new ApiError(code ?? "ERROR", message ?? "Failed.")));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new ApiResponse<object>(true, new { id = paymentId }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
gallery,
|
gallery,
|
||||||
cafe.InstagramHandle,
|
cafe.InstagramHandle,
|
||||||
cafe.WebsiteUrl,
|
cafe.WebsiteUrl,
|
||||||
ToHoursDto(hours))));
|
ToHoursDto(hours),
|
||||||
|
cafe.ShowOnKoja)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PUT (description / social / hours) ───────────────────────────────────
|
// ── PUT (description / social / hours) ───────────────────────────────────
|
||||||
@@ -91,6 +92,10 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
if (request.WorkingHours is not null)
|
if (request.WorkingHours is not null)
|
||||||
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
|
cafe.WorkingHoursJson = JsonSerializer.Serialize(ToHoursSchedule(request.WorkingHours), _jsonOpts);
|
||||||
|
|
||||||
|
// Koja (public discovery) listing preference
|
||||||
|
if (request.ShowOnKoja.HasValue)
|
||||||
|
cafe.ShowOnKoja = request.ShowOnKoja.Value;
|
||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
var gallery = Deserialize<List<string>>(cafe.GalleryJson) ?? [];
|
||||||
@@ -101,7 +106,8 @@ public class CafePublicProfileController : CafeApiControllerBase
|
|||||||
gallery,
|
gallery,
|
||||||
cafe.InstagramHandle,
|
cafe.InstagramHandle,
|
||||||
cafe.WebsiteUrl,
|
cafe.WebsiteUrl,
|
||||||
ToHoursDto(hours))));
|
ToHoursDto(hours),
|
||||||
|
cafe.ShowOnKoja)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST gallery/upload ───────────────────────────────────────────────────
|
// ── POST gallery/upload ───────────────────────────────────────────────────
|
||||||
@@ -207,13 +213,15 @@ public record UpdateCafePublicProfileRequest(
|
|||||||
string? Description,
|
string? Description,
|
||||||
string? InstagramHandle,
|
string? InstagramHandle,
|
||||||
string? WebsiteUrl,
|
string? WebsiteUrl,
|
||||||
WorkingHoursPublicDto? WorkingHours);
|
WorkingHoursPublicDto? WorkingHours,
|
||||||
|
bool? ShowOnKoja = null);
|
||||||
|
|
||||||
public record CafeProfileEditDto(
|
public record CafeProfileEditDto(
|
||||||
string? Description,
|
string? Description,
|
||||||
IReadOnlyList<string> GalleryUrls,
|
IReadOnlyList<string> GalleryUrls,
|
||||||
string? InstagramHandle,
|
string? InstagramHandle,
|
||||||
string? WebsiteUrl,
|
string? WebsiteUrl,
|
||||||
WorkingHoursPublicDto? WorkingHours);
|
WorkingHoursPublicDto? WorkingHours,
|
||||||
|
bool ShowOnKoja);
|
||||||
|
|
||||||
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
|
public record GalleryDto(IReadOnlyList<string> GalleryUrls);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ public class InventoryController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<object>(true, updated));
|
return Ok(new ApiResponse<object>(true, updated));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("ingredients/{ingredientId}")]
|
||||||
|
public async Task<IActionResult> Delete(
|
||||||
|
string cafeId,
|
||||||
|
string ingredientId,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
var deleted = await _inventory.DeleteAsync(cafeId, ingredientId, ct);
|
||||||
|
if (!deleted) return NotFoundError();
|
||||||
|
return Ok(new ApiResponse<object>(true, new { id = ingredientId }));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("ingredients/{ingredientId}/adjust")]
|
[HttpPost("ingredients/{ingredientId}/adjust")]
|
||||||
public async Task<IActionResult> Adjust(
|
public async Task<IActionResult> Adjust(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
|
|||||||
@@ -163,6 +163,15 @@ public class MenuController : CafeApiControllerBase
|
|||||||
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
return Ok(new ApiResponse<MenuItemDto>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("items/{id}")]
|
||||||
|
public async Task<IActionResult> DeleteItem(string cafeId, string id, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
var deleted = await _menuService.DeleteItemAsync(cafeId, id, cancellationToken);
|
||||||
|
if (!deleted) return NotFoundError();
|
||||||
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("ai-3d/usage")]
|
[HttpGet("ai-3d/usage")]
|
||||||
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetAi3dUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -66,6 +66,19 @@ public class ReservationsController : CafeApiControllerBase
|
|||||||
if (data is null) return NotFoundError();
|
if (data is null) return NotFoundError();
|
||||||
return Ok(new ApiResponse<ReservationDto>(true, data));
|
return Ok(new ApiResponse<ReservationDto>(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{id}")]
|
||||||
|
public async Task<IActionResult> Delete(
|
||||||
|
string cafeId,
|
||||||
|
string id,
|
||||||
|
ITenantContext tenant,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||||
|
var deleted = await _reservations.DeleteAsync(cafeId, id, ct);
|
||||||
|
if (!deleted) return NotFoundError();
|
||||||
|
return Ok(new ApiResponse<object>(true, new { id }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record UpdateReservationStatusRequest(ReservationStatus Status);
|
public record UpdateReservationStatusRequest(ReservationStatus Status);
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ public static class ServiceCollectionExtensions
|
|||||||
app.UseMeeziSecurity();
|
app.UseMeeziSecurity();
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseMiddleware<Middleware.TenantMiddleware>();
|
app.UseMiddleware<Middleware.TenantMiddleware>();
|
||||||
|
// After tenant context (keys are scoped per café), before plan-limit + controllers
|
||||||
|
// so a replayed write short-circuits without re-consuming limits or re-executing.
|
||||||
|
app.UseMiddleware<Middleware.IdempotencyMiddleware>();
|
||||||
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
|
app.UseMiddleware<Middleware.PlanLimitMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
using System.Text;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Meezi.Core.Entities;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace Meezi.API.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Makes mutating requests safe to retry. A client (e.g. the offline outbox)
|
||||||
|
/// attaches an <c>Idempotency-Key</c> header; if the same key is seen again, the
|
||||||
|
/// original response is replayed instead of executing the write twice.
|
||||||
|
///
|
||||||
|
/// Bookkeeping runs in isolated DI scopes so it never mixes with the controller's
|
||||||
|
/// own DbContext unit of work. Opt-in via header → non-idempotent and binary/file
|
||||||
|
/// endpoints are unaffected unless the client explicitly sends a key.
|
||||||
|
/// </summary>
|
||||||
|
public class IdempotencyMiddleware
|
||||||
|
{
|
||||||
|
private const string HeaderName = "Idempotency-Key";
|
||||||
|
private const int MaxKeyLength = 200;
|
||||||
|
private const int MaxStoredBodyBytes = 256 * 1024;
|
||||||
|
/// <summary>An InProgress record older than this is assumed crashed mid-flight and re-run.</summary>
|
||||||
|
private static readonly TimeSpan StaleInProgress = TimeSpan.FromSeconds(60);
|
||||||
|
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<IdempotencyMiddleware> _logger;
|
||||||
|
|
||||||
|
public IdempotencyMiddleware(RequestDelegate next, ILogger<IdempotencyMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context, ITenantContext tenant, IServiceScopeFactory scopeFactory)
|
||||||
|
{
|
||||||
|
var method = context.Request.Method;
|
||||||
|
var isMutating = HttpMethods.IsPost(method) || HttpMethods.IsPut(method)
|
||||||
|
|| HttpMethods.IsPatch(method) || HttpMethods.IsDelete(method);
|
||||||
|
|
||||||
|
if (!isMutating || !context.Request.Headers.TryGetValue(HeaderName, out var headerValues))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = headerValues.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || key.Length > MaxKeyLength)
|
||||||
|
{
|
||||||
|
// Unusable key — behave as if it wasn't sent rather than reject the write.
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scope = string.IsNullOrEmpty(tenant.CafeId) ? "global" : tenant.CafeId;
|
||||||
|
var path = context.Request.Path.Value ?? string.Empty;
|
||||||
|
|
||||||
|
// 1) Look for an existing record for this (tenant, key).
|
||||||
|
await using (var lookupScope = scopeFactory.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = lookupScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var existing = await db.IdempotencyRecords.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(r => r.Scope == scope && r.Key == key, context.RequestAborted);
|
||||||
|
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
if (existing.Status == IdempotencyStatus.Completed)
|
||||||
|
{
|
||||||
|
await ReplayAsync(context, existing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DateTime.UtcNow - existing.CreatedAt < StaleInProgress)
|
||||||
|
{
|
||||||
|
await WriteConflictAsync(context); // genuine concurrent duplicate
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Stale reservation (process likely crashed mid-flight) — drop and re-run.
|
||||||
|
_logger.LogWarning("Recovering stale idempotency reservation {Key} for scope {Scope}", key, scope);
|
||||||
|
var stale = await db.IdempotencyRecords
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == existing.Id, context.RequestAborted);
|
||||||
|
if (stale is not null)
|
||||||
|
{
|
||||||
|
db.IdempotencyRecords.Remove(stale);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Reserve the key. The unique (Scope, Key) index serializes racing first requests.
|
||||||
|
var record = new IdempotencyRecord
|
||||||
|
{
|
||||||
|
Scope = scope,
|
||||||
|
Key = key,
|
||||||
|
Method = method,
|
||||||
|
Path = path,
|
||||||
|
Status = IdempotencyStatus.InProgress,
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var reserveScope = scopeFactory.CreateAsyncScope();
|
||||||
|
var db = reserveScope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
db.IdempotencyRecords.Add(record);
|
||||||
|
await db.SaveChangesAsync(context.RequestAborted);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
await WriteConflictAsync(context); // another request won the reservation race
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Run the real request, capturing its response.
|
||||||
|
var originalBody = context.Response.Body;
|
||||||
|
await using var buffer = new MemoryStream();
|
||||||
|
context.Response.Body = buffer;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
context.Response.Body = originalBody;
|
||||||
|
await DeleteAsync(scopeFactory, record.Id);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusCode = context.Response.StatusCode;
|
||||||
|
buffer.Position = 0;
|
||||||
|
var bytes = buffer.ToArray();
|
||||||
|
context.Response.Body = originalBody;
|
||||||
|
if (bytes.Length > 0)
|
||||||
|
await originalBody.WriteAsync(bytes, context.RequestAborted);
|
||||||
|
|
||||||
|
// 4) Persist the result so retries replay it — except 5xx, which is transient and
|
||||||
|
// released so the client can retry the same key.
|
||||||
|
if (statusCode is >= 200 and < 500)
|
||||||
|
{
|
||||||
|
var storedBody = bytes.Length is > 0 and <= MaxStoredBodyBytes
|
||||||
|
? Encoding.UTF8.GetString(bytes)
|
||||||
|
: null;
|
||||||
|
await CompleteAsync(scopeFactory, record.Id, statusCode, storedBody);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await DeleteAsync(scopeFactory, record.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReplayAsync(HttpContext context, IdempotencyRecord record)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = record.ResponseStatusCode;
|
||||||
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
context.Response.Headers["Idempotent-Replay"] = "true";
|
||||||
|
if (!string.IsNullOrEmpty(record.ResponseBody))
|
||||||
|
await context.Response.WriteAsync(record.ResponseBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task WriteConflictAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status409Conflict;
|
||||||
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await context.Response.WriteAsync(
|
||||||
|
"{\"success\":false,\"data\":null,\"error\":{\"code\":\"IDEMPOTENCY_IN_PROGRESS\",\"message\":\"A request with this key is still being processed.\"}}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task CompleteAsync(IServiceScopeFactory f, string id, int status, string? body)
|
||||||
|
{
|
||||||
|
await using var s = f.CreateAsyncScope();
|
||||||
|
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (rec is null) return;
|
||||||
|
rec.Status = IdempotencyStatus.Completed;
|
||||||
|
rec.ResponseStatusCode = status;
|
||||||
|
rec.ResponseBody = body;
|
||||||
|
rec.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task DeleteAsync(IServiceScopeFactory f, string id)
|
||||||
|
{
|
||||||
|
await using var s = f.CreateAsyncScope();
|
||||||
|
var db = s.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var rec = await db.IdempotencyRecords.FirstOrDefaultAsync(r => r.Id == id);
|
||||||
|
if (rec is null) return;
|
||||||
|
db.IdempotencyRecords.Remove(rec);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,7 +92,12 @@ public class TenantMiddleware
|
|||||||
{
|
{
|
||||||
scopedMerchant.CafeId = cafeId;
|
scopedMerchant.CafeId = cafeId;
|
||||||
|
|
||||||
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value;
|
// .NET's JWT handler remaps the short "role" claim to ClaimTypes.Role
|
||||||
|
// on inbound, so FindFirst("role") returns null and tenant.Role would
|
||||||
|
// stay null — making EnsureManager/EnsureOwner reject even a real owner.
|
||||||
|
// Read both the raw claim and the mapped one.
|
||||||
|
var roleClaim = context.User.FindFirst(MeeziClaimTypes.Role)?.Value
|
||||||
|
?? context.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value;
|
||||||
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
|
if (Enum.TryParse<EmployeeRole>(roleClaim, ignoreCase: true, out var role))
|
||||||
scopedMerchant.Role = role;
|
scopedMerchant.Role = role;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ public record BillingStatusDto(
|
|||||||
int MenuAi3dUsedThisMonth,
|
int MenuAi3dUsedThisMonth,
|
||||||
int MenuAi3dMonthlyLimit,
|
int MenuAi3dMonthlyLimit,
|
||||||
bool DiscoverProfileEnabled,
|
bool DiscoverProfileEnabled,
|
||||||
bool IsPlanExpired);
|
bool IsPlanExpired,
|
||||||
|
IReadOnlyList<QueuedPlanDto> QueuedPlans);
|
||||||
|
|
||||||
|
public record QueuedPlanDto(
|
||||||
|
string PaymentId,
|
||||||
|
PlanTier PlanTier,
|
||||||
|
int Months,
|
||||||
|
DateTime EffectiveFrom,
|
||||||
|
DateTime EffectiveTo,
|
||||||
|
decimal AmountToman);
|
||||||
|
|
||||||
public record BillingVerifyResult(bool Success, string RedirectUrl);
|
public record BillingVerifyResult(bool Success, string RedirectUrl);
|
||||||
|
|||||||
@@ -253,7 +253,9 @@ public class AuthService : IAuthService
|
|||||||
if (employee?.Cafe is null)
|
if (employee?.Cafe is null)
|
||||||
return (false, null, "NOT_FOUND", "User no longer exists.");
|
return (false, null, "NOT_FOUND", "User no longer exists.");
|
||||||
|
|
||||||
await _refreshTokenStore.RevokeAsync(request.RefreshToken, cancellationToken);
|
// Note: we intentionally do NOT revoke the presented refresh token here.
|
||||||
|
// It is reused (with a slid TTL) so concurrent refreshes from multiple
|
||||||
|
// tabs/devices stay valid instead of racing each other into a logout.
|
||||||
|
|
||||||
var allMemberships = await _db.Employees
|
var allMemberships = await _db.Employees
|
||||||
.Include(e => e.Cafe)
|
.Include(e => e.Cafe)
|
||||||
@@ -265,7 +267,9 @@ public class AuthService : IAuthService
|
|||||||
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
.Select(e => new CafeMembershipDto(e.CafeId, e.Cafe!.Name, e.Role.ToString(), e.Cafe.PlanTier.ToString()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var tokens = await IssueTokensAsync(employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken);
|
var tokens = await IssueTokensAsync(
|
||||||
|
employee, employee.Cafe, membershipDtos, payload.ActiveBranchId, cancellationToken,
|
||||||
|
existingRefreshToken: request.RefreshToken);
|
||||||
return (true, tokens, null, null);
|
return (true, tokens, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,12 +514,18 @@ public class AuthService : IAuthService
|
|||||||
Core.Entities.Cafe cafe,
|
Core.Entities.Cafe cafe,
|
||||||
List<CafeMembershipDto>? memberships,
|
List<CafeMembershipDto>? memberships,
|
||||||
string? requestedBranchId,
|
string? requestedBranchId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken,
|
||||||
|
string? existingRefreshToken = null)
|
||||||
{
|
{
|
||||||
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
|
var resolution = await ResolveBranchAsync(employee, cafe, requestedBranchId, cancellationToken);
|
||||||
|
|
||||||
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
|
var accessToken = _jwtTokenService.CreateAccessToken(employee, cafe, resolution.EffectiveRole, resolution.ActiveBranchId);
|
||||||
var refreshToken = _jwtTokenService.CreateRefreshToken();
|
// On refresh, reuse the caller's refresh token (and slide its TTL below) instead
|
||||||
|
// of minting a new one. A café often runs POS + KDS + queue display at once; if
|
||||||
|
// refresh rotated the token, the first refresh would revoke it and every other
|
||||||
|
// concurrent refresh would get INVALID_TOKEN → forced logout → OTP storm.
|
||||||
|
// Mint a fresh token only on a real login (existingRefreshToken == null).
|
||||||
|
var refreshToken = existingRefreshToken ?? _jwtTokenService.CreateRefreshToken();
|
||||||
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
var refreshDays = _configuration.GetValue("Jwt:RefreshTokenExpiryDays", 30);
|
||||||
|
|
||||||
await _refreshTokenStore.StoreAsync(
|
await _refreshTokenStore.StoreAsync(
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ public interface IBillingService
|
|||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
|
Task<BillingStatusDto?> GetStatusAsync(string cafeId, PlanTier currentTier, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
|
||||||
|
string cafeId,
|
||||||
|
string paymentId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BillingService : IBillingService
|
public class BillingService : IBillingService
|
||||||
@@ -210,31 +215,161 @@ public class BillingService : IBillingService
|
|||||||
return new BillingVerifyResult(false, failUrl);
|
return new BillingVerifyResult(false, failUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
payment.Status = SubscriptionPaymentStatus.Completed;
|
|
||||||
payment.RefId = verify.RefId;
|
payment.RefId = verify.RefId;
|
||||||
|
|
||||||
var cafe = payment.Cafe;
|
var cafe = payment.Cafe;
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Where does the current paid coverage end? = the latest of the active plan's expiry
|
||||||
|
// and the furthest-out already-queued period. A new purchase is appended to that.
|
||||||
|
var coverageEnd = await ComputeCoverageEndAsync(cafe, payment.Id, now, cancellationToken);
|
||||||
|
|
||||||
|
payment.EffectiveFrom = coverageEnd;
|
||||||
|
payment.EffectiveTo = coverageEnd.AddMonths(payment.Months);
|
||||||
|
|
||||||
|
var queued = coverageEnd > now;
|
||||||
|
if (queued)
|
||||||
|
{
|
||||||
|
// The owner already has active/queued coverage → book this one after it.
|
||||||
|
payment.Status = SubscriptionPaymentStatus.Scheduled;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No active coverage → activate immediately.
|
||||||
|
payment.Status = SubscriptionPaymentStatus.Completed;
|
||||||
cafe.PlanTier = payment.PlanTier;
|
cafe.PlanTier = payment.PlanTier;
|
||||||
var baseDate = cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt > DateTime.UtcNow
|
cafe.PlanExpiresAt = payment.EffectiveTo;
|
||||||
? cafe.PlanExpiresAt.Value
|
}
|
||||||
: DateTime.UtcNow;
|
|
||||||
cafe.PlanExpiresAt = baseDate.AddMonths(payment.Months);
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await TrySendConfirmationSmsAsync(cafe, payment, cancellationToken);
|
await TrySendConfirmationSmsAsync(cafe, payment, queued, cancellationToken);
|
||||||
|
|
||||||
return new BillingVerifyResult(true, successUrl);
|
return new BillingVerifyResult(true, successUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>End of the cafe's current paid coverage: the later of its active plan expiry
|
||||||
|
/// and the furthest-out scheduled (queued) period. Returns <paramref name="now"/> if neither
|
||||||
|
/// extends past now (i.e. nothing active/queued).</summary>
|
||||||
|
private async Task<DateTime> ComputeCoverageEndAsync(
|
||||||
|
Cafe cafe, string? excludePaymentId, DateTime now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var end = now;
|
||||||
|
if (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > end)
|
||||||
|
end = cafe.PlanExpiresAt.Value;
|
||||||
|
|
||||||
|
var lastScheduledEnd = await _db.SubscriptionPayments
|
||||||
|
.Where(p => p.CafeId == cafe.Id
|
||||||
|
&& p.Status == SubscriptionPaymentStatus.Scheduled
|
||||||
|
&& (excludePaymentId == null || p.Id != excludePaymentId)
|
||||||
|
&& p.EffectiveTo != null)
|
||||||
|
.OrderByDescending(p => p.EffectiveTo)
|
||||||
|
.Select(p => p.EffectiveTo)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (lastScheduledEnd.HasValue && lastScheduledEnd.Value > end)
|
||||||
|
end = lastScheduledEnd.Value;
|
||||||
|
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>When the active plan has lapsed, promote due queued periods to active.
|
||||||
|
/// Loops so a fully-elapsed short queued period doesn't strand the next one.</summary>
|
||||||
|
private async Task PromoteDueScheduledAsync(string cafeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||||
|
if (cafe is null) return;
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
while (!(cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now))
|
||||||
|
{
|
||||||
|
var next = await _db.SubscriptionPayments
|
||||||
|
.Where(p => p.CafeId == cafeId
|
||||||
|
&& p.Status == SubscriptionPaymentStatus.Scheduled
|
||||||
|
&& p.EffectiveFrom != null && p.EffectiveFrom <= now)
|
||||||
|
.OrderBy(p => p.EffectiveFrom)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
if (next is null) break;
|
||||||
|
|
||||||
|
cafe.PlanTier = next.PlanTier;
|
||||||
|
cafe.PlanExpiresAt = next.EffectiveTo;
|
||||||
|
next.Status = SubscriptionPaymentStatus.Completed;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(bool Ok, string? ErrorCode, string? Message)> CancelQueuedAsync(
|
||||||
|
string cafeId,
|
||||||
|
string paymentId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var payment = await _db.SubscriptionPayments
|
||||||
|
.FirstOrDefaultAsync(p => p.Id == paymentId && p.CafeId == cafeId, cancellationToken);
|
||||||
|
if (payment is null)
|
||||||
|
return (false, "NOT_FOUND", "Subscription not found.");
|
||||||
|
|
||||||
|
// Only a queued (not-yet-started) subscription can be cancelled. The active prepaid
|
||||||
|
// plan keeps running until its paid time ends.
|
||||||
|
if (payment.Status != SubscriptionPaymentStatus.Scheduled)
|
||||||
|
return (false, "NOT_CANCELLABLE", "Only a queued subscription can be cancelled.");
|
||||||
|
|
||||||
|
payment.Status = SubscriptionPaymentStatus.Cancelled;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Re-pack the remaining queue so later periods slide earlier to fill the gap.
|
||||||
|
await RecomputeQueueAsync(cafeId, cancellationToken);
|
||||||
|
return (true, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-sequences the remaining queued periods contiguously after the active plan
|
||||||
|
/// (purchase order preserved), so cancelling one in the middle doesn't leave a gap.</summary>
|
||||||
|
private async Task RecomputeQueueAsync(string cafeId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var cafe = await _db.Cafes.FirstOrDefaultAsync(c => c.Id == cafeId, ct);
|
||||||
|
if (cafe is null) return;
|
||||||
|
|
||||||
|
var anchor = (cafe.PlanTier != PlanTier.Free && cafe.PlanExpiresAt.HasValue && cafe.PlanExpiresAt.Value > now)
|
||||||
|
? cafe.PlanExpiresAt.Value
|
||||||
|
: now;
|
||||||
|
|
||||||
|
var scheduled = await _db.SubscriptionPayments
|
||||||
|
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled)
|
||||||
|
.OrderBy(p => p.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var s in scheduled)
|
||||||
|
{
|
||||||
|
s.EffectiveFrom = anchor;
|
||||||
|
s.EffectiveTo = anchor.AddMonths(s.Months);
|
||||||
|
anchor = s.EffectiveTo.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduled.Count > 0) await _db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<BillingStatusDto?> GetStatusAsync(
|
public async Task<BillingStatusDto?> GetStatusAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
PlanTier currentTier,
|
PlanTier currentTier,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
// Lazily activate any queued plan whose start date has passed before reading status.
|
||||||
|
await PromoteDueScheduledAsync(cafeId, cancellationToken);
|
||||||
|
|
||||||
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
var cafe = await _db.Cafes.AsNoTracking().FirstOrDefaultAsync(c => c.Id == cafeId, cancellationToken);
|
||||||
if (cafe is null) return null;
|
if (cafe is null) return null;
|
||||||
|
|
||||||
|
var queuedPlans = await _db.SubscriptionPayments.AsNoTracking()
|
||||||
|
.Where(p => p.CafeId == cafeId && p.Status == SubscriptionPaymentStatus.Scheduled
|
||||||
|
&& p.EffectiveFrom != null && p.EffectiveTo != null)
|
||||||
|
.OrderBy(p => p.EffectiveFrom)
|
||||||
|
.Select(p => new QueuedPlanDto(
|
||||||
|
p.Id, p.PlanTier, p.Months, p.EffectiveFrom!.Value, p.EffectiveTo!.Value, p.AmountToman))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var todayStart = DateTime.UtcNow.Date;
|
var todayStart = DateTime.UtcNow.Date;
|
||||||
var ordersToday = await _db.Orders.CountAsync(
|
var ordersToday = await _db.Orders.CountAsync(
|
||||||
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
o => o.CafeId == cafeId && o.CreatedAt >= todayStart,
|
||||||
@@ -278,12 +413,14 @@ public class BillingService : IBillingService
|
|||||||
ai3dUsedCount,
|
ai3dUsedCount,
|
||||||
ai3dLimit,
|
ai3dLimit,
|
||||||
discoverProfile,
|
discoverProfile,
|
||||||
isExpired);
|
isExpired,
|
||||||
|
queuedPlans);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TrySendConfirmationSmsAsync(
|
private async Task TrySendConfirmationSmsAsync(
|
||||||
Cafe cafe,
|
Cafe cafe,
|
||||||
SubscriptionPayment payment,
|
SubscriptionPayment payment,
|
||||||
|
bool queued,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ownerPhone = await _db.Employees
|
var ownerPhone = await _db.Employees
|
||||||
@@ -293,8 +430,9 @@ public class BillingService : IBillingService
|
|||||||
|
|
||||||
if (string.IsNullOrEmpty(ownerPhone)) return;
|
if (string.IsNullOrEmpty(ownerPhone)) return;
|
||||||
|
|
||||||
var message =
|
var message = queued
|
||||||
$"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
? $"میزی: اشتراک {payment.PlanTier} ثبت شد و از {payment.EffectiveFrom:yyyy-MM-dd} (پس از پایان اشتراک فعلی) آغاز میشود. مبلغ: {payment.AmountToman:N0} ت"
|
||||||
|
: $"میزی: اشتراک {payment.PlanTier} فعال شد تا {cafe.PlanExpiresAt:yyyy-MM-dd}. مبلغ: {payment.AmountToman:N0} ت";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
await _smsService.SendMessageAsync(ownerPhone, message, cancellationToken);
|
||||||
|
|||||||
@@ -130,7 +130,10 @@ public class DemoSeedService : IDemoSeedService
|
|||||||
decimal qty, decimal reorder, decimal cost, decimal par) =>
|
decimal qty, decimal reorder, decimal cost, decimal par) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = $"{cafeId}_ing_{Guid.NewGuid():N}"[..36],
|
// No [..36] truncation: Id is a text column, and truncating to 36 chars
|
||||||
|
// cuts off the unique guid for real (32-char) café ids → every row gets
|
||||||
|
// the same id → PK collision → 500. Keep the full unique id.
|
||||||
|
Id = $"{cafeId}_ing_{Guid.NewGuid():N}",
|
||||||
CafeId = cafeId,
|
CafeId = cafeId,
|
||||||
Name = name,
|
Name = name,
|
||||||
Unit = unit,
|
Unit = unit,
|
||||||
@@ -160,7 +163,9 @@ public class DemoSeedService : IDemoSeedService
|
|||||||
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
|
string cafeId, string branchId, string number, int capacity, string floor, int sortOrder) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}"[..36],
|
// No [..36] truncation (see Ingredient above): truncating cuts the guid
|
||||||
|
// for real 32-char café ids → identical ids → PK collision → 500.
|
||||||
|
Id = $"{cafeId}_tbl_{Guid.NewGuid():N}",
|
||||||
CafeId = cafeId,
|
CafeId = cafeId,
|
||||||
BranchId = branchId,
|
BranchId = branchId,
|
||||||
Number = number,
|
Number = number,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ public interface IInventoryService
|
|||||||
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
|
Task<IReadOnlyList<IngredientDto>> LowStockAsync(string cafeId, CancellationToken ct = default);
|
||||||
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
|
Task<IngredientDto?> CreateAsync(string cafeId, CreateIngredientRequest request, CancellationToken ct = default);
|
||||||
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
|
Task<IngredientDto?> UpdateAsync(string cafeId, string ingredientId, UpdateIngredientRequest request, CancellationToken ct = default);
|
||||||
|
Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default);
|
||||||
Task<IngredientDto?> AdjustAsync(
|
Task<IngredientDto?> AdjustAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string ingredientId,
|
string ingredientId,
|
||||||
@@ -205,6 +206,18 @@ public class InventoryService : IInventoryService
|
|||||||
return ToDto(entity);
|
return ToDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var entity = await _db.Ingredients.FirstOrDefaultAsync(i => i.Id == ingredientId && i.CafeId == cafeId, ct);
|
||||||
|
if (entity is null) return false;
|
||||||
|
|
||||||
|
// Soft delete: Ingredient has a global DeletedAt query filter, so it (and its
|
||||||
|
// recipe lines / stock movements) drop out of every query without FK trouble.
|
||||||
|
entity.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IngredientDto?> AdjustAsync(
|
public async Task<IngredientDto?> AdjustAsync(
|
||||||
string cafeId,
|
string cafeId,
|
||||||
string ingredientId,
|
string ingredientId,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public interface IMenuService
|
|||||||
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
Task<MenuItemDto?> CreateItemAsync(string cafeId, CreateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
Task<MenuItemDto?> UpdateItemAsync(string cafeId, string id, UpdateMenuItemRequest request, CancellationToken cancellationToken = default);
|
||||||
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
Task<MenuItemDto?> SetAvailabilityAsync(string cafeId, string id, bool isAvailable, CancellationToken cancellationToken = default);
|
||||||
|
Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MenuService : IMenuService
|
public class MenuService : IMenuService
|
||||||
@@ -192,6 +193,16 @@ public class MenuService : IMenuService
|
|||||||
return ToItemDto(entity);
|
return ToItemDto(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteItemAsync(string cafeId, string id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await _db.MenuItems.FirstOrDefaultAsync(i => i.Id == id && i.CafeId == cafeId, cancellationToken);
|
||||||
|
if (entity is null) return false;
|
||||||
|
|
||||||
|
entity.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? NormalizeOptionalText(string? value) =>
|
private static string? NormalizeOptionalText(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ public interface IReservationService
|
|||||||
string reservationId,
|
string reservationId,
|
||||||
ReservationStatus status,
|
ReservationStatus status,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<bool> DeleteAsync(
|
||||||
|
string cafeId,
|
||||||
|
string reservationId,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ReservationService : IReservationService
|
public class ReservationService : IReservationService
|
||||||
@@ -118,6 +123,25 @@ public class ReservationService : IReservationService
|
|||||||
return Map(entity);
|
return Map(entity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteAsync(
|
||||||
|
string cafeId,
|
||||||
|
string reservationId,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await _db.TableReservations
|
||||||
|
.FirstOrDefaultAsync(r => r.Id == reservationId && r.CafeId == cafeId, cancellationToken);
|
||||||
|
if (entity is null) return false;
|
||||||
|
|
||||||
|
// Soft delete: TableReservation has a global DeletedAt query filter.
|
||||||
|
entity.DeletedAt = DateTime.UtcNow;
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(entity.TableId))
|
||||||
|
await _kdsNotifier.NotifyTableStatusChangedAsync(cafeId, cancellationToken);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
internal static ReservationDto Map(TableReservation r) => new(
|
internal static ReservationDto Map(TableReservation r) => new(
|
||||||
r.Id,
|
r.Id,
|
||||||
r.CafeId,
|
r.CafeId,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public class ReviewService : IReviewService
|
|||||||
DiscoverFilterParams filters,
|
DiscoverFilterParams filters,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null);
|
var query = _db.Cafes.Where(c => c.IsVerified && c.DeletedAt == null && c.ShowOnKoja);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filters.City))
|
if (!string.IsNullOrWhiteSpace(filters.City))
|
||||||
query = query.Where(c => c.City != null && c.City.Contains(filters.City));
|
query = query.Where(c => c.City != null && c.City.Contains(filters.City));
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ public class Cafe : BaseEntity
|
|||||||
public PlanTier PlanTier { get; set; } = PlanTier.Free;
|
public PlanTier PlanTier { get; set; } = PlanTier.Free;
|
||||||
public DateTime? PlanExpiresAt { get; set; }
|
public DateTime? PlanExpiresAt { get; set; }
|
||||||
public bool IsVerified { get; set; }
|
public bool IsVerified { get; set; }
|
||||||
|
/// <summary>Owner preference: list this café on Koja (public discovery). Defaults true so a
|
||||||
|
/// verified café is discoverable out of the box; the owner can opt out from settings.</summary>
|
||||||
|
public bool ShowOnKoja { get; set; } = true;
|
||||||
/// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary>
|
/// <summary>When true, merchant API access is blocked until reactivated by platform admin.</summary>
|
||||||
public bool IsSuspended { get; set; }
|
public bool IsSuspended { get; set; }
|
||||||
public string? SnappfoodVendorId { get; set; }
|
public string? SnappfoodVendorId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Meezi.Core.Enums;
|
||||||
|
|
||||||
|
namespace Meezi.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a client-supplied Idempotency-Key so a retried write (e.g. an order
|
||||||
|
/// replayed from the offline outbox after a lost response) returns the original
|
||||||
|
/// result instead of executing twice. Standalone POCO — deliberately not a
|
||||||
|
/// TenantEntity, to avoid soft-delete/tenant query filters.
|
||||||
|
/// </summary>
|
||||||
|
public class IdempotencyRecord
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
/// <summary>Tenant scope (CafeId), or "global" for non-tenant requests.</summary>
|
||||||
|
public string Scope { get; set; } = "global";
|
||||||
|
|
||||||
|
/// <summary>The client-supplied Idempotency-Key header value.</summary>
|
||||||
|
public string Key { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Method { get; set; } = string.Empty;
|
||||||
|
public string Path { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public IdempotencyStatus Status { get; set; } = IdempotencyStatus.InProgress;
|
||||||
|
|
||||||
|
public int ResponseStatusCode { get; set; }
|
||||||
|
public string? ResponseBody { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -13,5 +13,13 @@ public class SubscriptionPayment : TenantEntity
|
|||||||
public string? RefId { get; set; }
|
public string? RefId { get; set; }
|
||||||
public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending;
|
public SubscriptionPaymentStatus Status { get; set; } = SubscriptionPaymentStatus.Pending;
|
||||||
|
|
||||||
|
/// <summary>When this paid period starts. For an immediately-activated purchase this is
|
||||||
|
/// (around) the payment time; for a queued (Scheduled) purchase it is the end of the
|
||||||
|
/// current coverage. Null until the payment completes.</summary>
|
||||||
|
public DateTime? EffectiveFrom { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this paid period ends (EffectiveFrom + Months). Null until completed.</summary>
|
||||||
|
public DateTime? EffectiveTo { get; set; }
|
||||||
|
|
||||||
public Cafe Cafe { get; set; } = null!;
|
public Cafe Cafe { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Meezi.Core.Enums;
|
||||||
|
|
||||||
|
public enum IdempotencyStatus
|
||||||
|
{
|
||||||
|
/// <summary>Reserved; the original request is still executing.</summary>
|
||||||
|
InProgress = 0,
|
||||||
|
/// <summary>Finished; the stored response is replayed on duplicate keys.</summary>
|
||||||
|
Completed = 1
|
||||||
|
}
|
||||||
@@ -4,5 +4,9 @@ public enum SubscriptionPaymentStatus
|
|||||||
{
|
{
|
||||||
Pending = 0,
|
Pending = 0,
|
||||||
Completed = 1,
|
Completed = 1,
|
||||||
Failed = 2
|
Failed = 2,
|
||||||
|
/// <summary>Paid, but queued to start after the current coverage ends.</summary>
|
||||||
|
Scheduled = 3,
|
||||||
|
/// <summary>A queued (Scheduled) subscription the owner cancelled before it started.</summary>
|
||||||
|
Cancelled = 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,10 +82,25 @@ public class AppDbContext : DbContext
|
|||||||
// Immutable audit trail of sensitive POS / management actions.
|
// Immutable audit trail of sensitive POS / management actions.
|
||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
|
|
||||||
|
// Idempotency keys for safe retry of offline-replayed writes.
|
||||||
|
public DbSet<IdempotencyRecord> IdempotencyRecords => Set<IdempotencyRecord>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<IdempotencyRecord>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
// One result per (tenant, key). The unique index also serializes
|
||||||
|
// concurrent first-time requests carrying the same key.
|
||||||
|
e.HasIndex(x => new { x.Scope, x.Key }).IsUnique();
|
||||||
|
e.Property(x => x.Scope).HasMaxLength(64).IsRequired();
|
||||||
|
e.Property(x => x.Key).HasMaxLength(200).IsRequired();
|
||||||
|
e.Property(x => x.Method).HasMaxLength(10).IsRequired();
|
||||||
|
e.Property(x => x.Path).HasMaxLength(512).IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<PushDevice>(e =>
|
modelBuilder.Entity<PushDevice>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
@@ -111,6 +126,9 @@ public class AppDbContext : DbContext
|
|||||||
e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000);
|
e.Property(x => x.DiscoverProfileJson).HasMaxLength(8000);
|
||||||
e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000);
|
e.Property(x => x.DiscoverBadgesJson).HasMaxLength(2000);
|
||||||
e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2);
|
e.Property(x => x.DefaultTaxRate).HasPrecision(5, 2);
|
||||||
|
// Default true at the DB level so existing cafés stay listed on Koja after
|
||||||
|
// the column is added (EF doesn't read the C# initializer for the SQL default).
|
||||||
|
e.Property(x => x.ShowOnKoja).HasDefaultValue(true);
|
||||||
e.HasQueryFilter(x => x.DeletedAt == null);
|
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+3310
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCafeShowOnKoja : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "ShowOnKoja",
|
||||||
|
table: "Cafes",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShowOnKoja",
|
||||||
|
table: "Cafes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+3316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSubscriptionScheduling : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "EffectiveFrom",
|
||||||
|
table: "SubscriptionPayments",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "EffectiveTo",
|
||||||
|
table: "SubscriptionPayments",
|
||||||
|
type: "timestamp with time zone",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EffectiveFrom",
|
||||||
|
table: "SubscriptionPayments");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EffectiveTo",
|
||||||
|
table: "SubscriptionPayments");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3364
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIdempotencyRecords : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "IdempotencyRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Scope = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||||
|
Key = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||||
|
Method = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
|
||||||
|
Path = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ResponseStatusCode = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
ResponseBody = table.Column<string>(type: "text", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_IdempotencyRecords", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_IdempotencyRecords_Scope_Key",
|
||||||
|
table: "IdempotencyRecords",
|
||||||
|
columns: new[] { "Scope", "Key" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "IdempotencyRecords");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,6 +360,11 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("ShowOnKoja")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true);
|
||||||
|
|
||||||
b.Property<string>("Slug")
|
b.Property<string>("Slug")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -1124,6 +1129,54 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("Expenses");
|
b.ToTable("Expenses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.IdempotencyRecord", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CompletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("Method")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("character varying(512)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseBody")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<int>("ResponseStatusCode")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Scope")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Scope", "Key")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("IdempotencyRecords");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.Ingredient", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -2006,6 +2059,12 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EffectiveFrom")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("EffectiveTo")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<int>("Months")
|
b.Property<int>("Months")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,25 @@ 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);
|
||||||
|
|
||||||
|
// Best-effort, NON-FATAL seeding. These steps populate convenience data
|
||||||
|
// (map pins, plan/feature catalog) and must never crash-loop the API on
|
||||||
|
// boot — a failure is logged and startup continues so the service serves.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 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 the admin panel reads in every
|
||||||
|
// environment. Idempotent: adds any tiers/keys that are missing.
|
||||||
|
await SeedPlansAsync(db, logger);
|
||||||
|
await SeedFeaturesAsync(db, logger);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Non-fatal platform seeding step failed; continuing startup");
|
||||||
|
}
|
||||||
|
|
||||||
if (!env.IsDevelopment())
|
if (!env.IsDevelopment())
|
||||||
{
|
{
|
||||||
// Production: also ensure integration settings (Kavenegar enabled/template,
|
// Production: also ensure integration settings (Kavenegar enabled/template,
|
||||||
@@ -39,12 +58,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 +370,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 +431,26 @@ public static class PlatformDataSeeder
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
db.PlatformPlanDefinitions.AddRange(plans);
|
// Tier (not Id) carries the unique constraint, so dedupe on Tier — an
|
||||||
|
// existing Free plan may have a different Id, and inserting another
|
||||||
|
// Free-tier row would violate IX_PlatformPlanDefinitions_Tier.
|
||||||
|
// IgnoreQueryFilters: a SOFT-DELETED plan still occupies its Tier in the
|
||||||
|
// unique index, so it must be counted or the insert collides on boot.
|
||||||
|
var existingTiers = (await db.PlatformPlanDefinitions
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Select(p => p.Tier)
|
||||||
|
.ToListAsync())
|
||||||
|
.ToHashSet();
|
||||||
|
var missing = plans.Where(p => !existingTiers.Contains(p.Tier)).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 +476,19 @@ public static class PlatformDataSeeder
|
|||||||
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
|
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
|
||||||
};
|
};
|
||||||
|
|
||||||
db.PlatformFeatures.AddRange(features);
|
// Key carries the unique constraint, so dedupe on Key (not Id).
|
||||||
|
// IgnoreQueryFilters so a soft-deleted feature's Key is still counted.
|
||||||
|
var existingKeys = (await db.PlatformFeatures
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Select(f => f.Key)
|
||||||
|
.ToListAsync())
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var missing = features.Where(f => !existingKeys.Contains(f.Key)).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()
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Meezi.API.Middleware;
|
||||||
|
using Meezi.Core.Enums;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Data;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Meezi.API.Tests;
|
||||||
|
|
||||||
|
public class IdempotencyMiddlewareTests
|
||||||
|
{
|
||||||
|
private sealed class TestTenant(string? cafeId) : ITenantContext
|
||||||
|
{
|
||||||
|
public string? UserId => "user-1";
|
||||||
|
public string? CafeId => cafeId;
|
||||||
|
public EmployeeRole? Role => EmployeeRole.Owner;
|
||||||
|
public PlanTier? PlanTier => Core.Enums.PlanTier.Pro;
|
||||||
|
public string? Language => "fa";
|
||||||
|
public string? BranchId => null;
|
||||||
|
public bool IsSystemAdmin => false;
|
||||||
|
public bool IsAuthenticated => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A scope factory whose scopes share one in-memory database, mirroring how the
|
||||||
|
/// middleware opens isolated DI scopes against the same store in production.</summary>
|
||||||
|
private static IServiceScopeFactory BuildScopeFactory()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddDbContext<AppDbContext>(o => o.UseInMemoryDatabase(dbName));
|
||||||
|
services.AddLogging();
|
||||||
|
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DefaultHttpContext NewPost(string? key)
|
||||||
|
{
|
||||||
|
var ctx = new DefaultHttpContext();
|
||||||
|
ctx.Request.Method = "POST";
|
||||||
|
ctx.Request.Path = "/api/test";
|
||||||
|
if (key is not null) ctx.Request.Headers["Idempotency-Key"] = key;
|
||||||
|
ctx.Response.Body = new MemoryStream();
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadBody(HttpContext ctx)
|
||||||
|
{
|
||||||
|
ctx.Response.Body.Position = 0;
|
||||||
|
return new StreamReader(ctx.Response.Body).ReadToEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_ExecutesOnce_AndReplaysStoredResponse()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
await ctx.Response.WriteAsync($"{{\"v\":\"{Guid.NewGuid():N}\"}}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
var c1 = NewPost("KEY-1");
|
||||||
|
await mw.InvokeAsync(c1, tenant, scopeFactory);
|
||||||
|
var body1 = ReadBody(c1);
|
||||||
|
|
||||||
|
var c2 = NewPost("KEY-1");
|
||||||
|
await mw.InvokeAsync(c2, tenant, scopeFactory);
|
||||||
|
var body2 = ReadBody(c2);
|
||||||
|
|
||||||
|
Assert.Equal(1, calls); // executed exactly once
|
||||||
|
Assert.Equal(body1, body2); // second call replays the stored body verbatim
|
||||||
|
Assert.Equal(200, c2.Response.StatusCode);
|
||||||
|
Assert.Equal("true", c2.Response.Headers["Idempotent-Replay"].ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DifferentKey_ExecutesAgain()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
await ctx.Response.WriteAsync("{\"ok\":true}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost("A"), tenant, scopeFactory);
|
||||||
|
await mw.InvokeAsync(NewPost("B"), tenant, scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoKey_PassesThrough_NoIdempotency()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
|
||||||
|
await mw.InvokeAsync(NewPost(null), tenant, scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SameKey_DifferentTenant_IsNotReplayed()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 200;
|
||||||
|
await ctx.Response.WriteAsync("{\"ok\":true}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-A"), scopeFactory);
|
||||||
|
await mw.InvokeAsync(NewPost("SHARED"), new TestTenant("cafe-B"), scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls); // keys are scoped per café — no cross-tenant collision
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerError_IsNotCached_SoRetryReexecutes()
|
||||||
|
{
|
||||||
|
var scopeFactory = BuildScopeFactory();
|
||||||
|
var tenant = new TestTenant("cafe-1");
|
||||||
|
var calls = 0;
|
||||||
|
RequestDelegate next = async ctx =>
|
||||||
|
{
|
||||||
|
calls++;
|
||||||
|
ctx.Response.StatusCode = 500;
|
||||||
|
await ctx.Response.WriteAsync("{\"error\":\"boom\"}");
|
||||||
|
};
|
||||||
|
var mw = new IdempotencyMiddleware(next, NullLogger<IdempotencyMiddleware>.Instance);
|
||||||
|
|
||||||
|
await mw.InvokeAsync(NewPost("KEY-5XX"), tenant, scopeFactory);
|
||||||
|
var c2 = NewPost("KEY-5XX");
|
||||||
|
await mw.InvokeAsync(c2, tenant, scopeFactory);
|
||||||
|
|
||||||
|
Assert.Equal(2, calls); // 5xx is transient → reservation released, retry runs again
|
||||||
|
Assert.NotEqual("true", c2.Response.Headers["Idempotent-Replay"].ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,9 @@ 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<bool> DeleteAsync(string cafeId, string ingredientId, CancellationToken ct = default) =>
|
||||||
|
Task.FromResult(false);
|
||||||
|
|
||||||
public Task<IngredientDto?> AdjustAsync(string cafeId, string ingredientId, AdjustStockRequest request, string? userId, 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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
@@ -604,11 +605,18 @@ export function AdminIntegrationsScreen() {
|
|||||||
});
|
});
|
||||||
}, [data]);
|
}, [data]);
|
||||||
|
|
||||||
|
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
adminPut<PlatformIntegrations>("/api/admin/integrations", {
|
adminPut<PlatformIntegrations>("/api/admin/integrations", {
|
||||||
activePaymentGateway: activeGateway,
|
activePaymentGateway: activeGateway,
|
||||||
paymentGateways: gateways.map((g) => ({
|
// Save from `list` (what's rendered/edited), not `gateways` — if the
|
||||||
|
// gateways state hasn't hydrated, `list` falls back to the fetched data,
|
||||||
|
// and edits go through updateGateway which seeds it. This keeps the
|
||||||
|
// rendered, edited, and saved arrays the same source (was dropping
|
||||||
|
// edits like the Zarinpal merchantId when gateways was empty).
|
||||||
|
paymentGateways: list.map((g) => ({
|
||||||
id: g.id,
|
id: g.id,
|
||||||
isEnabled: g.isEnabled,
|
isEnabled: g.isEnabled,
|
||||||
merchantId: g.id === "zarinpal" ? g.merchantId : undefined,
|
merchantId: g.id === "zarinpal" ? g.merchantId : undefined,
|
||||||
@@ -637,11 +645,14 @@ export function AdminIntegrationsScreen() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => {
|
const updateGateway = (id: string, patch: Partial<PaymentGatewayConfig>) => {
|
||||||
setGateways((prev) => prev.map((g) => (g.id === id ? { ...g, ...patch } : g)));
|
setGateways((prev) => {
|
||||||
|
// Seed from fetched data on the first edit so an edit is never dropped
|
||||||
|
// because the state hadn't hydrated yet.
|
||||||
|
const base = prev.length > 0 ? prev : data?.paymentGateways?.map((g) => ({ ...g })) ?? [];
|
||||||
|
return base.map((g) => (g.id === id ? { ...g, ...patch } : g));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const list = gateways.length > 0 ? gateways : data?.paymentGateways ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ function BlogToggle({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
|
dir="ltr"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
"saved": "تم الحفظ",
|
"saved": "تم الحفظ",
|
||||||
"errorGeneric": "حدث خطأ. حاول مرة أخرى."
|
"errorGeneric": "حدث خطأ. حاول مرة أخرى."
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"planLimit": "وصلت إلى حد الخطة",
|
||||||
|
"notFound": "غير موجود",
|
||||||
|
"unauthorized": "غير مصرح",
|
||||||
|
"network": "خطأ في الاتصال",
|
||||||
|
"generic": "حدث خطأ. حاول مرة أخرى."
|
||||||
|
},
|
||||||
"brand": {
|
"brand": {
|
||||||
"name": "ميزي"
|
"name": "ميزي"
|
||||||
},
|
},
|
||||||
@@ -243,6 +250,7 @@
|
|||||||
"void": "إلغاء",
|
"void": "إلغاء",
|
||||||
"voidItem": "إلغاء الصنف",
|
"voidItem": "إلغاء الصنف",
|
||||||
"voided": "ملغى",
|
"voided": "ملغى",
|
||||||
|
"itemNotePlaceholder": "ملاحظة للمطبخ/البار (اختياري)",
|
||||||
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
|
"confirmVoid": "هل أنت متأكد أنك تريد إلغاء هذا الصنف؟",
|
||||||
"voidError": "تعذر إلغاء الصنف",
|
"voidError": "تعذر إلغاء الصنف",
|
||||||
"transferTable": "نقل الطاولة",
|
"transferTable": "نقل الطاولة",
|
||||||
@@ -372,7 +380,10 @@
|
|||||||
"duplicatePhone": "رقم الجوال مسجل مسبقاً.",
|
"duplicatePhone": "رقم الجوال مسجل مسبقاً.",
|
||||||
"generic": "تعذر الحفظ. حاول مرة أخرى."
|
"generic": "تعذر الحفظ. حاول مرة أخرى."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"deleted": "تم حذف العميل",
|
||||||
|
"deleteConfirmTitle": "حذف العميل",
|
||||||
|
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟"
|
||||||
},
|
},
|
||||||
"coupons": {
|
"coupons": {
|
||||||
"title": "القسائم",
|
"title": "القسائم",
|
||||||
@@ -388,7 +399,10 @@
|
|||||||
"FixedAmount": "مبلغ ثابت",
|
"FixedAmount": "مبلغ ثابت",
|
||||||
"FreeItem": "عنصر مجاني"
|
"FreeItem": "عنصر مجاني"
|
||||||
},
|
},
|
||||||
"noCoupons": "لا توجد قسائم"
|
"noCoupons": "لا توجد قسائم",
|
||||||
|
"deleted": "تم حذف القسيمة",
|
||||||
|
"deleteConfirmTitle": "حذف القسيمة",
|
||||||
|
"deleteConfirmDesc": "هل أنت متأكد من حذف القسيمة «{code}»؟"
|
||||||
},
|
},
|
||||||
"hr": {
|
"hr": {
|
||||||
"title": "الموارد البشرية",
|
"title": "الموارد البشرية",
|
||||||
@@ -735,7 +749,13 @@
|
|||||||
"addItemSuccess": "تمت إضافة الصنف",
|
"addItemSuccess": "تمت إضافة الصنف",
|
||||||
"updateItemSuccess": "تم تحديث الصنف",
|
"updateItemSuccess": "تم تحديث الصنف",
|
||||||
"addCategorySuccess": "تمت إضافة الفئة",
|
"addCategorySuccess": "تمت إضافة الفئة",
|
||||||
"updateCategorySuccess": "تم تحديث الفئة"
|
"updateCategorySuccess": "تم تحديث الفئة",
|
||||||
|
"deleteItemConfirmTitle": "حذف الصنف",
|
||||||
|
"deleteItemConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع عن هذا الإجراء.",
|
||||||
|
"deleteItemSuccess": "تم حذف الصنف",
|
||||||
|
"deleteCategoryConfirmTitle": "حذف الفئة",
|
||||||
|
"deleteCategoryConfirmDesc": "هل أنت متأكد من حذف الفئة «{name}»؟",
|
||||||
|
"deleteCategorySuccess": "تم حذف الفئة"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "قائمة الفرع",
|
"title": "قائمة الفرع",
|
||||||
@@ -829,7 +849,10 @@
|
|||||||
"purchasesThisMonth": "مشتريات المواد هذا الشهر",
|
"purchasesThisMonth": "مشتريات المواد هذا الشهر",
|
||||||
"purchaseCount": "{count} عملية شراء",
|
"purchaseCount": "{count} عملية شراء",
|
||||||
"viewInExpenses": "عرض في المصروفات",
|
"viewInExpenses": "عرض في المصروفات",
|
||||||
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع."
|
"selectBranchForPurchases": "اختر الفرع من الشريط العلوي لتسجيل مشتريات المستودع.",
|
||||||
|
"deleted": "تم حذف المادة",
|
||||||
|
"deleteConfirmTitle": "حذف المادة",
|
||||||
|
"deleteConfirmDesc": "هل أنت متأكد من حذف «{name}»؟ لا يمكن التراجع."
|
||||||
},
|
},
|
||||||
"qr": {
|
"qr": {
|
||||||
"brand": "ميزي",
|
"brand": "ميزي",
|
||||||
@@ -856,6 +879,7 @@
|
|||||||
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
|
"orderHint": "سيقوم الموظفون بتحضير طلبك قريباً",
|
||||||
"guestName": "اسمك (اختياري)",
|
"guestName": "اسمك (اختياري)",
|
||||||
"guestPhone": "الجوال (اختياري)",
|
"guestPhone": "الجوال (اختياري)",
|
||||||
|
"itemNote": "ملاحظة (مثلاً بدون طماطم، سكر أقل)",
|
||||||
"addMoreItems": "إضافة المزيد",
|
"addMoreItems": "إضافة المزيد",
|
||||||
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
|
"orderError": "تعذر تسجيل الطلب. حاول مرة أخرى.",
|
||||||
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
|
"rateLimited": "طلبات كثيرة — انتظر بضع دقائق",
|
||||||
@@ -943,7 +967,10 @@
|
|||||||
"Cancelled": "ملغى",
|
"Cancelled": "ملغى",
|
||||||
"Seated": "جالس",
|
"Seated": "جالس",
|
||||||
"Completed": "مكتمل"
|
"Completed": "مكتمل"
|
||||||
}
|
},
|
||||||
|
"deleted": "تم حذف الحجز",
|
||||||
|
"deleteConfirmTitle": "حذف الحجز",
|
||||||
|
"deleteConfirmDesc": "هل أنت متأكد من حذف حجز «{name}»؟"
|
||||||
},
|
},
|
||||||
"branchesPage": {
|
"branchesPage": {
|
||||||
"title": "الفروع",
|
"title": "الفروع",
|
||||||
@@ -1020,7 +1047,18 @@
|
|||||||
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
"secureNote": "تتم المعالجة عبر بوابة دفع بنكية آمنة.",
|
||||||
"payTotal": "ادفع {total}",
|
"payTotal": "ادفع {total}",
|
||||||
"redirecting": "جارٍ التحويل إلى البوابة...",
|
"redirecting": "جارٍ التحويل إلى البوابة...",
|
||||||
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى."
|
"paymentFailed": "فشل الدفع. الرجاء المحاولة مرة أخرى.",
|
||||||
|
"queuedNotice": "لديك اشتراك نشط بالفعل. ستتم إضافة هذا الشراء إلى قائمة الانتظار وسيبدأ في {date}."
|
||||||
|
},
|
||||||
|
"queued": {
|
||||||
|
"title": "الاشتراكات في قائمة الانتظار",
|
||||||
|
"subtitle": "تبدأ تلقائيًا عند انتهاء اشتراكك الحالي.",
|
||||||
|
"months": "{count} أشهر",
|
||||||
|
"window": "من {from} إلى {to}",
|
||||||
|
"cancel": "إلغاء",
|
||||||
|
"cancelled": "تم إلغاء الاشتراك في قائمة الانتظار",
|
||||||
|
"cancelConfirmTitle": "إلغاء الاشتراك المجدول",
|
||||||
|
"cancelConfirmDesc": "إلغاء اشتراك {plan} المقرر أن يبدأ في {from}؟ لن يتأثر اشتراكك الحالي."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -1359,12 +1397,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
|
||||||
"planLimit": "وصلت إلى حد الخطة",
|
|
||||||
"notFound": "غير موجود",
|
|
||||||
"unauthorized": "غير مصرح",
|
|
||||||
"network": "خطأ في الاتصال"
|
|
||||||
},
|
|
||||||
"discoverPublic": {
|
"discoverPublic": {
|
||||||
"brand": "ميزي",
|
"brand": "ميزي",
|
||||||
"title": "اكتشاف المقاهي",
|
"title": "اكتشاف المقاهي",
|
||||||
@@ -1511,5 +1543,9 @@
|
|||||||
"mid": "میانه",
|
"mid": "میانه",
|
||||||
"premium": "پریمیوم"
|
"premium": "پریمیوم"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"cafePublicProfile": {
|
||||||
|
"showOnKoja": "العرض على كوجا",
|
||||||
|
"showOnKojaHint": "إدراج مقهاك في دليل كوجا العام (koja.meezi.ir). مفعّل افتراضيًا."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"errorGeneric": "Something went wrong. Please try again."
|
"errorGeneric": "Something went wrong. Please try again."
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"planLimit": "Plan limit reached. Please upgrade.",
|
||||||
|
"notFound": "Not found",
|
||||||
|
"unauthorized": "Unauthorized",
|
||||||
|
"network": "Network error",
|
||||||
|
"generic": "Something went wrong. Please try again."
|
||||||
|
},
|
||||||
"brand": {
|
"brand": {
|
||||||
"name": "Meezi"
|
"name": "Meezi"
|
||||||
},
|
},
|
||||||
@@ -262,6 +269,7 @@
|
|||||||
"void": "Void",
|
"void": "Void",
|
||||||
"voidItem": "Void item",
|
"voidItem": "Void item",
|
||||||
"voided": "Voided",
|
"voided": "Voided",
|
||||||
|
"itemNotePlaceholder": "Note for kitchen/bar (optional)",
|
||||||
"confirmVoid": "Are you sure you want to void this item?",
|
"confirmVoid": "Are you sure you want to void this item?",
|
||||||
"voidError": "Could not void item",
|
"voidError": "Could not void item",
|
||||||
"transferTable": "Transfer table",
|
"transferTable": "Transfer table",
|
||||||
@@ -391,7 +399,10 @@
|
|||||||
"duplicatePhone": "This phone number is already registered.",
|
"duplicatePhone": "This phone number is already registered.",
|
||||||
"generic": "Could not save. Please try again."
|
"generic": "Could not save. Please try again."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"deleted": "Customer deleted",
|
||||||
|
"deleteConfirmTitle": "Delete customer",
|
||||||
|
"deleteConfirmDesc": "Delete “{name}”?"
|
||||||
},
|
},
|
||||||
"coupons": {
|
"coupons": {
|
||||||
"title": "Coupons",
|
"title": "Coupons",
|
||||||
@@ -407,7 +418,10 @@
|
|||||||
"FixedAmount": "Fixed amount",
|
"FixedAmount": "Fixed amount",
|
||||||
"FreeItem": "Free item"
|
"FreeItem": "Free item"
|
||||||
},
|
},
|
||||||
"noCoupons": "No coupons yet"
|
"noCoupons": "No coupons yet",
|
||||||
|
"deleted": "Coupon deleted",
|
||||||
|
"deleteConfirmTitle": "Delete coupon",
|
||||||
|
"deleteConfirmDesc": "Delete coupon “{code}”?"
|
||||||
},
|
},
|
||||||
"hr": {
|
"hr": {
|
||||||
"title": "Human resources",
|
"title": "Human resources",
|
||||||
@@ -778,7 +792,13 @@
|
|||||||
"addItemSuccess": "Item added",
|
"addItemSuccess": "Item added",
|
||||||
"updateItemSuccess": "Item updated",
|
"updateItemSuccess": "Item updated",
|
||||||
"addCategorySuccess": "Category added",
|
"addCategorySuccess": "Category added",
|
||||||
"updateCategorySuccess": "Category updated"
|
"updateCategorySuccess": "Category updated",
|
||||||
|
"deleteItemConfirmTitle": "Delete item",
|
||||||
|
"deleteItemConfirmDesc": "Are you sure you want to delete “{name}”? This can't be undone.",
|
||||||
|
"deleteItemSuccess": "Item deleted",
|
||||||
|
"deleteCategoryConfirmTitle": "Delete category",
|
||||||
|
"deleteCategoryConfirmDesc": "Are you sure you want to delete the “{name}” category?",
|
||||||
|
"deleteCategorySuccess": "Category deleted"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "Branch Menu",
|
"title": "Branch Menu",
|
||||||
@@ -898,7 +918,10 @@
|
|||||||
"purchasesThisMonth": "Material purchases this month",
|
"purchasesThisMonth": "Material purchases this month",
|
||||||
"purchaseCount": "{count} purchases",
|
"purchaseCount": "{count} purchases",
|
||||||
"viewInExpenses": "View in expenses",
|
"viewInExpenses": "View in expenses",
|
||||||
"selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases."
|
"selectBranchForPurchases": "Select a branch in the top bar to record warehouse purchases.",
|
||||||
|
"deleted": "Material deleted",
|
||||||
|
"deleteConfirmTitle": "Delete material",
|
||||||
|
"deleteConfirmDesc": "Delete “{name}”? This can’t be undone."
|
||||||
},
|
},
|
||||||
"qr": {
|
"qr": {
|
||||||
"brand": "Meezi",
|
"brand": "Meezi",
|
||||||
@@ -925,6 +948,7 @@
|
|||||||
"orderHint": "Staff will prepare your order shortly",
|
"orderHint": "Staff will prepare your order shortly",
|
||||||
"guestName": "Your name (optional)",
|
"guestName": "Your name (optional)",
|
||||||
"guestPhone": "Mobile (optional)",
|
"guestPhone": "Mobile (optional)",
|
||||||
|
"itemNote": "Note (e.g. no tomato, less sugar)",
|
||||||
"addMoreItems": "Add more items",
|
"addMoreItems": "Add more items",
|
||||||
"orderError": "Could not place order. Try again.",
|
"orderError": "Could not place order. Try again.",
|
||||||
"rateLimited": "Too many requests — please wait a few minutes",
|
"rateLimited": "Too many requests — please wait a few minutes",
|
||||||
@@ -1013,7 +1037,10 @@
|
|||||||
"Cancelled": "Cancelled",
|
"Cancelled": "Cancelled",
|
||||||
"Seated": "Seated",
|
"Seated": "Seated",
|
||||||
"Completed": "Completed"
|
"Completed": "Completed"
|
||||||
}
|
},
|
||||||
|
"deleted": "Reservation deleted",
|
||||||
|
"deleteConfirmTitle": "Delete reservation",
|
||||||
|
"deleteConfirmDesc": "Delete the reservation for “{name}”?"
|
||||||
},
|
},
|
||||||
"branchesPage": {
|
"branchesPage": {
|
||||||
"title": "Branches",
|
"title": "Branches",
|
||||||
@@ -1092,7 +1119,18 @@
|
|||||||
"secureNote": "Payment is processed through a secure bank gateway.",
|
"secureNote": "Payment is processed through a secure bank gateway.",
|
||||||
"payTotal": "Pay {total}",
|
"payTotal": "Pay {total}",
|
||||||
"redirecting": "Redirecting to gateway...",
|
"redirecting": "Redirecting to gateway...",
|
||||||
"paymentFailed": "Payment failed. Please try again."
|
"paymentFailed": "Payment failed. Please try again.",
|
||||||
|
"queuedNotice": "You already have an active subscription. This purchase will be queued and start on {date}."
|
||||||
|
},
|
||||||
|
"queued": {
|
||||||
|
"title": "Queued subscriptions",
|
||||||
|
"subtitle": "These start automatically when your current subscription ends.",
|
||||||
|
"months": "{count} months",
|
||||||
|
"window": "From {from} to {to}",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"cancelled": "Queued subscription cancelled",
|
||||||
|
"cancelConfirmTitle": "Cancel queued subscription",
|
||||||
|
"cancelConfirmDesc": "Cancel the {plan} subscription scheduled to start on {from}? Your current subscription is unaffected."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -1441,12 +1479,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
|
||||||
"planLimit": "Plan limit reached. Please upgrade.",
|
|
||||||
"notFound": "Not found",
|
|
||||||
"unauthorized": "Unauthorized",
|
|
||||||
"network": "Network error"
|
|
||||||
},
|
|
||||||
"discoverPublic": {
|
"discoverPublic": {
|
||||||
"brand": "Meezi",
|
"brand": "Meezi",
|
||||||
"title": "Discover cafés",
|
"title": "Discover cafés",
|
||||||
@@ -1551,7 +1583,9 @@
|
|||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saved": "Saved",
|
"saved": "Saved",
|
||||||
"saveFailed": "Save failed",
|
"saveFailed": "Save failed",
|
||||||
"loading": "Loading…"
|
"loading": "Loading…",
|
||||||
|
"showOnKoja": "Show on Koja",
|
||||||
|
"showOnKojaHint": "List your café in the public Koja directory (koja.meezi.ir). On by default."
|
||||||
},
|
},
|
||||||
"discoverProfile": {
|
"discoverProfile": {
|
||||||
"sections": {
|
"sections": {
|
||||||
|
|||||||
@@ -20,6 +20,13 @@
|
|||||||
"saved": "ذخیره شد",
|
"saved": "ذخیره شد",
|
||||||
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
|
"errorGeneric": "خطایی رخ داد. دوباره تلاش کنید."
|
||||||
},
|
},
|
||||||
|
"errors": {
|
||||||
|
"planLimit": "به سقف پلن رسیدهاید. برای ادامه ارتقا دهید",
|
||||||
|
"notFound": "یافت نشد",
|
||||||
|
"unauthorized": "دسترسی ندارید",
|
||||||
|
"network": "خطای ارتباط با سرور",
|
||||||
|
"generic": "خطایی رخ داد. دوباره تلاش کنید."
|
||||||
|
},
|
||||||
"brand": {
|
"brand": {
|
||||||
"name": "میزی"
|
"name": "میزی"
|
||||||
},
|
},
|
||||||
@@ -262,6 +269,7 @@
|
|||||||
"void": "ابطال",
|
"void": "ابطال",
|
||||||
"voidItem": "ابطال آیتم",
|
"voidItem": "ابطال آیتم",
|
||||||
"voided": "ابطال شده",
|
"voided": "ابطال شده",
|
||||||
|
"itemNotePlaceholder": "یادداشت برای آشپزخانه/بار (اختیاری)",
|
||||||
"confirmVoid": "آیا مطمئن هستید که میخواهید این آیتم را ابطال کنید؟",
|
"confirmVoid": "آیا مطمئن هستید که میخواهید این آیتم را ابطال کنید؟",
|
||||||
"voidError": "خطا در ابطال آیتم",
|
"voidError": "خطا در ابطال آیتم",
|
||||||
"transferTable": "انتقال میز",
|
"transferTable": "انتقال میز",
|
||||||
@@ -391,7 +399,10 @@
|
|||||||
"duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.",
|
"duplicatePhone": "این شماره موبایل قبلاً ثبت شده است.",
|
||||||
"generic": "ذخیره انجام نشد. دوباره تلاش کنید."
|
"generic": "ذخیره انجام نشد. دوباره تلاش کنید."
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"deleted": "مشتری حذف شد",
|
||||||
|
"deleteConfirmTitle": "حذف مشتری",
|
||||||
|
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟"
|
||||||
},
|
},
|
||||||
"coupons": {
|
"coupons": {
|
||||||
"title": "کوپنها",
|
"title": "کوپنها",
|
||||||
@@ -407,7 +418,10 @@
|
|||||||
"FixedAmount": "مبلغ ثابت",
|
"FixedAmount": "مبلغ ثابت",
|
||||||
"FreeItem": "آیتم رایگان"
|
"FreeItem": "آیتم رایگان"
|
||||||
},
|
},
|
||||||
"noCoupons": "کوپنی ثبت نشده"
|
"noCoupons": "کوپنی ثبت نشده",
|
||||||
|
"deleted": "کوپن حذف شد",
|
||||||
|
"deleteConfirmTitle": "حذف کوپن",
|
||||||
|
"deleteConfirmDesc": "آیا از حذف کوپن «{code}» مطمئن هستید؟"
|
||||||
},
|
},
|
||||||
"hr": {
|
"hr": {
|
||||||
"title": "منابع انسانی",
|
"title": "منابع انسانی",
|
||||||
@@ -778,7 +792,13 @@
|
|||||||
"addItemSuccess": "آیتم اضافه شد",
|
"addItemSuccess": "آیتم اضافه شد",
|
||||||
"updateItemSuccess": "آیتم بهروز شد",
|
"updateItemSuccess": "آیتم بهروز شد",
|
||||||
"addCategorySuccess": "دسته اضافه شد",
|
"addCategorySuccess": "دسته اضافه شد",
|
||||||
"updateCategorySuccess": "دسته بهروز شد"
|
"updateCategorySuccess": "دسته بهروز شد",
|
||||||
|
"deleteItemConfirmTitle": "حذف آیتم",
|
||||||
|
"deleteItemConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست.",
|
||||||
|
"deleteItemSuccess": "آیتم حذف شد",
|
||||||
|
"deleteCategoryConfirmTitle": "حذف دستهبندی",
|
||||||
|
"deleteCategoryConfirmDesc": "آیا از حذف دسته «{name}» مطمئن هستید؟",
|
||||||
|
"deleteCategorySuccess": "دسته حذف شد"
|
||||||
},
|
},
|
||||||
"branchMenu": {
|
"branchMenu": {
|
||||||
"title": "منوی شعبه",
|
"title": "منوی شعبه",
|
||||||
@@ -898,7 +918,10 @@
|
|||||||
"purchasesThisMonth": "خرید مواد این ماه",
|
"purchasesThisMonth": "خرید مواد این ماه",
|
||||||
"purchaseCount": "{count} خرید",
|
"purchaseCount": "{count} خرید",
|
||||||
"viewInExpenses": "مشاهده در هزینهها",
|
"viewInExpenses": "مشاهده در هزینهها",
|
||||||
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید."
|
"selectBranchForPurchases": "برای ثبت خرید انبار، ابتدا شعبه را از نوار بالا انتخاب کنید.",
|
||||||
|
"deleted": "ماده حذف شد",
|
||||||
|
"deleteConfirmTitle": "حذف ماده",
|
||||||
|
"deleteConfirmDesc": "آیا از حذف «{name}» مطمئن هستید؟ این عمل قابل بازگشت نیست."
|
||||||
},
|
},
|
||||||
"qr": {
|
"qr": {
|
||||||
"brand": "میزی",
|
"brand": "میزی",
|
||||||
@@ -925,6 +948,7 @@
|
|||||||
"orderHint": "کارکنان به زودی سفارش شما را آماده میکنند",
|
"orderHint": "کارکنان به زودی سفارش شما را آماده میکنند",
|
||||||
"guestName": "نام شما (اختیاری)",
|
"guestName": "نام شما (اختیاری)",
|
||||||
"guestPhone": "شماره موبایل (اختیاری)",
|
"guestPhone": "شماره موبایل (اختیاری)",
|
||||||
|
"itemNote": "یادداشت (مثلاً بدون گوجه، کمشکر)",
|
||||||
"addMoreItems": "افزودن آیتم دیگر",
|
"addMoreItems": "افزودن آیتم دیگر",
|
||||||
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
|
"orderError": "خطا در ثبت سفارش. دوباره امتحان کنید",
|
||||||
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
|
"orderSaveError": "سفارش ثبت شد اما ذخیره محلی ناموفق بود. صفحه را رفرش نکنید.",
|
||||||
@@ -1014,7 +1038,10 @@
|
|||||||
"Cancelled": "لغو شده",
|
"Cancelled": "لغو شده",
|
||||||
"Seated": "نشسته",
|
"Seated": "نشسته",
|
||||||
"Completed": "انجام شده"
|
"Completed": "انجام شده"
|
||||||
}
|
},
|
||||||
|
"deleted": "رزرو حذف شد",
|
||||||
|
"deleteConfirmTitle": "حذف رزرو",
|
||||||
|
"deleteConfirmDesc": "آیا از حذف رزرو «{name}» مطمئن هستید؟"
|
||||||
},
|
},
|
||||||
"branchesPage": {
|
"branchesPage": {
|
||||||
"title": "شعب",
|
"title": "شعب",
|
||||||
@@ -1093,7 +1120,18 @@
|
|||||||
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
"secureNote": "پرداخت از طریق درگاه امن بانکی انجام میشود.",
|
||||||
"payTotal": "پرداخت {total}",
|
"payTotal": "پرداخت {total}",
|
||||||
"redirecting": "در حال انتقال به درگاه...",
|
"redirecting": "در حال انتقال به درگاه...",
|
||||||
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید."
|
"paymentFailed": "پرداخت ناموفق بود. لطفاً دوباره امتحان کنید.",
|
||||||
|
"queuedNotice": "شما اشتراک فعالی دارید. این خرید در صف قرار میگیرد و از {date} آغاز میشود."
|
||||||
|
},
|
||||||
|
"queued": {
|
||||||
|
"title": "اشتراکهای در صف",
|
||||||
|
"subtitle": "این اشتراکها پس از پایان اشتراک فعلی بهصورت خودکار فعال میشوند.",
|
||||||
|
"months": "{count} ماه",
|
||||||
|
"window": "از {from} تا {to}",
|
||||||
|
"cancel": "لغو",
|
||||||
|
"cancelled": "اشتراک در صف لغو شد",
|
||||||
|
"cancelConfirmTitle": "لغو اشتراک در صف",
|
||||||
|
"cancelConfirmDesc": "اشتراک {plan} که قرار بود از {from} آغاز شود لغو شود؟ اشتراک فعلی شما دستنخورده میماند."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
@@ -1442,12 +1480,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
|
||||||
"planLimit": "به سقف پلن رسیدهاید. برای ادامه ارتقا دهید",
|
|
||||||
"notFound": "یافت نشد",
|
|
||||||
"unauthorized": "دسترسی ندارید",
|
|
||||||
"network": "خطای ارتباط با سرور"
|
|
||||||
},
|
|
||||||
"discoverPublic": {
|
"discoverPublic": {
|
||||||
"brand": "میزی",
|
"brand": "میزی",
|
||||||
"title": "کافهیاب",
|
"title": "کافهیاب",
|
||||||
@@ -1552,7 +1584,9 @@
|
|||||||
"save": "ذخیره",
|
"save": "ذخیره",
|
||||||
"saved": "ذخیره شد",
|
"saved": "ذخیره شد",
|
||||||
"saveFailed": "ذخیره ناموفق بود",
|
"saveFailed": "ذخیره ناموفق بود",
|
||||||
"loading": "در حال بارگذاری…"
|
"loading": "در حال بارگذاری…",
|
||||||
|
"showOnKoja": "نمایش در کوجا",
|
||||||
|
"showOnKojaHint": "کافه شما در فهرست عمومی کوجا (koja.meezi.ir) نمایش داده شود. پیشفرض روشن است."
|
||||||
},
|
},
|
||||||
"discoverProfile": {
|
"discoverProfile": {
|
||||||
"sections": {
|
"sections": {
|
||||||
|
|||||||
@@ -3,24 +3,29 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { apiGet, apiPost } from "@/lib/api/client";
|
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
|
||||||
import type { Coupon, CouponType } from "@/lib/api/types";
|
import type { Coupon, CouponType } from "@/lib/api/types";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
|
import { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
|
||||||
export function CouponsScreen() {
|
export function CouponsScreen() {
|
||||||
const t = useTranslations("coupons");
|
const t = useTranslations("coupons");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const apiError = useApiError();
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Coupon | null>(null);
|
||||||
const [code, setCode] = useState("");
|
const [code, setCode] = useState("");
|
||||||
const [type, setType] = useState<CouponType>("Percentage");
|
const [type, setType] = useState<CouponType>("Percentage");
|
||||||
const [value, setValue] = useState("10");
|
const [value, setValue] = useState("10");
|
||||||
@@ -47,6 +52,16 @@ export function CouponsScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteCoupon = useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/coupons/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["coupons", cafeId] });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
notify.success(t("deleted"));
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(apiError(err)),
|
||||||
|
});
|
||||||
|
|
||||||
if (!cafeId) return null;
|
if (!cafeId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,11 +147,34 @@ export function CouponsScreen() {
|
|||||||
{t("usage")}: {formatNumber(c.usedCount)}
|
{t("usage")}: {formatNumber(c.usedCount)}
|
||||||
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
|
{c.usageLimit ? ` / ${formatNumber(c.usageLimit)}` : ""}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() => setDeleteTarget(c)}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-1.5 size-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
title={t("deleteConfirmTitle")}
|
||||||
|
description={deleteTarget ? t("deleteConfirmDesc", { code: deleteTarget.code }) : undefined}
|
||||||
|
busy={deleteCoupon.isPending}
|
||||||
|
onConfirm={() => deleteTarget && deleteCoupon.mutate(deleteTarget.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Plus, Pencil, Search } from "lucide-react";
|
import { Plus, Pencil, Search, Trash2 } from "lucide-react";
|
||||||
import { apiGet } from "@/lib/api/client";
|
import { apiDelete, apiGet } from "@/lib/api/client";
|
||||||
import type { Customer } from "@/lib/api/types";
|
import type { Customer } from "@/lib/api/types";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
|
import { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
|
import { CustomerWizard, type CustomerWizardMode } from "@/components/crm/customer-wizard";
|
||||||
|
|
||||||
export function CrmScreen() {
|
export function CrmScreen() {
|
||||||
const t = useTranslations("crm");
|
const t = useTranslations("crm");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const apiError = useApiError();
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -26,6 +30,7 @@ export function CrmScreen() {
|
|||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
|
const [wizardMode, setWizardMode] = useState<CustomerWizardMode>("create");
|
||||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Customer | null>(null);
|
||||||
|
|
||||||
const { data: customers = [], isLoading } = useQuery({
|
const { data: customers = [], isLoading } = useQuery({
|
||||||
queryKey: ["customers", cafeId, debouncedSearch],
|
queryKey: ["customers", cafeId, debouncedSearch],
|
||||||
@@ -46,6 +51,16 @@ export function CrmScreen() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
|
queryClient.invalidateQueries({ queryKey: ["customers", cafeId] });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteCustomer = useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/customers/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
refreshCustomers();
|
||||||
|
setDeleteTarget(null);
|
||||||
|
notify.success(t("deleted"));
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(apiError(err)),
|
||||||
|
});
|
||||||
|
|
||||||
if (!cafeId) return null;
|
if (!cafeId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -104,21 +119,43 @@ export function CrmScreen() {
|
|||||||
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
|
{t("loyaltyPoints")}: {formatNumber(c.loyaltyPoints)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="flex-1"
|
||||||
onClick={() => openWizard("edit", c)}
|
onClick={() => openWizard("edit", c)}
|
||||||
>
|
>
|
||||||
<Pencil className="me-1 h-3.5 w-3.5" />
|
<Pencil className="me-1 h-3.5 w-3.5" />
|
||||||
{tCommon("edit")}
|
{tCommon("edit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
aria-label={tCommon("delete")}
|
||||||
|
onClick={() => setDeleteTarget(c)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
title={t("deleteConfirmTitle")}
|
||||||
|
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
|
||||||
|
busy={deleteCustomer.isPending}
|
||||||
|
onConfirm={() => deleteTarget && deleteCustomer.mutate(deleteTarget.id)}
|
||||||
|
/>
|
||||||
|
|
||||||
<CustomerWizard
|
<CustomerWizard
|
||||||
open={wizardOpen}
|
open={wizardOpen}
|
||||||
mode={wizardMode}
|
mode={wizardMode}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
updateCafePublicProfile,
|
updateCafePublicProfile,
|
||||||
uploadGalleryPhoto,
|
uploadGalleryPhoto,
|
||||||
type CafeProfileEdit,
|
type CafeProfileEdit,
|
||||||
|
type UpdateCafeProfilePayload,
|
||||||
} from "@/lib/api/cafe-public-profile";
|
} from "@/lib/api/cafe-public-profile";
|
||||||
import type { WorkingHours } from "@/lib/api/public-discover";
|
import type { WorkingHours } from "@/lib/api/public-discover";
|
||||||
import { resolveMediaUrl } from "@/lib/api/client";
|
import { resolveMediaUrl } from "@/lib/api/client";
|
||||||
@@ -42,6 +43,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
|||||||
const [instagram, setInstagram] = useState<string>("");
|
const [instagram, setInstagram] = useState<string>("");
|
||||||
const [website, setWebsite] = useState<string>("");
|
const [website, setWebsite] = useState<string>("");
|
||||||
const [hours, setHours] = useState<WorkingHours>(emptyHours());
|
const [hours, setHours] = useState<WorkingHours>(emptyHours());
|
||||||
|
const [showOnKoja, setShowOnKoja] = useState(true);
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
|
||||||
// Populate local state once we get server data
|
// Populate local state once we get server data
|
||||||
@@ -50,17 +52,20 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
|||||||
setInstagram(profile.instagramHandle ?? "");
|
setInstagram(profile.instagramHandle ?? "");
|
||||||
setWebsite(profile.websiteUrl ?? "");
|
setWebsite(profile.websiteUrl ?? "");
|
||||||
setHours(profile.workingHours ?? emptyHours());
|
setHours(profile.workingHours ?? emptyHours());
|
||||||
|
setShowOnKoja(profile.showOnKoja ?? true);
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Save info/social/hours ────────────────────────────────────────────────
|
// ── Save info/social/hours ────────────────────────────────────────────────
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (override?: Partial<UpdateCafeProfilePayload>) =>
|
||||||
updateCafePublicProfile(cafeId, {
|
updateCafePublicProfile(cafeId, {
|
||||||
description,
|
description,
|
||||||
instagramHandle: instagram || null,
|
instagramHandle: instagram || null,
|
||||||
websiteUrl: website || null,
|
websiteUrl: website || null,
|
||||||
workingHours: hours,
|
workingHours: hours,
|
||||||
|
showOnKoja,
|
||||||
|
...override,
|
||||||
}),
|
}),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
qc.setQueryData(["cafe-public-profile", cafeId], data);
|
qc.setQueryData(["cafe-public-profile", cafeId], data);
|
||||||
@@ -157,6 +162,23 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
|||||||
{tab === "info" && (
|
{tab === "info" && (
|
||||||
<Card className="rounded-xl border border-border/80">
|
<Card className="rounded-xl border border-border/80">
|
||||||
<CardContent className="space-y-4 p-4">
|
<CardContent className="space-y-4 p-4">
|
||||||
|
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-[#0F6E56]/25 bg-[#E1F5EE]/40 px-3 py-2.5">
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-medium">{t("showOnKoja")}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{t("showOnKojaHint")}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showOnKoja}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.checked;
|
||||||
|
setShowOnKoja(v);
|
||||||
|
// Persist immediately (pass the new value to avoid stale state).
|
||||||
|
saveMutation.mutate({ showOnKoja: v });
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 shrink-0 cursor-pointer accent-[#0F6E56]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t("description")}</Label>
|
<Label>{t("description")}</Label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -167,7 +189,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
|||||||
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
|
className="w-full resize-none rounded-lg border border-border/80 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#0F6E56]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -276,7 +298,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -307,7 +329,7 @@ export function CafePublicProfilePanel({ cafeId }: Props) {
|
|||||||
dir="ltr"
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate()} t={t} />
|
<SaveButton saving={saveMutation.isPending} saved={saved} onSave={() => saveMutation.mutate(undefined)} t={t} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||||
import { apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/lib/api/client";
|
||||||
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
|
import { InventoryUnitField } from "@/components/inventory/inventory-unit-field";
|
||||||
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";
|
||||||
@@ -19,6 +20,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
|
|
||||||
type Ingredient = {
|
type Ingredient = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -67,6 +69,7 @@ type PurchasesSummary = {
|
|||||||
export function InventoryScreen() {
|
export function InventoryScreen() {
|
||||||
const t = useTranslations("inventory");
|
const t = useTranslations("inventory");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
const apiError = useApiError();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
const numberLocale = locale === "en" ? "en-US" : "fa-IR";
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
@@ -95,6 +98,7 @@ export function InventoryScreen() {
|
|||||||
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
|
const [adjustQty, setAdjustQty] = useState<Record<string, string>>({});
|
||||||
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
|
const [adjustPaid, setAdjustPaid] = useState<Record<string, string>>({});
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Ingredient | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
const [editUnit, setEditUnit] = useState("گرم");
|
const [editUnit, setEditUnit] = useState("گرم");
|
||||||
const [editReorder, setEditReorder] = useState("0");
|
const [editReorder, setEditReorder] = useState("0");
|
||||||
@@ -198,6 +202,17 @@ export function InventoryScreen() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteIngredient = useMutation({
|
||||||
|
mutationFn: (id: string) =>
|
||||||
|
apiDelete(`/api/cafes/${cafeId}/inventory/ingredients/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["inventory", cafeId] });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
notify.success(t("deleted"));
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(apiError(err)),
|
||||||
|
});
|
||||||
|
|
||||||
const adjustStock = useMutation({
|
const adjustStock = useMutation({
|
||||||
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
|
mutationFn: ({ id, delta, paid }: { id: string; delta: number; paid?: number }) =>
|
||||||
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
|
apiPost(`/api/cafes/${cafeId}/inventory/ingredients/${id}/adjust`, {
|
||||||
@@ -478,6 +493,16 @@ export function InventoryScreen() {
|
|||||||
>
|
>
|
||||||
<Pencil className="size-4" />
|
<Pencil className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="size-8 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
aria-label={tCommon("delete")}
|
||||||
|
onClick={() => setDeleteTarget(ing)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-[#0F6E56]">
|
<p className="text-sm font-medium text-[#0F6E56]">
|
||||||
@@ -661,6 +686,17 @@ export function InventoryScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
title={t("deleteConfirmTitle")}
|
||||||
|
description={deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.name }) : undefined}
|
||||||
|
busy={deleteIngredient.isPending}
|
||||||
|
onConfirm={() => deleteTarget && deleteIngredient.mutate(deleteTarget.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,11 @@ export function KdsScreen() {
|
|||||||
<li key={item.id}>
|
<li key={item.id}>
|
||||||
{formatNumber(item.quantity, numberLocale)}×{" "}
|
{formatNumber(item.quantity, numberLocale)}×{" "}
|
||||||
{item.menuItemName}
|
{item.menuItemName}
|
||||||
|
{item.notes ? (
|
||||||
|
<span className="mt-0.5 block rounded bg-amber-50 px-1.5 py-0.5 text-[11px] font-medium text-amber-800">
|
||||||
|
✍️ {item.notes}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -193,6 +193,8 @@ export function BranchMenuOverrides({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
|
// Force LTR so the knob's translate-x stays inside the track in RTL.
|
||||||
|
dir="ltr"
|
||||||
aria-checked={row.isAvailable}
|
aria-checked={row.isAvailable}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
|
"relative inline-flex h-6 w-11 shrink-0 rounded-full border transition-colors",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { useIsRtl } from "@/lib/use-is-rtl";
|
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||||
import { Box, Pencil, Plus, Search, Video, X } from "lucide-react";
|
import { Box, Pencil, Plus, Search, Trash2, Video, X } from "lucide-react";
|
||||||
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
import { DemoDataBanner } from "@/components/demo/demo-data-banner";
|
||||||
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
import { Menu3dUpload } from "@/components/media/menu-3d-upload";
|
||||||
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
import { MenuAi3dGenerate } from "@/components/media/menu-ai-3d-generate";
|
||||||
@@ -12,8 +12,19 @@ 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 { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
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";
|
||||||
@@ -126,6 +137,9 @@ function ToggleSwitch({
|
|||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
type="button"
|
type="button"
|
||||||
|
// Force LTR so the knob's translate-x stays inside the track; in RTL the
|
||||||
|
// flex start sits on the right and translate-x-4 would push it out.
|
||||||
|
dir="ltr"
|
||||||
onClick={() => !disabled && onChange(!checked)}
|
onClick={() => !disabled && onChange(!checked)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
|
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors duration-200",
|
||||||
@@ -184,11 +198,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";
|
||||||
@@ -211,6 +222,11 @@ export function MenuAdminScreen() {
|
|||||||
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
|
const [editingCategory, setEditingCategory] = useState<MenuCategory | null>(null);
|
||||||
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
|
const [catForm, setCatForm] = useState<CatForm>(defaultCatForm);
|
||||||
|
|
||||||
|
// Delete confirmation (shared dialog for items + categories)
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<
|
||||||
|
{ kind: "item" | "category"; id: string; name: string } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
// ── Data queries ───────────────────────────────────────────────────────────
|
// ── Data queries ───────────────────────────────────────────────────────────
|
||||||
const { data: categories = [] } = useQuery({
|
const { data: categories = [] } = useQuery({
|
||||||
queryKey: ["menu-categories", cafeId],
|
queryKey: ["menu-categories", cafeId],
|
||||||
@@ -301,6 +317,30 @@ export function MenuAdminScreen() {
|
|||||||
onError: showError,
|
onError: showError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteItemMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/items/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setItemModalOpen(false);
|
||||||
|
notify.success(t("deleteItemSuccess"));
|
||||||
|
invalidateMenu();
|
||||||
|
},
|
||||||
|
onError: showError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCategoryMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/menu/categories/${id}`),
|
||||||
|
onSuccess: (_data, id) => {
|
||||||
|
setConfirmDelete(null);
|
||||||
|
setCatModalOpen(false);
|
||||||
|
// If the deleted category was selected, fall back to "all items".
|
||||||
|
setSelectedCategoryId((prev) => (prev === id ? "all" : prev));
|
||||||
|
notify.success(t("deleteCategorySuccess"));
|
||||||
|
invalidateMenu();
|
||||||
|
},
|
||||||
|
onError: showError,
|
||||||
|
});
|
||||||
|
|
||||||
const addCategoryMutation = useMutation({
|
const addCategoryMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
apiPost(`/api/cafes/${cafeId}/menu/categories`, {
|
||||||
@@ -893,11 +933,28 @@ export function MenuAdminScreen() {
|
|||||||
</LabeledField>
|
</LabeledField>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
|
||||||
|
{editingItem ? (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setItemModalOpen(false)}
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmDelete({
|
||||||
|
kind: "item",
|
||||||
|
id: editingItem.id,
|
||||||
|
name: editingItem.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
|
<Trash2 className="me-1.5 size-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setItemModalOpen(false)}>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -909,6 +966,7 @@ export function MenuAdminScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */}
|
{/* ── Category Add / Edit Modal ─────────────────────────────────────── */}
|
||||||
@@ -941,7 +999,27 @@ export function MenuAdminScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 border-t border-border pt-4">
|
<div className="flex items-center justify-between gap-2 border-t border-border pt-4">
|
||||||
|
{editingCategory ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() =>
|
||||||
|
setConfirmDelete({
|
||||||
|
kind: "category",
|
||||||
|
id: editingCategory.id,
|
||||||
|
name: editingCategory.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-1.5 size-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
|
<Button variant="ghost" onClick={() => setCatModalOpen(false)}>
|
||||||
{tCommon("cancel")}
|
{tCommon("cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -954,7 +1032,51 @@ export function MenuAdminScreen() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* ── Delete confirmation (items + categories) ──────────────────────── */}
|
||||||
|
<AlertDialog
|
||||||
|
open={!!confirmDelete}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{confirmDelete?.kind === "category"
|
||||||
|
? t("deleteCategoryConfirmTitle")
|
||||||
|
: t("deleteItemConfirmTitle")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{confirmDelete?.kind === "category"
|
||||||
|
? t("deleteCategoryConfirmDesc", { name: confirmDelete?.name ?? "" })
|
||||||
|
: t("deleteItemConfirmDesc", { name: confirmDelete?.name ?? "" })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
disabled={deleteItemMutation.isPending || deleteCategoryMutation.isPending}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // keep dialog open until the mutation resolves
|
||||||
|
if (!confirmDelete) return;
|
||||||
|
if (confirmDelete.kind === "category") {
|
||||||
|
deleteCategoryMutation.mutate(confirmDelete.id);
|
||||||
|
} else {
|
||||||
|
deleteItemMutation.mutate(confirmDelete.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleteItemMutation.isPending || deleteCategoryMutation.isPending
|
||||||
|
? t("saving")
|
||||||
|
: tCommon("delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export function PosScreen() {
|
|||||||
addItem,
|
addItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
updateQty,
|
updateQty,
|
||||||
|
setNotes,
|
||||||
couponCode,
|
couponCode,
|
||||||
appliedCoupon,
|
appliedCoupon,
|
||||||
setCouponCode,
|
setCouponCode,
|
||||||
@@ -1210,10 +1211,11 @@ export function PosScreen() {
|
|||||||
<div
|
<div
|
||||||
key={line.orderItemId ?? line.menuItem.id}
|
key={line.orderItemId ?? line.menuItem.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-2 rounded-lg border border-border p-2",
|
"flex flex-col gap-1.5 rounded-lg border border-border p-2",
|
||||||
line.isVoided && "opacity-60"
|
line.isVoided && "opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<MenuItemLabels
|
<MenuItemLabels
|
||||||
item={line.menuItem}
|
item={line.menuItem}
|
||||||
@@ -1292,6 +1294,18 @@ export function PosScreen() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!line.isVoided && (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={line.notes ?? ""}
|
||||||
|
onChange={(e) => setNotes(line.menuItem.id, e.target.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
placeholder={t("itemNotePlaceholder")}
|
||||||
|
className="w-full rounded-md border border-border/70 bg-background px-2 py-1 text-[11px] placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
import { ConfirmProvider } from "@/components/providers/confirm-provider";
|
||||||
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
import { MeeziToaster } from "@/components/ui/meezi-toaster";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
import { restoreQueryCache, startPersisting } from "@/lib/offline/query-persister";
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
() =>
|
() =>
|
||||||
new QueryClient({
|
new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { staleTime: 30_000, retry: 1 },
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
retry: 1,
|
||||||
|
// Keep data in memory long enough to back offline reads; it is also
|
||||||
|
// persisted to IndexedDB by the persister below.
|
||||||
|
gcTime: 24 * 60 * 60 * 1000,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Persist the query cache to IndexedDB so the dashboard is viewable offline.
|
||||||
|
// Scoped to the current café so a different tenant never hydrates this data.
|
||||||
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
|
useEffect(() => {
|
||||||
|
const scope = cafeId ?? "anon";
|
||||||
|
let active = true;
|
||||||
|
let stop: () => void = () => {};
|
||||||
|
void (async () => {
|
||||||
|
await restoreQueryCache(queryClient, scope);
|
||||||
|
if (!active) return;
|
||||||
|
stop = startPersisting(queryClient, scope);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
}, [queryClient, cafeId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
|
|||||||
@@ -407,8 +407,9 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
|||||||
{cart.map((c) => (
|
{cart.map((c) => (
|
||||||
<div
|
<div
|
||||||
key={c.item.id}
|
key={c.item.id}
|
||||||
className="flex items-center justify-between gap-3 border-b px-3 py-3 last:border-0"
|
className="flex flex-col gap-2 border-b px-3 py-3 last:border-0"
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
|
<MenuItemLabels item={c.item} lines={1} primaryClassName="text-sm" />
|
||||||
<p className="text-sm font-medium" style={{ color: primary }}>
|
<p className="text-sm font-medium" style={{ color: primary }}>
|
||||||
@@ -431,6 +432,20 @@ export function QrGuestMenu({ code }: QrGuestMenuProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={c.note ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCart((prev) =>
|
||||||
|
prev.map((l) =>
|
||||||
|
l.item.id === c.item.id ? { ...l, note: e.target.value } : l
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={t("itemNote")}
|
||||||
|
className="w-full rounded-md border qr-border bg-transparent px-2 py-1.5 text-xs placeholder:opacity-60 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-2">
|
<div className="mt-4 space-y-2">
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useState } from "react";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Link } from "@/i18n/routing";
|
import { Link } from "@/i18n/routing";
|
||||||
import { apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/client";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
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 { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -45,8 +49,11 @@ const statusStyle: Record<ReservationStatus, string> = {
|
|||||||
|
|
||||||
export function ReservationsScreen() {
|
export function ReservationsScreen() {
|
||||||
const t = useTranslations("reservations");
|
const t = useTranslations("reservations");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const apiError = useApiError();
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Reservation | null>(null);
|
||||||
|
|
||||||
const [guestName, setGuestName] = useState("");
|
const [guestName, setGuestName] = useState("");
|
||||||
const [guestPhone, setGuestPhone] = useState("09121234567");
|
const [guestPhone, setGuestPhone] = useState("09121234567");
|
||||||
@@ -92,6 +99,16 @@ export function ReservationsScreen() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteReservation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiDelete(`/api/cafes/${cafeId}/reservations/${id}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reservations", cafeId] });
|
||||||
|
setDeleteTarget(null);
|
||||||
|
notify.success(t("deleted"));
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(apiError(err)),
|
||||||
|
});
|
||||||
|
|
||||||
if (!cafeId) return null;
|
if (!cafeId) return null;
|
||||||
|
|
||||||
const posHref = (r: Reservation) => {
|
const posHref = (r: Reservation) => {
|
||||||
@@ -245,6 +262,15 @@ export function ReservationsScreen() {
|
|||||||
{t("markCompleted")}
|
{t("markCompleted")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
aria-label={tCommon("delete")}
|
||||||
|
onClick={() => setDeleteTarget(r)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -252,6 +278,19 @@ export function ReservationsScreen() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
title={t("deleteConfirmTitle")}
|
||||||
|
description={
|
||||||
|
deleteTarget ? t("deleteConfirmDesc", { name: deleteTarget.guestName }) : undefined
|
||||||
|
}
|
||||||
|
busy={deleteReservation.isPending}
|
||||||
|
onConfirm={() => deleteTarget && deleteReservation.mutate(deleteTarget.id)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -66,6 +68,37 @@ export function CheckoutScreen() {
|
|||||||
enabled: !!cafeId && isCafeOwner(role),
|
enabled: !!cafeId && isCafeOwner(role),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If the owner is still covered (active plan and/or queued plans), this purchase will be
|
||||||
|
// queued to start when the current coverage ends rather than activating immediately.
|
||||||
|
const { data: billingStatus } = useQuery({
|
||||||
|
queryKey: ["billing-status", cafeId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiGet<{
|
||||||
|
planTier: string;
|
||||||
|
planExpiresAt: string | null;
|
||||||
|
isPlanExpired: boolean;
|
||||||
|
queuedPlans: { effectiveTo: string }[];
|
||||||
|
}>("/api/billing/status"),
|
||||||
|
enabled: !!cafeId && isCafeOwner(role),
|
||||||
|
});
|
||||||
|
|
||||||
|
const coverageEnd = useMemo(() => {
|
||||||
|
if (!billingStatus) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
let end = 0;
|
||||||
|
if (
|
||||||
|
billingStatus.planTier !== "Free" &&
|
||||||
|
billingStatus.planExpiresAt &&
|
||||||
|
!billingStatus.isPlanExpired
|
||||||
|
) {
|
||||||
|
end = Math.max(end, new Date(billingStatus.planExpiresAt).getTime());
|
||||||
|
}
|
||||||
|
for (const q of billingStatus.queuedPlans ?? []) {
|
||||||
|
end = Math.max(end, new Date(q.effectiveTo).getTime());
|
||||||
|
}
|
||||||
|
return end > now ? new Date(end) : null;
|
||||||
|
}, [billingStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!paymentMethod && paymentMethods.length > 0) {
|
if (!paymentMethod && paymentMethods.length > 0) {
|
||||||
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
const def = paymentMethods.find((m) => m.isDefault) ?? paymentMethods[0];
|
||||||
@@ -81,8 +114,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"));
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,6 +171,13 @@ export function CheckoutScreen() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{coverageEnd ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/50 px-4 py-3 text-sm text-[#0F6E56]">
|
||||||
|
<ShieldCheck className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
|
||||||
|
<p>{tc("queuedNotice", { date: coverageEnd.toLocaleDateString(numberLocale) })}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Factor / invoice */}
|
{/* Factor / invoice */}
|
||||||
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
|
<Card className="overflow-hidden rounded-xl border border-border/80 shadow-sm">
|
||||||
{/* Invoice header */}
|
{/* Invoice header */}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CalendarClock, Trash2 } from "lucide-react";
|
||||||
import { useRouter } from "@/i18n/routing";
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { apiGet, apiPost } from "@/lib/api/client";
|
import { apiDelete, apiGet, apiPost } from "@/lib/api/client";
|
||||||
import { isCafeOwner } from "@/lib/auth-permissions";
|
import { isCafeOwner } from "@/lib/auth-permissions";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
@@ -14,9 +15,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { PageHeader } from "@/components/layout/page-header";
|
import { PageHeader } from "@/components/layout/page-header";
|
||||||
import { PlanComparison } from "@/components/settings/plan-comparison";
|
import { PlanComparison } from "@/components/settings/plan-comparison";
|
||||||
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
import type { AuthTokenResponse } from "@/lib/api/types";
|
import type { AuthTokenResponse } from "@/lib/api/types";
|
||||||
import { Alert } from "@/components/ui/alert";
|
import { Alert } from "@/components/ui/alert";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { useApiError } from "@/lib/use-api-error";
|
||||||
|
|
||||||
|
type QueuedPlan = {
|
||||||
|
paymentId: string;
|
||||||
|
planTier: string;
|
||||||
|
months: number;
|
||||||
|
effectiveFrom: string;
|
||||||
|
effectiveTo: string;
|
||||||
|
amountToman: number;
|
||||||
|
};
|
||||||
|
|
||||||
type BillingStatus = {
|
type BillingStatus = {
|
||||||
planTier: string;
|
planTier: string;
|
||||||
@@ -30,6 +42,7 @@ type BillingStatus = {
|
|||||||
menu3dEnabled: boolean;
|
menu3dEnabled: boolean;
|
||||||
discoverProfileEnabled: boolean;
|
discoverProfileEnabled: boolean;
|
||||||
isPlanExpired: boolean;
|
isPlanExpired: boolean;
|
||||||
|
queuedPlans: QueuedPlan[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SubscriptionScreen() {
|
export function SubscriptionScreen() {
|
||||||
@@ -40,8 +53,11 @@ export function SubscriptionScreen() {
|
|||||||
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 setAuth = useAuthStore((s) => s.setAuth);
|
const setAuth = useAuthStore((s) => s.setAuth);
|
||||||
|
const apiError = useApiError();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const billingRefreshed = useRef(false);
|
const billingRefreshed = useRef(false);
|
||||||
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
const [billingBanner, setBillingBanner] = useState<"success" | "failed" | null>(null);
|
||||||
|
const [cancelTarget, setCancelTarget] = useState<QueuedPlan | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const billing = searchParams.get("billing");
|
const billing = searchParams.get("billing");
|
||||||
@@ -61,6 +77,18 @@ export function SubscriptionScreen() {
|
|||||||
enabled: !!cafeId,
|
enabled: !!cafeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cancelQueued = useMutation({
|
||||||
|
mutationFn: (paymentId: string) => apiDelete(`/api/billing/queued/${paymentId}`),
|
||||||
|
onSuccess: () => {
|
||||||
|
setCancelTarget(null);
|
||||||
|
notify.success(t("queued.cancelled"));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["billing-status", cafeId] });
|
||||||
|
},
|
||||||
|
onError: (err) => notify.error(apiError(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fmtDate = (iso: string) => new Date(iso).toLocaleDateString("fa-IR");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
if (searchParams.get("billing") !== "success" || billingRefreshed.current) return;
|
||||||
const refresh = localStorage.getItem("meezi_refresh_token");
|
const refresh = localStorage.getItem("meezi_refresh_token");
|
||||||
@@ -155,12 +183,72 @@ export function SubscriptionScreen() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{status?.queuedPlans && status.queuedPlans.length > 0 ? (
|
||||||
|
<Card className="rounded-xl border border-[#0F6E56]/30 bg-[#E1F5EE]/30 shadow-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CalendarClock className="size-4 text-[#0F6E56]" aria-hidden />
|
||||||
|
{t("queued.title")}
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{t("queued.subtitle")}</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{status.queuedPlans.map((q) => (
|
||||||
|
<div
|
||||||
|
key={q.paymentId}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border/70 bg-card px-3 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge>{q.planTier}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t("queued.months", { count: formatNumber(q.months) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{t("queued.window", { from: fmtDate(q.effectiveFrom), to: fmtDate(q.effectiveTo) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
onClick={() => setCancelTarget(q)}
|
||||||
|
>
|
||||||
|
<Trash2 className="me-1.5 size-4" />
|
||||||
|
{t("queued.cancel")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<PlanComparison
|
<PlanComparison
|
||||||
currentPlan={status?.planTier ?? "Free"}
|
currentPlan={status?.planTier ?? "Free"}
|
||||||
onSubscribe={(planTier) =>
|
onSubscribe={(planTier) =>
|
||||||
router.push(`/subscription/checkout?plan=${planTier}`)
|
router.push(`/subscription/checkout?plan=${planTier}`)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!cancelTarget}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setCancelTarget(null);
|
||||||
|
}}
|
||||||
|
title={t("queued.cancelConfirmTitle")}
|
||||||
|
description={
|
||||||
|
cancelTarget
|
||||||
|
? t("queued.cancelConfirmDesc", {
|
||||||
|
plan: cancelTarget.planTier,
|
||||||
|
from: fmtDate(cancelTarget.effectiveFrom),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
confirmLabel={t("queued.cancel")}
|
||||||
|
busy={cancelQueued.isPending}
|
||||||
|
onConfirm={() => cancelTarget && cancelQueued.mutate(cancelTarget.paymentId)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useIsRtl } from "@/lib/use-is-rtl";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared confirmation dialog (used for destructive delete actions across screens).
|
||||||
|
* Keeps the dialog open while `busy` so the row stays until the mutation resolves;
|
||||||
|
* the caller closes it via onOpenChange(false) on success.
|
||||||
|
*/
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
onConfirm,
|
||||||
|
busy = false,
|
||||||
|
destructive = true,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
busy?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
}) {
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const isRtl = useIsRtl();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent dir={isRtl ? "rtl" : "ltr"}>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
{description ? (
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
) : null}
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className={destructive ? "bg-red-600 text-white hover:bg-red-700" : ""}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault(); // keep open until the mutation resolves
|
||||||
|
onConfirm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? tCommon("loading") : confirmLabel ?? tCommon("delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 } =
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type CafeProfileEdit = {
|
|||||||
instagramHandle: string | null;
|
instagramHandle: string | null;
|
||||||
websiteUrl: string | null;
|
websiteUrl: string | null;
|
||||||
workingHours: WorkingHours | null;
|
workingHours: WorkingHours | null;
|
||||||
|
showOnKoja: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UpdateCafeProfilePayload = {
|
export type UpdateCafeProfilePayload = {
|
||||||
@@ -15,6 +16,7 @@ export type UpdateCafeProfilePayload = {
|
|||||||
instagramHandle?: string | null;
|
instagramHandle?: string | null;
|
||||||
websiteUrl?: string | null;
|
websiteUrl?: string | null;
|
||||||
workingHours?: WorkingHours | null;
|
workingHours?: WorkingHours | null;
|
||||||
|
showOnKoja?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
|
async function unwrap<T>(promise: Promise<{ data: ApiResponse<T> }>): Promise<T> {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ api.interceptors.response.use(
|
|||||||
|
|
||||||
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, undefined, status));
|
||||||
}
|
}
|
||||||
if (status === 401 && typeof window !== "undefined") {
|
if (status === 401 && typeof window !== "undefined") {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -131,15 +131,29 @@ export class ApiClientError extends Error {
|
|||||||
public readonly code: string,
|
public readonly code: string,
|
||||||
message: string,
|
message: string,
|
||||||
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
|
/** Payload returned alongside a non-success response (e.g. CHOOSE_CAFE choices). */
|
||||||
public readonly payload?: unknown
|
public readonly payload?: unknown,
|
||||||
|
/** HTTP status, when known — lets callers (e.g. the outbox) tell 5xx (retry) from 4xx (give up). */
|
||||||
|
public readonly status?: number
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = "ApiClientError";
|
this.name = "ApiClientError";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T> {
|
/** Options for mutating requests. An `idempotencyKey` is sent as the
|
||||||
const { data } = await api.post<ApiResponse<T>>(url, body);
|
* `Idempotency-Key` header so the server safely de-duplicates retries
|
||||||
|
* (used by the offline outbox; harmless when omitted). */
|
||||||
|
export interface WriteOptions {
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeConfig(opts?: WriteOptions) {
|
||||||
|
if (!opts?.idempotencyKey) return undefined;
|
||||||
|
return { headers: { "Idempotency-Key": opts.idempotencyKey } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiPost<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||||
|
const { data } = await api.post<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||||
if (!data.success || data.data === undefined) {
|
if (!data.success || data.data === undefined) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
|
throw new ApiClientError(code, data.error?.message ?? "Request failed", data.data);
|
||||||
@@ -147,8 +161,8 @@ export async function apiPost<T, B = unknown>(url: string, body?: B): Promise<T>
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T> {
|
export async function apiPut<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||||
const { data } = await api.put<ApiResponse<T>>(url, body);
|
const { data } = await api.put<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||||
if (!data.success || data.data === undefined) {
|
if (!data.success || data.data === undefined) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||||
@@ -156,8 +170,8 @@ export async function apiPut<T, B = unknown>(url: string, body?: B): Promise<T>
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T> {
|
export async function apiPatch<T, B = unknown>(url: string, body?: B, opts?: WriteOptions): Promise<T> {
|
||||||
const { data } = await api.patch<ApiResponse<T>>(url, body);
|
const { data } = await api.patch<ApiResponse<T>>(url, body, writeConfig(opts));
|
||||||
if (!data.success || data.data === undefined) {
|
if (!data.success || data.data === undefined) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||||
@@ -165,8 +179,8 @@ export async function apiPatch<T, B = unknown>(url: string, body?: B): Promise<T
|
|||||||
return data.data;
|
return data.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDelete(url: string): Promise<void> {
|
export async function apiDelete(url: string, opts?: WriteOptions): Promise<void> {
|
||||||
const { data } = await api.delete<ApiResponse<unknown>>(url);
|
const { data } = await api.delete<ApiResponse<unknown>>(url, writeConfig(opts));
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
const code = data.error?.code ?? "REQUEST_FAILED";
|
const code = data.error?.code ?? "REQUEST_FAILED";
|
||||||
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
throw new ApiClientError(code, data.error?.message ?? "Request failed");
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ export type OfflineQueueItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DB_NAME = "meezi_pos_offline";
|
const DB_NAME = "meezi_pos_offline";
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 3;
|
||||||
|
/** Legacy POS-orders-only queue (kept for one-time migration into the outbox). */
|
||||||
const STORE = "order_queue";
|
const STORE = "order_queue";
|
||||||
|
/** Generic key-value store (used to persist the React Query cache for offline reads). */
|
||||||
|
const KV_STORE = "kv";
|
||||||
|
/** Generic write outbox: any mutating request, replayed with idempotency + id remap. */
|
||||||
|
const OUTBOX_STORE = "outbox";
|
||||||
|
|
||||||
let _db: IDBDatabase | null = null;
|
let _db: IDBDatabase | null = null;
|
||||||
|
|
||||||
@@ -36,6 +41,12 @@ function openDb(): Promise<IDBDatabase> {
|
|||||||
if (!db.objectStoreNames.contains(STORE)) {
|
if (!db.objectStoreNames.contains(STORE)) {
|
||||||
db.createObjectStore(STORE, { keyPath: "id" });
|
db.createObjectStore(STORE, { keyPath: "id" });
|
||||||
}
|
}
|
||||||
|
if (!db.objectStoreNames.contains(KV_STORE)) {
|
||||||
|
db.createObjectStore(KV_STORE);
|
||||||
|
}
|
||||||
|
if (!db.objectStoreNames.contains(OUTBOX_STORE)) {
|
||||||
|
db.createObjectStore(OUTBOX_STORE, { keyPath: "id" });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
req.onsuccess = () => {
|
req.onsuccess = () => {
|
||||||
_db = req.result;
|
_db = req.result;
|
||||||
@@ -109,3 +120,159 @@ export async function markQueueItemFailed(id: string): Promise<void> {
|
|||||||
tx.onerror = () => reject(tx.error);
|
tx.onerror = () => reject(tx.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Generic key-value store (React Query cache persistence) ───────────────────
|
||||||
|
|
||||||
|
/** Store an arbitrary JSON-serializable value under a key. Never throws. */
|
||||||
|
export async function kvSet(key: string, value: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(KV_STORE, "readwrite");
|
||||||
|
tx.objectStore(KV_STORE).put(value, key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// IndexedDB unavailable / quota exceeded / blocked — degrade silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a value previously stored with {@link kvSet}. Returns undefined on any failure. */
|
||||||
|
export async function kvGet<T>(key: string): Promise<T | undefined> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
return await new Promise<T | undefined>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(KV_STORE, "readonly");
|
||||||
|
const req = tx.objectStore(KV_STORE).get(key);
|
||||||
|
req.onsuccess = () => resolve(req.result as T | undefined);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a persisted value (e.g. on logout, to avoid leaking another user's cache). */
|
||||||
|
export async function kvDelete(key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(KV_STORE, "readwrite");
|
||||||
|
tx.objectStore(KV_STORE).delete(key);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Generic write outbox ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type OutboxMethod = "POST" | "PUT" | "PATCH" | "DELETE";
|
||||||
|
|
||||||
|
export type OutboxOp = {
|
||||||
|
/** Local op id (primary key). */
|
||||||
|
id: string;
|
||||||
|
/** Stable Idempotency-Key sent on every send attempt for this op. */
|
||||||
|
idempotencyKey: string;
|
||||||
|
method: OutboxMethod;
|
||||||
|
/** Request URL; may embed a local id (local_*) to be remapped after its creator syncs. */
|
||||||
|
url: string;
|
||||||
|
body?: unknown;
|
||||||
|
/** Coarse entity kind, for conflict policy + UI grouping (e.g. "order", "menu_item"). */
|
||||||
|
entityType: string;
|
||||||
|
/** The local id this op creates, if any — enables remapping later ops that reference it. */
|
||||||
|
createsClientId?: string;
|
||||||
|
/** Dotted path to the new server id in the response data (default "id"). */
|
||||||
|
idField?: string;
|
||||||
|
createdAt: number;
|
||||||
|
attempts: number;
|
||||||
|
status: "pending" | "failed";
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function enqueueOutboxOp(
|
||||||
|
op: Omit<OutboxOp, "attempts" | "status">
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readwrite");
|
||||||
|
tx.objectStore(OUTBOX_STORE).put({ ...op, attempts: 0, status: "pending" });
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All queued ops, oldest first (insertion / causal order). */
|
||||||
|
export async function getOutboxOps(): Promise<OutboxOp[]> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
const ops = await new Promise<OutboxOp[]>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readonly");
|
||||||
|
const req = tx.objectStore(OUTBOX_STORE).getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result as OutboxOp[]);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
return ops.sort((a, b) => a.createdAt - b.createdAt);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOutboxCount(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
return await new Promise<number>((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readonly");
|
||||||
|
const req = tx.objectStore(OUTBOX_STORE).count();
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeOutboxOp(id: string): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readwrite");
|
||||||
|
tx.objectStore(OUTBOX_STORE).delete(id);
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOutboxOp(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<OutboxOp, "status" | "attempts" | "lastError">>
|
||||||
|
): Promise<void> {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(OUTBOX_STORE, "readwrite");
|
||||||
|
const store = tx.objectStore(OUTBOX_STORE);
|
||||||
|
const getReq = store.get(id);
|
||||||
|
getReq.onsuccess = () => {
|
||||||
|
const op = getReq.result as OutboxOp | undefined;
|
||||||
|
if (op) store.put({ ...op, ...patch });
|
||||||
|
};
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── client→server id map (persisted across reloads) ───────────────────────────
|
||||||
|
|
||||||
|
const ID_MAP_KEY = "outbox_id_map";
|
||||||
|
|
||||||
|
export async function getIdMap(): Promise<Record<string, string>> {
|
||||||
|
return (await kvGet<Record<string, string>>(ID_MAP_KEY)) ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setIdMapEntry(clientId: string, serverId: string): Promise<void> {
|
||||||
|
const map = await getIdMap();
|
||||||
|
map[clientId] = serverId;
|
||||||
|
await kvSet(ID_MAP_KEY, map);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Generic offline write engine.
|
||||||
|
*
|
||||||
|
* Every offline write is recorded as an {@link OutboxOp} carrying a stable
|
||||||
|
* idempotency key. On reconnect the outbox is drained in causal (insertion)
|
||||||
|
* order:
|
||||||
|
* - local ids (local_*) created by earlier ops are remapped to their real
|
||||||
|
* server ids before an op that references them is sent;
|
||||||
|
* - each op is sent with its idempotency key, so a replay after a lost response
|
||||||
|
* is de-duplicated by the server instead of creating a duplicate;
|
||||||
|
* - failures are classified: offline → stop; server 5xx / in-progress →
|
||||||
|
* retry next pass; client 4xx → count an attempt and poison after MAX.
|
||||||
|
*/
|
||||||
|
import { isAxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
apiDelete,
|
||||||
|
apiPatch,
|
||||||
|
apiPost,
|
||||||
|
apiPut,
|
||||||
|
ApiClientError,
|
||||||
|
type WriteOptions,
|
||||||
|
} from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
getIdMap,
|
||||||
|
getOutboxOps,
|
||||||
|
removeOutboxOp,
|
||||||
|
setIdMapEntry,
|
||||||
|
updateOutboxOp,
|
||||||
|
type OutboxOp,
|
||||||
|
} from "@/lib/offline/offline-db";
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
/** Matches local placeholder ids like `local_1717…_a1b2c3`. */
|
||||||
|
const LOCAL_ID_RE = /local_[A-Za-z0-9]+(?:_[A-Za-z0-9]+)*/g;
|
||||||
|
|
||||||
|
function getByPath(obj: unknown, path: string): string | undefined {
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (const part of path.split(".")) {
|
||||||
|
if (cur == null || typeof cur !== "object") return undefined;
|
||||||
|
cur = (cur as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return typeof cur === "string" ? cur : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace known local ids in the op's url/body with their server ids. Returns
|
||||||
|
* `blocked: true` if it still references an unresolved local id (its creator
|
||||||
|
* hasn't synced yet) other than the id this op itself creates.
|
||||||
|
*/
|
||||||
|
export function remapReferences(
|
||||||
|
op: Pick<OutboxOp, "url" | "body" | "createsClientId">,
|
||||||
|
idMap: Record<string, string>
|
||||||
|
): { url: string; body: unknown; blocked: boolean } {
|
||||||
|
let url = op.url;
|
||||||
|
let bodyStr = op.body !== undefined ? JSON.stringify(op.body) : "";
|
||||||
|
|
||||||
|
for (const [clientId, serverId] of Object.entries(idMap)) {
|
||||||
|
if (url.includes(clientId)) url = url.split(clientId).join(serverId);
|
||||||
|
if (bodyStr && bodyStr.includes(clientId)) bodyStr = bodyStr.split(clientId).join(serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = `${url} ${bodyStr}`.match(LOCAL_ID_RE) ?? [];
|
||||||
|
const unresolved = remaining.filter((id) => id !== op.createsClientId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
body: bodyStr !== "" ? JSON.parse(bodyStr) : op.body,
|
||||||
|
blocked: unresolved.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendOp(op: OutboxOp, url: string, body: unknown): Promise<unknown> {
|
||||||
|
const opts: WriteOptions = { idempotencyKey: op.idempotencyKey };
|
||||||
|
switch (op.method) {
|
||||||
|
case "POST":
|
||||||
|
return apiPost(url, body, opts);
|
||||||
|
case "PUT":
|
||||||
|
return apiPut(url, body, opts);
|
||||||
|
case "PATCH":
|
||||||
|
return apiPatch(url, body, opts);
|
||||||
|
case "DELETE":
|
||||||
|
await apiDelete(url, opts);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Disposition = "offline" | "transient" | "permanent";
|
||||||
|
|
||||||
|
function classify(err: unknown): Disposition {
|
||||||
|
if (err instanceof ApiClientError) {
|
||||||
|
if (err.code === "IDEMPOTENCY_IN_PROGRESS") return "transient"; // another tab/pass owns it
|
||||||
|
if (err.status !== undefined && err.status >= 500) return "transient";
|
||||||
|
return "permanent"; // validation / 4xx — retrying the same payload won't help
|
||||||
|
}
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
if (!err.response) return "offline"; // network down
|
||||||
|
if (err.response.status >= 500) return "transient";
|
||||||
|
return "permanent";
|
||||||
|
}
|
||||||
|
return "permanent";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMessage(err: unknown): string {
|
||||||
|
if (err instanceof Error) return err.message;
|
||||||
|
return String(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DrainResult = { sent: number; remaining: number; ran: boolean };
|
||||||
|
|
||||||
|
let draining = false;
|
||||||
|
|
||||||
|
/** Drain the outbox once, in causal order. Never throws. */
|
||||||
|
export async function drainOutbox(): Promise<DrainResult> {
|
||||||
|
const isOffline = typeof navigator !== "undefined" && !navigator.onLine;
|
||||||
|
if (draining || isOffline) {
|
||||||
|
return { sent: 0, remaining: (await getOutboxOps()).length, ran: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
draining = true;
|
||||||
|
let sent = 0;
|
||||||
|
try {
|
||||||
|
const idMap = await getIdMap();
|
||||||
|
const ops = await getOutboxOps();
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.status === "failed" && op.attempts >= MAX_ATTEMPTS) continue; // poisoned
|
||||||
|
|
||||||
|
const { url, body, blocked } = remapReferences(op, idMap);
|
||||||
|
if (blocked) continue; // a dependency hasn't synced yet; revisit next pass
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendOp(op, url, body);
|
||||||
|
if (op.createsClientId) {
|
||||||
|
const serverId = getByPath(result, op.idField ?? "id");
|
||||||
|
if (serverId) {
|
||||||
|
idMap[op.createsClientId] = serverId;
|
||||||
|
await setIdMapEntry(op.createsClientId, serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await removeOutboxOp(op.id);
|
||||||
|
sent++;
|
||||||
|
} catch (err) {
|
||||||
|
const disposition = classify(err);
|
||||||
|
if (disposition === "offline") break; // stop the whole pass; resume when online
|
||||||
|
if (disposition === "transient") {
|
||||||
|
await updateOutboxOp(op.id, { lastError: errMessage(err) }); // retry, don't burn an attempt
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await updateOutboxOp(op.id, {
|
||||||
|
status: "failed",
|
||||||
|
attempts: op.attempts + 1,
|
||||||
|
lastError: errMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
draining = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sent, remaining: (await getOutboxOps()).length, ran: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ops that exhausted their retries and need user attention. */
|
||||||
|
export async function getPoisonedOps(): Promise<OutboxOp[]> {
|
||||||
|
const ops = await getOutboxOps();
|
||||||
|
return ops.filter((o) => o.status === "failed" && o.attempts >= MAX_ATTEMPTS);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Persists the React Query cache to IndexedDB so the dashboard is *viewable*
|
||||||
|
* offline (last-synced data) and survives a reload with no connection.
|
||||||
|
*
|
||||||
|
* Uses `dehydrate`/`hydrate` from @tanstack/react-query directly — no extra
|
||||||
|
* dependency. Writes are debounced; reads are guarded by a schema buster, a
|
||||||
|
* max-age, and a tenant scope so one café never hydrates another's data.
|
||||||
|
*/
|
||||||
|
import { dehydrate, hydrate, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import { kvGet, kvSet } from "@/lib/offline/offline-db";
|
||||||
|
|
||||||
|
const CACHE_KEY = "rq-cache";
|
||||||
|
/** Bump when cached shapes change so stale persisted data is dropped on deploy. */
|
||||||
|
const BUSTER = "v1";
|
||||||
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
const SAVE_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
type PersistedCache = {
|
||||||
|
buster: string;
|
||||||
|
timestamp: number;
|
||||||
|
/** Tenant/user scope this cache belongs to (cafeId, or "anon"). */
|
||||||
|
scope: string;
|
||||||
|
state: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate the query cache from IndexedDB if a valid snapshot exists for this
|
||||||
|
* scope. Safe to call before or after queries mount.
|
||||||
|
*/
|
||||||
|
export async function restoreQueryCache(qc: QueryClient, scope: string): Promise<void> {
|
||||||
|
const saved = await kvGet<PersistedCache>(CACHE_KEY);
|
||||||
|
if (!saved) return;
|
||||||
|
if (saved.buster !== BUSTER) return; // schema changed
|
||||||
|
if (saved.scope !== scope) return; // different tenant/user — do not leak
|
||||||
|
if (Date.now() - saved.timestamp > MAX_AGE_MS) return; // too old
|
||||||
|
try {
|
||||||
|
hydrate(qc, saved.state as never);
|
||||||
|
} catch {
|
||||||
|
// corrupt snapshot — ignore, it will be overwritten on next save
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to cache changes and persist a debounced snapshot. Returns an
|
||||||
|
* unsubscribe function.
|
||||||
|
*/
|
||||||
|
export function startPersisting(qc: QueryClient, scope: string): () => void {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
timer = null;
|
||||||
|
const snapshot: PersistedCache = {
|
||||||
|
buster: BUSTER,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
scope,
|
||||||
|
state: dehydrate(qc),
|
||||||
|
};
|
||||||
|
void kvSet(CACHE_KEY, snapshot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribe = qc.getQueryCache().subscribe(() => {
|
||||||
|
if (timer) return; // a save is already scheduled
|
||||||
|
timer = setTimeout(save, SAVE_DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,87 +1,117 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||||
import {
|
import {
|
||||||
|
enqueueOutboxOp,
|
||||||
getAllQueueItems,
|
getAllQueueItems,
|
||||||
|
getOutboxCount,
|
||||||
getQueueCount,
|
getQueueCount,
|
||||||
removeQueueItem,
|
removeQueueItem,
|
||||||
markQueueItemFailed,
|
|
||||||
} from "@/lib/offline/offline-db";
|
} from "@/lib/offline/offline-db";
|
||||||
import { apiPost } from "@/lib/api/client";
|
import { drainOutbox } from "@/lib/offline/outbox";
|
||||||
|
|
||||||
|
function newId(prefix: string): string {
|
||||||
|
if (prefix === "idem" && typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes one queued item and returns whether it succeeded.
|
* One-time migration of any items left in the legacy POS `order_queue` into the
|
||||||
|
* generic outbox, so orders queued before this release still sync. Best-effort.
|
||||||
*/
|
*/
|
||||||
async function processItem(item: Awaited<ReturnType<typeof getAllQueueItems>>[number]): Promise<boolean> {
|
async function migrateLegacyQueue(): Promise<void> {
|
||||||
|
let legacy: Awaited<ReturnType<typeof getAllQueueItems>> = [];
|
||||||
|
try {
|
||||||
|
legacy = await getAllQueueItems();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const item of legacy) {
|
||||||
try {
|
try {
|
||||||
if (item.type === "create_order") {
|
if (item.type === "create_order") {
|
||||||
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
const { cafeId, body } = item.payload as { cafeId: string; body: unknown };
|
||||||
await apiPost(`/api/cafes/${cafeId}/orders`, body as Record<string, unknown>);
|
await enqueueOutboxOp({
|
||||||
|
id: newId("op"),
|
||||||
|
idempotencyKey: newId("idem"),
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/cafes/${cafeId}/orders`,
|
||||||
|
body,
|
||||||
|
entityType: "order",
|
||||||
|
idField: "id",
|
||||||
|
createdAt: Date.parse(item.createdAt) || Date.now(),
|
||||||
|
});
|
||||||
} else if (item.type === "add_items") {
|
} else if (item.type === "add_items") {
|
||||||
const { cafeId, orderId, body } = item.payload as {
|
const { cafeId, orderId, body } = item.payload as {
|
||||||
cafeId: string;
|
cafeId: string;
|
||||||
orderId: string;
|
orderId: string;
|
||||||
body: unknown;
|
body: unknown;
|
||||||
};
|
};
|
||||||
await apiPost(
|
await enqueueOutboxOp({
|
||||||
`/api/cafes/${cafeId}/orders/${orderId}/items`,
|
id: newId("op"),
|
||||||
body as Record<string, unknown>
|
idempotencyKey: newId("idem"),
|
||||||
);
|
method: "POST",
|
||||||
|
url: `/api/cafes/${cafeId}/orders/${orderId}/items`,
|
||||||
|
body,
|
||||||
|
entityType: "order_items",
|
||||||
|
createdAt: Date.parse(item.createdAt) || Date.now(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return true;
|
await removeQueueItem(item.id);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
// leave the legacy item in place; we'll try again next mount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this hook once in the app shell to:
|
* Mount once in the app shell to:
|
||||||
* - Load initial queue count from IndexedDB on mount
|
* - migrate any legacy queued orders into the outbox,
|
||||||
* - Listen to online/offline events
|
* - keep the pending-count badge and online flag in sync,
|
||||||
* - Auto-sync when back online or tab becomes visible
|
* - drain the outbox when back online or the tab regains focus,
|
||||||
|
* - refresh server data once writes have synced.
|
||||||
*/
|
*/
|
||||||
export function useOfflineSync() {
|
export function useOfflineSync() {
|
||||||
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
const { setQueueCount, setSyncing, setOnline } = useSyncQueueStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const syncLock = useRef(false);
|
const syncLock = useRef(false);
|
||||||
|
|
||||||
const refreshCount = useCallback(async () => {
|
const refreshCount = useCallback(async () => {
|
||||||
const n = await getQueueCount();
|
const n = (await getOutboxCount()) + (await getQueueCount());
|
||||||
setQueueCount(n);
|
setQueueCount(n);
|
||||||
return n;
|
return n;
|
||||||
}, [setQueueCount]);
|
}, [setQueueCount]);
|
||||||
|
|
||||||
const syncQueue = useCallback(async () => {
|
const syncQueue = useCallback(async () => {
|
||||||
if (syncLock.current) return;
|
if (syncLock.current) return;
|
||||||
if (!navigator.onLine) return;
|
if (typeof navigator !== "undefined" && !navigator.onLine) return;
|
||||||
const count = await refreshCount();
|
|
||||||
if (count === 0) return;
|
|
||||||
|
|
||||||
syncLock.current = true;
|
syncLock.current = true;
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
try {
|
try {
|
||||||
const items = await getAllQueueItems();
|
const result = await drainOutbox();
|
||||||
for (const item of items) {
|
if (result.sent > 0) {
|
||||||
if (item.status === "failed" && item.retries >= 3) continue; // give up after 3
|
// Replace optimistic local data with the authoritative server state.
|
||||||
const ok = await processItem(item);
|
await queryClient.invalidateQueries();
|
||||||
if (ok) {
|
|
||||||
await removeQueueItem(item.id);
|
|
||||||
} else {
|
|
||||||
await markQueueItemFailed(item.id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
syncLock.current = false;
|
syncLock.current = false;
|
||||||
setSyncing(false);
|
setSyncing(false);
|
||||||
await refreshCount();
|
await refreshCount();
|
||||||
}
|
}
|
||||||
}, [refreshCount, setSyncing]);
|
}, [refreshCount, setSyncing, queryClient]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Load initial count
|
void (async () => {
|
||||||
void refreshCount();
|
await migrateLegacyQueue();
|
||||||
|
await refreshCount();
|
||||||
|
// Drain anything pending if we mounted already online.
|
||||||
|
if (typeof navigator === "undefined" || navigator.onLine) void syncQueue();
|
||||||
|
})();
|
||||||
|
|
||||||
// Track online state
|
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
setOnline(true);
|
setOnline(true);
|
||||||
void syncQueue();
|
void syncQueue();
|
||||||
@@ -92,7 +122,6 @@ export function useOfflineSync() {
|
|||||||
window.addEventListener("online", handleOnline);
|
window.addEventListener("online", handleOnline);
|
||||||
window.addEventListener("offline", handleOffline);
|
window.addEventListener("offline", handleOffline);
|
||||||
|
|
||||||
// Sync when tab regains focus
|
|
||||||
const handleVisibility = () => {
|
const handleVisibility = () => {
|
||||||
if (document.visibilityState === "visible" && navigator.onLine) {
|
if (document.visibilityState === "visible" && navigator.onLine) {
|
||||||
void syncQueue();
|
void syncQueue();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { apiPost } from "@/lib/api/client";
|
|||||||
import type { Order, OrderItemLine } from "@/lib/api/types";
|
import type { Order, OrderItemLine } from "@/lib/api/types";
|
||||||
import type { CartItem } from "@/lib/stores/cart.store";
|
import type { CartItem } from "@/lib/stores/cart.store";
|
||||||
import { iranMobileForApi } from "@/lib/phone";
|
import { iranMobileForApi } from "@/lib/phone";
|
||||||
import { enqueueOfflineItem, getQueueCount } from "@/lib/offline/offline-db";
|
import { enqueueOutboxOp, getOutboxCount, getQueueCount } from "@/lib/offline/offline-db";
|
||||||
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
import { useSyncQueueStore } from "@/lib/stores/sync-queue.store";
|
||||||
|
|
||||||
export type SubmitOrderCart = {
|
export type SubmitOrderCart = {
|
||||||
@@ -24,7 +24,7 @@ export type SubmitOrderParams = {
|
|||||||
cartItems?: CartItem[];
|
cartItems?: CartItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Offline helpers ──────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isNetworkError(err: unknown): boolean {
|
function isNetworkError(err: unknown): boolean {
|
||||||
if (err instanceof TypeError) {
|
if (err instanceof TypeError) {
|
||||||
@@ -36,6 +36,9 @@ function isNetworkError(err: unknown): boolean {
|
|||||||
msg.includes("network request failed")
|
msg.includes("network request failed")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// axios network errors surface as an Error with code ERR_NETWORK and no response.
|
||||||
|
const ax = err as { isAxiosError?: boolean; response?: unknown };
|
||||||
|
if (ax?.isAxiosError && !ax.response) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +46,36 @@ function newLocalId(): string {
|
|||||||
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
return `local_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a synthetic Order that keeps the POS cart functional while offline */
|
/** A stable idempotency key used for BOTH the online attempt and any queued
|
||||||
function buildLocalOrder(
|
* replay of the same submit, so the server de-duplicates them. */
|
||||||
|
function newIdempotencyKey(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||||
|
return `idem_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body for a create-order POST. */
|
||||||
|
function buildCreateBody(
|
||||||
params: SubmitOrderParams,
|
params: SubmitOrderParams,
|
||||||
cartItems: CartItem[]
|
pending: ReturnType<SubmitOrderCart["getPendingLines"]>
|
||||||
): Order {
|
) {
|
||||||
|
const { cart, orderBranchId, reservationId } = params;
|
||||||
|
return {
|
||||||
|
orderType: "DineIn",
|
||||||
|
branchId: orderBranchId,
|
||||||
|
tableId: cart.tableId ?? undefined,
|
||||||
|
reservationId: reservationId ?? undefined,
|
||||||
|
guestName: cart.guestName.trim() || undefined,
|
||||||
|
guestPhone: iranMobileForApi(cart.guestPhone),
|
||||||
|
customerId: cart.customerId ?? undefined,
|
||||||
|
couponId: cart.appliedCoupon?.id,
|
||||||
|
items: pending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a synthetic Order so the POS stays usable offline. Uses the supplied
|
||||||
|
* id so it matches the outbox op's createsClientId (enabling later remap). */
|
||||||
|
function buildLocalOrder(params: SubmitOrderParams, cartItems: CartItem[], orderId: string): Order {
|
||||||
const pending = params.cart.getPendingLines();
|
const pending = params.cart.getPendingLines();
|
||||||
const localId = newLocalId();
|
|
||||||
|
|
||||||
const items: OrderItemLine[] = pending.map((p) => {
|
const items: OrderItemLine[] = pending.map((p) => {
|
||||||
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
|
const ci = cartItems.find((c) => c.menuItem.id === p.menuItemId);
|
||||||
@@ -69,7 +95,7 @@ function buildLocalOrder(
|
|||||||
const total = subtotal + taxTotal;
|
const total = subtotal + taxTotal;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: localId,
|
id: orderId,
|
||||||
cafeId: params.cafeId,
|
cafeId: params.cafeId,
|
||||||
branchId: params.orderBranchId,
|
branchId: params.orderBranchId,
|
||||||
tableId: params.cart.tableId ?? undefined,
|
tableId: params.cart.tableId ?? undefined,
|
||||||
@@ -90,50 +116,58 @@ function buildLocalOrder(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshQueueBadge(): Promise<void> {
|
||||||
|
const count = (await getOutboxCount()) + (await getQueueCount());
|
||||||
|
useSyncQueueStore.getState().setQueueCount(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue the write and return a local mock order. Two cases:
|
||||||
|
* - create: enqueue POST /orders with a fresh local id as createsClientId;
|
||||||
|
* - add items: enqueue POST /orders/{id}/items. {id} may be a local id — the
|
||||||
|
* outbox blocks then remaps it once the create syncs.
|
||||||
|
*/
|
||||||
async function queueAndBuildLocalOrder(
|
async function queueAndBuildLocalOrder(
|
||||||
params: SubmitOrderParams,
|
params: SubmitOrderParams,
|
||||||
cartItems: CartItem[]
|
cartItems: CartItem[],
|
||||||
|
idempotencyKey: string
|
||||||
): Promise<Order> {
|
): Promise<Order> {
|
||||||
const pending = params.cart.getPendingLines();
|
const { cafeId, cart } = params;
|
||||||
|
const pending = cart.getPendingLines();
|
||||||
if (pending.length === 0) throw new Error("nothing pending");
|
if (pending.length === 0) throw new Error("nothing pending");
|
||||||
|
|
||||||
const isAddToExisting =
|
const activeId = cart.activeOrderId;
|
||||||
!!params.cart.activeOrderId &&
|
|
||||||
!params.cart.activeOrderId.startsWith("local_");
|
|
||||||
|
|
||||||
await enqueueOfflineItem({
|
if (activeId) {
|
||||||
|
// Add items to an existing order (real server id, or a not-yet-synced local id).
|
||||||
|
await enqueueOutboxOp({
|
||||||
id: newLocalId(),
|
id: newLocalId(),
|
||||||
type: isAddToExisting ? "add_items" : "create_order",
|
idempotencyKey,
|
||||||
cafeId: params.cafeId,
|
method: "POST",
|
||||||
targetOrderId: isAddToExisting ? params.cart.activeOrderId : null,
|
url: `/api/cafes/${cafeId}/orders/${activeId}/items`,
|
||||||
payload: isAddToExisting
|
|
||||||
? {
|
|
||||||
cafeId: params.cafeId,
|
|
||||||
orderId: params.cart.activeOrderId!,
|
|
||||||
body: { items: pending },
|
body: { items: pending },
|
||||||
}
|
entityType: "order_items",
|
||||||
: {
|
createdAt: Date.now(),
|
||||||
cafeId: params.cafeId,
|
|
||||||
body: {
|
|
||||||
orderType: "DineIn",
|
|
||||||
branchId: params.orderBranchId,
|
|
||||||
tableId: params.cart.tableId ?? undefined,
|
|
||||||
reservationId: params.reservationId ?? undefined,
|
|
||||||
guestName: params.cart.guestName.trim() || undefined,
|
|
||||||
guestPhone: iranMobileForApi(params.cart.guestPhone),
|
|
||||||
customerId: params.cart.customerId ?? undefined,
|
|
||||||
couponId: params.cart.appliedCoupon?.id,
|
|
||||||
items: pending,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
});
|
});
|
||||||
|
await refreshQueueBadge();
|
||||||
|
return buildLocalOrder(params, cartItems, activeId);
|
||||||
|
}
|
||||||
|
|
||||||
// Update global queue count
|
// Create a brand-new order. createsClientId lets later add-items ops remap.
|
||||||
const count = await getQueueCount();
|
const localOrderId = newLocalId();
|
||||||
useSyncQueueStore.getState().setQueueCount(count);
|
await enqueueOutboxOp({
|
||||||
|
id: newLocalId(),
|
||||||
return buildLocalOrder(params, cartItems);
|
idempotencyKey,
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/cafes/${cafeId}/orders`,
|
||||||
|
body: buildCreateBody(params, pending),
|
||||||
|
entityType: "order",
|
||||||
|
createsClientId: localOrderId,
|
||||||
|
idField: "id",
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
await refreshQueueBadge();
|
||||||
|
return buildLocalOrder(params, cartItems, localOrderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main export ──────────────────────────────────────────────────────────────
|
// ─── Main export ──────────────────────────────────────────────────────────────
|
||||||
@@ -145,47 +179,45 @@ export async function submitOrderToApi({
|
|||||||
reservationId,
|
reservationId,
|
||||||
cartItems = [],
|
cartItems = [],
|
||||||
}: SubmitOrderParams): Promise<Order> {
|
}: SubmitOrderParams): Promise<Order> {
|
||||||
|
const params: SubmitOrderParams = { cafeId, orderBranchId, cart, reservationId, cartItems };
|
||||||
const pending = cart.getPendingLines();
|
const pending = cart.getPendingLines();
|
||||||
if (pending.length === 0) throw new Error("nothing pending");
|
if (pending.length === 0) throw new Error("nothing pending");
|
||||||
|
|
||||||
const tryOnline = async (): Promise<Order> => {
|
const idempotencyKey = newIdempotencyKey();
|
||||||
if (cart.activeOrderId && !cart.activeOrderId.startsWith("local_")) {
|
const addingToLocalOrder = isLocalOrder(cart.activeOrderId);
|
||||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`, {
|
|
||||||
items: pending,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return apiPost<Order>(`/api/cafes/${cafeId}/orders`, {
|
|
||||||
orderType: "DineIn",
|
|
||||||
branchId: orderBranchId,
|
|
||||||
tableId: cart.tableId ?? undefined,
|
|
||||||
reservationId: reservationId ?? undefined,
|
|
||||||
guestName: cart.guestName.trim() || undefined,
|
|
||||||
guestPhone: iranMobileForApi(cart.guestPhone),
|
|
||||||
customerId: cart.customerId ?? undefined,
|
|
||||||
couponId: cart.appliedCoupon?.id,
|
|
||||||
items: pending,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try online first
|
// Fast path: online, and either a new order or adding to a real server order.
|
||||||
if (navigator.onLine) {
|
// (Adding to a still-local order must be queued so the outbox can remap its id.)
|
||||||
|
if (typeof navigator !== "undefined" && navigator.onLine && !addingToLocalOrder) {
|
||||||
try {
|
try {
|
||||||
return await tryOnline();
|
if (cart.activeOrderId) {
|
||||||
|
return await apiPost<Order>(
|
||||||
|
`/api/cafes/${cafeId}/orders/${cart.activeOrderId}/items`,
|
||||||
|
{ items: pending },
|
||||||
|
{ idempotencyKey }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await apiPost<Order>(
|
||||||
|
`/api/cafes/${cafeId}/orders`,
|
||||||
|
buildCreateBody(params, pending),
|
||||||
|
{ idempotencyKey }
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// If it's a network error despite onLine flag, fall through to offline path
|
// Only fall back to the offline queue on a genuine network failure; a real
|
||||||
|
// server/validation error must surface. The same idempotencyKey is reused
|
||||||
|
// so the server de-dups if the failed attempt actually reached it.
|
||||||
if (!isNetworkError(err)) throw err;
|
if (!isNetworkError(err)) throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offline path: queue and return a local mock order
|
return queueAndBuildLocalOrder(params, cartItems, idempotencyKey);
|
||||||
return queueAndBuildLocalOrder({ cafeId, orderBranchId, cart, reservationId, cartItems }, cartItems);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function orderAmountDue(order: Order): number {
|
export function orderAmountDue(order: Order): number {
|
||||||
return Math.max(0, order.total - (order.paidAmount ?? 0));
|
return Math.max(0, order.total - (order.paidAmount ?? 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the order was created locally (offline) and not yet synced */
|
/** True when the order was created locally (offline) and not yet synced. */
|
||||||
export function isLocalOrder(orderId: string | null): boolean {
|
export function isLocalOrder(orderId: string | null): boolean {
|
||||||
return !!orderId?.startsWith("local_");
|
return !!orderId?.startsWith("local_");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface CartState {
|
|||||||
addItem: (item: MenuItem) => void;
|
addItem: (item: MenuItem) => void;
|
||||||
removeItem: (menuItemId: string) => void;
|
removeItem: (menuItemId: string) => void;
|
||||||
updateQty: (menuItemId: string, quantity: number) => void;
|
updateQty: (menuItemId: string, quantity: number) => void;
|
||||||
|
setNotes: (menuItemId: string, notes: string) => void;
|
||||||
setCouponCode: (code: string) => void;
|
setCouponCode: (code: string) => void;
|
||||||
setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
|
setAppliedCoupon: (coupon: AppliedCoupon | null) => void;
|
||||||
clearCoupon: () => void;
|
clearCoupon: () => void;
|
||||||
@@ -135,6 +136,13 @@ export const useCartStore = create<CartState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setNotes: (menuItemId, notes) =>
|
||||||
|
set({
|
||||||
|
items: get().items.map((i) =>
|
||||||
|
i.menuItem.id === menuItemId ? { ...i, notes: notes.trim() || undefined } : i
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
|
||||||
setCouponCode: (code) => set({ couponCode: code }),
|
setCouponCode: (code) => set({ couponCode: code }),
|
||||||
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
|
setAppliedCoupon: (coupon) => set({ appliedCoupon: coupon }),
|
||||||
clearCoupon: () => set(clearCouponState),
|
clearCoupon: () => set(clearCouponState),
|
||||||
|
|||||||
@@ -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");
|
||||||
|
};
|
||||||
|
}
|
||||||
+5
-10
@@ -50,16 +50,11 @@ const nextConfig: NextConfig = {
|
|||||||
{ protocol: "http", hostname: "**" },
|
{ protocol: "http", hostname: "**" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
async redirects() {
|
// NOTE: the previous "short URL" redirect (/:slug → /fa/cafe/:slug) matched
|
||||||
return [
|
// single-segment paths INCLUDING the locale itself, so "/fa" redirected to
|
||||||
// Short URL: koja.meezi.ir/my-cafe → koja.meezi.ir/fa/cafe/my-cafe
|
// "/fa/cafe/fa" (and "/en" → "/fa/cafe/en") — a non-existent slug that 500'd
|
||||||
{
|
// the home page. Removed; re-add via middleware with explicit reserved-word
|
||||||
source: "/:slug([a-z0-9][a-z0-9-]*[a-z0-9])",
|
// exclusions if short café URLs are needed.
|
||||||
destination: "/fa/cafe/:slug",
|
|
||||||
permanent: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default withPWA(withNextIntl(nextConfig));
|
export default withPWA(withNextIntl(nextConfig));
|
||||||
|
|||||||
@@ -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