diff --git a/src/Meezi.API/Controllers/CafeSettingsController.cs b/src/Meezi.API/Controllers/CafeSettingsController.cs index 45be75b..53f6466 100644 --- a/src/Meezi.API/Controllers/CafeSettingsController.cs +++ b/src/Meezi.API/Controllers/CafeSettingsController.cs @@ -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(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(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); } diff --git a/src/Meezi.API/Controllers/MenuController.cs b/src/Meezi.API/Controllers/MenuController.cs index ab86ad3..089073d 100644 --- a/src/Meezi.API/Controllers/MenuController.cs +++ b/src/Meezi.API/Controllers/MenuController.cs @@ -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 _createCategoryValidator; private readonly IValidator _createItemValidator; + private readonly AppDbContext _db; + + private const string CategoryLimitMessage = + "محدودیت دسته‌بندی پلن رایگان (۳ دسته). برای افزودن دسته‌بندی بیشتر، پلن خود را ارتقا دهید."; + private const string ItemLimitMessage = + "محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید."; public MenuController( IMenuService menuService, IMenuAi3dGenerationService menuAi3d, IValidator createCategoryValidator, - IValidator createItemValidator) + IValidator 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(false, null, + new ApiError("PLAN_LIMIT_REACHED", CategoryLimitMessage))); + } + var data = await _menuService.CreateCategoryAsync(cafeId, request, cancellationToken); return Ok(new ApiResponse(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(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(true, data)); diff --git a/src/Meezi.API/Controllers/PublicController.cs b/src/Meezi.API/Controllers/PublicController.cs index 699380a..6105aeb 100644 --- a/src/Meezi.API/Controllers/PublicController.cs +++ b/src/Meezi.API/Controllers/PublicController.cs @@ -367,4 +367,101 @@ public class PublicController : ControllerBase return Ok(new ApiResponse(true, null)); } + + /// + /// Returns all cafés that have a known location (Latitude/Longitude set). + /// Used by the marketing website SVG map to render blinking dots. + /// + [HttpGet("map-markers")] + [EnableRateLimiting("public-read")] + public async Task 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(true, markers)); + } + + /// + /// Returns cafés near a given coordinate, sorted by distance ascending. + /// Used by Koja guest page to show "nearby cafés" section. + /// At most results (default 5, max 20). + /// + [HttpGet("nearby")] + [EnableRateLimiting("public-read")] + public async Task 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(true, nearby)); + } } diff --git a/src/Meezi.API/Models/Cafes/CafeSettingsDtos.cs b/src/Meezi.API/Models/Cafes/CafeSettingsDtos.cs index ee5d217..2467887 100644 --- a/src/Meezi.API/Models/Cafes/CafeSettingsDtos.cs +++ b/src/Meezi.API/Models/Cafes/CafeSettingsDtos.cs @@ -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, + /// WGS-84 latitude. Send null to clear. + double? Latitude, + /// WGS-84 longitude. Send null to clear. + double? Longitude, + /// When true, Latitude and Longitude are explicitly being cleared (set to null). + bool ClearLocation = false); diff --git a/src/Meezi.Core/Constants/PlanLimits.cs b/src/Meezi.Core/Constants/PlanLimits.cs index c160c26..77ffe5d 100644 --- a/src/Meezi.Core/Constants/PlanLimits.cs +++ b/src/Meezi.Core/Constants/PlanLimits.cs @@ -54,4 +54,24 @@ public static class PlanLimits PlanTier.Enterprise => 100, _ => 0 }; + + /// Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited. + public static int MaxMenuCategories(PlanTier tier) => tier switch + { + PlanTier.Free => 3, + _ => int.MaxValue + }; + + /// Maximum menu items. Free tier is capped at 30; Pro+ is unlimited. + public static int MaxMenuItems(PlanTier tier) => tier switch + { + PlanTier.Free => 30, + _ => int.MaxValue + }; + + /// CRM (customers, loyalty) is only available on Pro and above. + public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro; + + /// Statistics and analytics dashboards are only available on Pro and above. + public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro; } diff --git a/src/Meezi.Core/Entities/Cafe.cs b/src/Meezi.Core/Entities/Cafe.cs index 129b6a8..d578eae 100644 --- a/src/Meezi.Core/Entities/Cafe.cs +++ b/src/Meezi.Core/Entities/Cafe.cs @@ -37,6 +37,10 @@ public class Cafe : BaseEntity public string? InstagramHandle { get; set; } /// Cafe website URL, max 300 chars. public string? WebsiteUrl { get; set; } + /// WGS-84 latitude (positive = north). Null until owner sets location. + public double? Latitude { get; set; } + /// WGS-84 longitude (positive = east). Null until owner sets location. + public double? Longitude { get; set; } /// Default VAT/sales tax % for all branches unless branch override is allowed. public decimal DefaultTaxRate { get; set; } = 9m; public bool AllowBranchTaxOverride { get; set; } diff --git a/src/Meezi.Infrastructure/Data/Migrations/20260601120000_AddCafeLocation.cs b/src/Meezi.Infrastructure/Data/Migrations/20260601120000_AddCafeLocation.cs new file mode 100644 index 0000000..1545928 --- /dev/null +++ b/src/Meezi.Infrastructure/Data/Migrations/20260601120000_AddCafeLocation.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Meezi.Infrastructure.Data.Migrations +{ + /// + public partial class AddCafeLocation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Latitude", + table: "Cafes", + type: "double precision", + nullable: true); + + migrationBuilder.AddColumn( + name: "Longitude", + table: "Cafes", + type: "double precision", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Latitude", + table: "Cafes"); + + migrationBuilder.DropColumn( + name: "Longitude", + table: "Cafes"); + } + } +} diff --git a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 353560a..def1271 100644 --- a/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Meezi.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -327,9 +327,15 @@ namespace Meezi.Infrastructure.Data.Migrations b.Property("IsVerified") .HasColumnType("boolean"); + b.Property("Latitude") + .HasColumnType("double precision"); + b.Property("LogoUrl") .HasColumnType("text"); + b.Property("Longitude") + .HasColumnType("double precision"); + b.Property("Name") .IsRequired() .HasMaxLength(200) diff --git a/web/dashboard/src/components/settings/settings-shop-panel.tsx b/web/dashboard/src/components/settings/settings-shop-panel.tsx index 584b8bf..5dc41ff 100644 --- a/web/dashboard/src/components/settings/settings-shop-panel.tsx +++ b/web/dashboard/src/components/settings/settings-shop-panel.tsx @@ -15,6 +15,23 @@ import { Input } from "@/components/ui/input"; import { LabeledField } from "@/components/ui/labeled-field"; import { notify } from "@/lib/notify"; +// ── Location map preview ────────────────────────────────────────────────────── +function LocationMapPreview({ lat, lng }: { lat: number; lng: number }) { + const zoom = 15; + const src = `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01},${lat - 0.01},${lng + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lng}`; + return ( +
+