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
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ public record CafeSettingsDto(
|
||||
DateTime? PlanExpiresAt,
|
||||
CafeThemeDto Theme,
|
||||
decimal DefaultTaxRate,
|
||||
bool AllowBranchTaxOverride);
|
||||
bool AllowBranchTaxOverride,
|
||||
double? Latitude,
|
||||
double? Longitude);
|
||||
|
||||
public record PatchCafeSettingsRequest(
|
||||
string? Name,
|
||||
@@ -30,4 +32,10 @@ public record PatchCafeSettingsRequest(
|
||||
string? SnappfoodVendorId,
|
||||
CafeThemeDto? Theme,
|
||||
decimal? DefaultTaxRate,
|
||||
bool? AllowBranchTaxOverride);
|
||||
bool? AllowBranchTaxOverride,
|
||||
/// <summary>WGS-84 latitude. Send null to clear.</summary>
|
||||
double? Latitude,
|
||||
/// <summary>WGS-84 longitude. Send null to clear.</summary>
|
||||
double? Longitude,
|
||||
/// <summary>When true, Latitude and Longitude are explicitly being cleared (set to null).</summary>
|
||||
bool ClearLocation = false);
|
||||
|
||||
@@ -54,4 +54,24 @@ public static class PlanLimits
|
||||
PlanTier.Enterprise => 100,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
|
||||
public static int MaxMenuCategories(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 3,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
/// <summary>Maximum menu items. Free tier is capped at 30; Pro+ is unlimited.</summary>
|
||||
public static int MaxMenuItems(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 30,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
|
||||
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
|
||||
|
||||
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
|
||||
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ public class Cafe : BaseEntity
|
||||
public string? InstagramHandle { get; set; }
|
||||
/// <summary>Cafe website URL, max 300 chars.</summary>
|
||||
public string? WebsiteUrl { get; set; }
|
||||
/// <summary>WGS-84 latitude (positive = north). Null until owner sets location.</summary>
|
||||
public double? Latitude { get; set; }
|
||||
/// <summary>WGS-84 longitude (positive = east). Null until owner sets location.</summary>
|
||||
public double? Longitude { get; set; }
|
||||
/// <summary>Default VAT/sales tax % for all branches unless branch override is allowed.</summary>
|
||||
public decimal DefaultTaxRate { get; set; } = 9m;
|
||||
public bool AllowBranchTaxOverride { get; set; }
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Meezi.Infrastructure.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCafeLocation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "Latitude",
|
||||
table: "Cafes",
|
||||
type: "double precision",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "Longitude",
|
||||
table: "Cafes",
|
||||
type: "double precision",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Latitude",
|
||||
table: "Cafes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Longitude",
|
||||
table: "Cafes");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,9 +327,15 @@ namespace Meezi.Infrastructure.Data.Migrations
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double?>("Latitude")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("LogoUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<double?>("Longitude")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
|
||||
Reference in New Issue
Block a user