feat: plan limits, café location, nearby API, Iran map section
CI/CD / CI · API (dotnet build + test) (push) Successful in 56s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 49s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 3m15s

• PlanLimits: add MaxMenuCategories (Free→3), MaxMenuItems (Free→30),
  CanAccessCrm and CanAccessStatistics (Pro+ only)
• MenuController: enforce category/item limits before create (403 + PLAN_LIMIT_REACHED)
• Cafe entity + EF migration: Latitude/Longitude (double?, nullable)
• CafeSettingsController: PATCH accepts lat/lng with range validation
• PublicController: GET /api/public/map-markers (marketing SVG map feed)
  and GET /api/public/nearby (Koja nearby-cafés with Haversine sort)
• Dashboard settings: location card with OSM iframe preview + Neshan link
• Website homepage: IranMapSection — stylised SVG silhouette with
  SMIL-animated blinking dots at real café coordinates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-01 15:09:09 +03:30
parent 665e3ca279
commit 5e980cdfc0
12 changed files with 619 additions and 4 deletions
@@ -87,6 +87,21 @@ public class CafeSettingsController : CafeApiControllerBase
if (request.AllowBranchTaxOverride is bool allowTax)
cafe.AllowBranchTaxOverride = allowTax;
// Location: explicit null-clear flag OR new values
if (request.ClearLocation)
{
cafe.Latitude = null;
cafe.Longitude = null;
}
else if (request.Latitude.HasValue && request.Longitude.HasValue)
{
if (request.Latitude is < -90 or > 90 || request.Longitude is < -180 or > 180)
return BadRequest(new ApiResponse<object>(false, null,
new ApiError("INVALID_LOCATION", "Latitude must be 90…90 and longitude 180…180.")));
cafe.Latitude = request.Latitude;
cafe.Longitude = request.Longitude;
}
await _db.SaveChangesAsync(ct);
return Ok(new ApiResponse<CafeSettingsDto>(true, ToDto(cafe)));
}
@@ -106,5 +121,7 @@ public class CafeSettingsController : CafeApiControllerBase
cafe.PlanExpiresAt,
CafeThemeMapping.FromJson(cafe.ThemeJson),
cafe.DefaultTaxRate,
cafe.AllowBranchTaxOverride);
cafe.AllowBranchTaxOverride,
cafe.Latitude,
cafe.Longitude);
}
+34 -1
View File
@@ -1,9 +1,12 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Meezi.API.Models.Menu;
using Meezi.API.Services;
using Meezi.Core.Constants;
using Meezi.Core.Enums;
using Meezi.Core.Interfaces;
using Meezi.Infrastructure.Data;
using Meezi.Shared;
namespace Meezi.API.Controllers;
@@ -15,17 +18,25 @@ public class MenuController : CafeApiControllerBase
private readonly IMenuAi3dGenerationService _menuAi3d;
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
private readonly AppDbContext _db;
private const string CategoryLimitMessage =
"محدودیت دسته‌بندی پلن رایگان (۳ دسته). برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید.";
private const string ItemLimitMessage =
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
public MenuController(
IMenuService menuService,
IMenuAi3dGenerationService menuAi3d,
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
IValidator<CreateMenuItemRequest> createItemValidator)
IValidator<CreateMenuItemRequest> createItemValidator,
AppDbContext db)
{
_menuService = menuService;
_menuAi3d = menuAi3d;
_createCategoryValidator = createCategoryValidator;
_createItemValidator = createItemValidator;
_db = db;
}
[HttpGet("categories")]
@@ -47,6 +58,17 @@ public class MenuController : CafeApiControllerBase
var validation = await _createCategoryValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuCategories(tier);
if (max != int.MaxValue)
{
var count = await _db.MenuCategories.CountAsync(
c => c.CafeId == cafeId && c.DeletedAt == null, cancellationToken);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", CategoryLimitMessage)));
}
var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken);
return Ok(new ApiResponse<MenuCategoryDto>(true, data));
}
@@ -97,6 +119,17 @@ public class MenuController : CafeApiControllerBase
var validation = await _createItemValidator.ValidateAsync(request, cancellationToken);
if (!validation.IsValid) return BadRequest(ValidationError(validation));
var tier = tenant.PlanTier ?? PlanTier.Free;
var max = PlanLimits.MaxMenuItems(tier);
if (max != int.MaxValue)
{
var count = await _db.MenuItems.CountAsync(
i => i.CafeId == cafeId && i.DeletedAt == null, cancellationToken);
if (count >= max)
return StatusCode(403, new ApiResponse<object>(false, null,
new ApiError("PLAN_LIMIT_REACHED", ItemLimitMessage)));
}
var data = await _menuService.CreateItemAsync(cafeId, request, cancellationToken);
if (data is null) return NotFoundError("Category not found.");
return Ok(new ApiResponse<MenuItemDto>(true, data));
@@ -367,4 +367,101 @@ public class PublicController : ControllerBase
return Ok(new ApiResponse<object>(true, null));
}
/// <summary>
/// Returns all cafés that have a known location (Latitude/Longitude set).
/// Used by the marketing website SVG map to render blinking dots.
/// </summary>
[HttpGet("map-markers")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetMapMarkers(
[FromServices] AppDbContext db,
CancellationToken ct)
{
var markers = await db.Cafes
.AsNoTracking()
.Where(c => c.DeletedAt == null && c.Latitude != null && c.Longitude != null)
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl
})
.ToListAsync(ct);
return Ok(new ApiResponse<object>(true, markers));
}
/// <summary>
/// Returns cafés near a given coordinate, sorted by distance ascending.
/// Used by Koja guest page to show "nearby cafés" section.
/// At most <paramref name="limit"/> results (default 5, max 20).
/// </summary>
[HttpGet("nearby")]
[EnableRateLimiting("public-read")]
public async Task<IActionResult> GetNearbyCafes(
[FromQuery] double lat,
[FromQuery] double lng,
[FromQuery] string? excludeSlug,
[FromQuery] int limit,
[FromServices] AppDbContext db,
CancellationToken ct)
{
limit = Math.Clamp(limit <= 0 ? 5 : limit, 1, 20);
// Pull all located cafés from DB (typically small set) and sort in memory with Haversine.
var cafes = await db.Cafes
.AsNoTracking()
.Where(c => c.DeletedAt == null
&& c.Latitude != null
&& c.Longitude != null
&& (excludeSlug == null || c.Slug != excludeSlug))
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl,
c.CoverImageUrl
})
.ToListAsync(ct);
static double ToRad(double deg) => deg * Math.PI / 180.0;
static double Haversine(double lat1, double lon1, double lat2, double lon2)
{
const double R = 6371; // km
var dLat = ToRad(lat2 - lat1);
var dLon = ToRad(lon2 - lon1);
var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
+ Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2))
* Math.Sin(dLon / 2) * Math.Sin(dLon / 2);
return R * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
}
var nearby = cafes
.Select(c => new
{
c.Id,
c.Name,
c.Slug,
c.City,
c.Latitude,
c.Longitude,
c.LogoUrl,
c.CoverImageUrl,
DistanceKm = Math.Round(Haversine(lat, lng, c.Latitude!.Value, c.Longitude!.Value), 1)
})
.OrderBy(c => c.DistanceKm)
.Take(limit)
.ToList();
return Ok(new ApiResponse<object>(true, nearby));
}
}