Compare commits
5 Commits
4c98c2cce1
...
2487f9e30f
| Author | SHA1 | Date | |
|---|---|---|---|
| 2487f9e30f | |||
| 8f738f6469 | |||
| 7f52b2823f | |||
| c5d5a4006a | |||
| 4cb640814a |
@@ -2,7 +2,9 @@ using FluentValidation;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -12,11 +14,16 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly IReviewService _reviews;
|
||||
private readonly IValidator<ReplyCafeReviewRequest> _replyValidator;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public CafeReviewsController(IReviewService reviews, IValidator<ReplyCafeReviewRequest> replyValidator)
|
||||
public CafeReviewsController(
|
||||
IReviewService reviews,
|
||||
IValidator<ReplyCafeReviewRequest> replyValidator,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_reviews = reviews;
|
||||
_replyValidator = replyValidator;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -41,6 +48,13 @@ public class CafeReviewsController : CafeApiControllerBase
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
|
||||
// Replying to reviews is a paid feature (Starter+).
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, tier, "review_reply", ct))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "Replying to reviews is not included in your plan. Please upgrade.")));
|
||||
|
||||
var validation = await _replyValidator.ValidateAsync(request, ct);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
|
||||
@@ -3,10 +3,12 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Meezi.API.Models.Cafes;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Core.Utilities;
|
||||
using Meezi.Infrastructure.Branding;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -16,11 +18,16 @@ public class CafeSettingsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IValidator<PatchCafeSettingsRequest> _validator;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public CafeSettingsController(AppDbContext db, IValidator<PatchCafeSettingsRequest> validator)
|
||||
public CafeSettingsController(
|
||||
AppDbContext db,
|
||||
IValidator<PatchCafeSettingsRequest> validator,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_db = db;
|
||||
_validator = validator;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@@ -81,7 +88,19 @@ public class CafeSettingsController : CafeApiControllerBase
|
||||
if (request.CoverImageUrl is not null) cafe.CoverImageUrl = request.CoverImageUrl.Trim();
|
||||
if (request.SnappfoodVendorId is not null) cafe.SnappfoodVendorId = request.SnappfoodVendorId.Trim();
|
||||
if (request.Theme is not null)
|
||||
cafe.ThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||
{
|
||||
// Custom menu styling is a paid feature (Starter+). Only block an actual change,
|
||||
// so a normal settings save that re-sends the current theme isn't rejected.
|
||||
var newThemeJson = CafeThemeSerializer.Serialize(CafeThemeMapping.FromDto(request.Theme));
|
||||
if (newThemeJson != cafe.ThemeJson)
|
||||
{
|
||||
var styleTier = tenant.PlanTier ?? PlanTier.Free;
|
||||
if (!await _catalog.IsFeatureEnabledForCafeAsync(cafeId, styleTier, "custom_menu_styling", ct))
|
||||
return StatusCode(403, new ApiResponse<object>(false, null,
|
||||
new ApiError("PLAN_FEATURE_DISABLED", "Custom menu styling is not included in your plan. Please upgrade.")));
|
||||
cafe.ThemeJson = newThemeJson;
|
||||
}
|
||||
}
|
||||
if (request.DefaultTaxRate is decimal taxRate)
|
||||
cafe.DefaultTaxRate = taxRate;
|
||||
if (request.AllowBranchTaxOverride is bool allowTax)
|
||||
|
||||
@@ -7,6 +7,7 @@ using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -19,24 +20,27 @@ public class MenuController : CafeApiControllerBase
|
||||
private readonly IValidator<CreateMenuCategoryRequest> _createCategoryValidator;
|
||||
private readonly IValidator<CreateMenuItemRequest> _createItemValidator;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
private const string CategoryLimitMessage =
|
||||
"محدودیت دستهبندی پلن رایگان (۳ دسته). برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||
"به سقف دستهبندی منوی پلن شما رسیدید. برای افزودن دستهبندی بیشتر، پلن خود را ارتقا دهید.";
|
||||
private const string ItemLimitMessage =
|
||||
"محدودیت آیتم منو پلن رایگان (۳۰ آیتم). برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||
"به سقف آیتم منوی پلن شما رسیدید. برای افزودن آیتم بیشتر، پلن خود را ارتقا دهید.";
|
||||
|
||||
public MenuController(
|
||||
IMenuService menuService,
|
||||
IMenuAi3dGenerationService menuAi3d,
|
||||
IValidator<CreateMenuCategoryRequest> createCategoryValidator,
|
||||
IValidator<CreateMenuItemRequest> createItemValidator,
|
||||
AppDbContext db)
|
||||
AppDbContext db,
|
||||
IPlatformCatalogService catalog)
|
||||
{
|
||||
_menuService = menuService;
|
||||
_menuAi3d = menuAi3d;
|
||||
_createCategoryValidator = createCategoryValidator;
|
||||
_createItemValidator = createItemValidator;
|
||||
_db = db;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpGet("categories")]
|
||||
@@ -59,7 +63,7 @@ public class MenuController : CafeApiControllerBase
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var max = PlanLimits.MaxMenuCategories(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuCategories;
|
||||
if (max != int.MaxValue)
|
||||
{
|
||||
var count = await _db.MenuCategories.CountAsync(
|
||||
@@ -120,7 +124,7 @@ public class MenuController : CafeApiControllerBase
|
||||
if (!validation.IsValid) return BadRequest(ValidationError(validation));
|
||||
|
||||
var tier = tenant.PlanTier ?? PlanTier.Free;
|
||||
var max = PlanLimits.MaxMenuItems(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxMenuItems;
|
||||
if (max != int.MaxValue)
|
||||
{
|
||||
var count = await _db.MenuItems.CountAsync(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Infrastructure.Services.Platform;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
@@ -11,8 +11,13 @@ namespace Meezi.API.Controllers;
|
||||
public class TerminalsController : CafeApiControllerBase
|
||||
{
|
||||
private readonly ITerminalRegistryService _terminals;
|
||||
private readonly IPlatformCatalogService _catalog;
|
||||
|
||||
public TerminalsController(ITerminalRegistryService terminals) => _terminals = terminals;
|
||||
public TerminalsController(ITerminalRegistryService terminals, IPlatformCatalogService catalog)
|
||||
{
|
||||
_terminals = terminals;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> Register(
|
||||
@@ -35,7 +40,7 @@ public class TerminalsController : CafeApiControllerBase
|
||||
{
|
||||
if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied;
|
||||
var list = await _terminals.ListAsync(cafeId, ct);
|
||||
var max = PlanLimits.MaxTerminals(tenant.PlanTier ?? PlanTier.Free);
|
||||
var max = (await _catalog.GetLimitsAsync(tenant.PlanTier ?? PlanTier.Free, ct)).MaxTerminals;
|
||||
return Ok(new ApiResponse<object>(true, new { terminals = list, max }));
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,21 @@ public class PlanLimitChecker : IPlanLimitChecker
|
||||
return (false, "PLAN_LIMIT_REACHED", "Branch limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
|
||||
var tablesPath = $"/api/cafes/{cafeId}/tables";
|
||||
if (path.StartsWith(tablesPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(path.Equals(tablesPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{tablesPath}/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var limitsTables = await _platformCatalog.GetLimitsAsync(tier, cancellationToken);
|
||||
var maxTables = limitsTables.MaxTables;
|
||||
if (maxTables != int.MaxValue)
|
||||
{
|
||||
var tableCount = await _db.Tables.CountAsync(t => t.CafeId == cafeId, cancellationToken);
|
||||
if (tableCount >= maxTables)
|
||||
return (false, "PLAN_LIMIT_REACHED", "Table limit reached for your plan. Please upgrade.");
|
||||
}
|
||||
}
|
||||
|
||||
var smsCampaignPath = $"/api/cafes/{cafeId}/sms/campaign";
|
||||
if (path.Equals(smsCampaignPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
path.Equals($"{smsCampaignPath}/", StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -23,8 +23,15 @@ public class TerminalRegistryService : ITerminalRegistryService
|
||||
{
|
||||
private static readonly TimeSpan TerminalTtl = TimeSpan.FromDays(90);
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly Meezi.Infrastructure.Services.Platform.IPlatformCatalogService _catalog;
|
||||
|
||||
public TerminalRegistryService(IConnectionMultiplexer redis) => _redis = redis;
|
||||
public TerminalRegistryService(
|
||||
IConnectionMultiplexer redis,
|
||||
Meezi.Infrastructure.Services.Platform.IPlatformCatalogService catalog)
|
||||
{
|
||||
_redis = redis;
|
||||
_catalog = catalog;
|
||||
}
|
||||
|
||||
public async Task<(bool Allowed, string? ErrorCode, string? Message)> RegisterAsync(
|
||||
string cafeId,
|
||||
@@ -38,7 +45,7 @@ public class TerminalRegistryService : ITerminalRegistryService
|
||||
terminalId = terminalId.Trim();
|
||||
var db = _redis.GetDatabase();
|
||||
var setKey = $"terminals:{cafeId}";
|
||||
var max = PlanLimits.MaxTerminals(tier);
|
||||
var max = (await _catalog.GetLimitsAsync(tier, cancellationToken)).MaxTerminals;
|
||||
|
||||
if (max == int.MaxValue)
|
||||
{
|
||||
|
||||
@@ -2,30 +2,53 @@ using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Constants;
|
||||
|
||||
/// <summary>
|
||||
/// Code-level DEFAULTS for per-plan numeric limits. These are the fallback /
|
||||
/// seed values; the source of truth at runtime is the admin-editable
|
||||
/// PlatformPlanDefinition (LimitsJson) read via IPlatformCatalogService.
|
||||
/// Gating uses explicit tier sets, never `tier >= X`, so the appended Starter
|
||||
/// tier (enum value 4) is handled correctly.
|
||||
/// </summary>
|
||||
public static class PlanLimits
|
||||
{
|
||||
private static bool IsPaid(PlanTier t) => t is not PlanTier.Free;
|
||||
private static bool IsProPlus(PlanTier t) =>
|
||||
t is PlanTier.Pro or PlanTier.Business or PlanTier.Enterprise;
|
||||
private static bool IsBusinessPlus(PlanTier t) =>
|
||||
t is PlanTier.Business or PlanTier.Enterprise;
|
||||
|
||||
public static int MaxOrdersPerDay(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 50,
|
||||
PlanTier.Free => 30,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
/// <summary>Maximum tables a café may define.</summary>
|
||||
public static int MaxTables(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 6,
|
||||
PlanTier.Starter => 15,
|
||||
PlanTier.Pro => 40,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
public static int MaxTerminals(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 1,
|
||||
PlanTier.Starter => 2,
|
||||
PlanTier.Pro => 3,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
public static int MaxCustomers(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 50,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
public static int MaxCustomers(PlanTier tier) => int.MaxValue; // CRM module gated by CanAccessCrm
|
||||
|
||||
/// <summary>Monthly bundled SMS. The product direction is pay-as-you-go credits for
|
||||
/// all tiers, but until the credit-purchase system ships we keep the existing bundled
|
||||
/// quotas so paying cafés don't lose SMS. (Switch to 0 + credits in the SMS stage.)</summary>
|
||||
public static int MaxSmsPerMonth(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 0,
|
||||
PlanTier.Starter => 0,
|
||||
PlanTier.Pro => 50,
|
||||
PlanTier.Business => 200,
|
||||
_ => int.MaxValue
|
||||
@@ -34,8 +57,8 @@ public static class PlanLimits
|
||||
public static int MaxBranches(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 1,
|
||||
PlanTier.Starter => 1,
|
||||
PlanTier.Pro => 3,
|
||||
PlanTier.Business => int.MaxValue,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
@@ -43,35 +66,27 @@ public static class PlanLimits
|
||||
public static int MaxReportHistoryDays(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 8,
|
||||
PlanTier.Starter => 30,
|
||||
PlanTier.Pro => 90,
|
||||
_ => int.MaxValue
|
||||
};
|
||||
|
||||
/// <summary>AI image-to-3D generations per calendar month (UTC).</summary>
|
||||
public static int MaxMenuAi3dPerMonth(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Business => 100,
|
||||
PlanTier.Enterprise => 100,
|
||||
_ => 0
|
||||
};
|
||||
/// <summary>AI image-to-3D generations per calendar month (UTC). Business+ only.</summary>
|
||||
public static int MaxMenuAi3dPerMonth(PlanTier tier) => IsBusinessPlus(tier) ? 100 : 0;
|
||||
|
||||
/// <summary>Maximum active menu categories. Free tier is capped at 3; Pro+ is unlimited.</summary>
|
||||
/// <summary>Maximum active menu categories. Free is capped at 10; paid tiers unlimited.</summary>
|
||||
public static int MaxMenuCategories(PlanTier tier) => tier switch
|
||||
{
|
||||
PlanTier.Free => 3,
|
||||
PlanTier.Free => 10,
|
||||
_ => 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>Menu items are unlimited on every tier (Free can fully build the menu).</summary>
|
||||
public static int MaxMenuItems(PlanTier tier) => int.MaxValue;
|
||||
|
||||
/// <summary>CRM (customers, loyalty) is only available on Pro and above.</summary>
|
||||
public static bool CanAccessCrm(PlanTier tier) => tier >= PlanTier.Pro;
|
||||
/// <summary>CRM (customers, loyalty) — Pro and above.</summary>
|
||||
public static bool CanAccessCrm(PlanTier tier) => IsProPlus(tier);
|
||||
|
||||
/// <summary>Statistics and analytics dashboards are only available on Pro and above.</summary>
|
||||
public static bool CanAccessStatistics(PlanTier tier) => tier >= PlanTier.Pro;
|
||||
/// <summary>Statistics / analytics dashboards — Pro and above.</summary>
|
||||
public static bool CanAccessStatistics(PlanTier tier) => IsProPlus(tier);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,10 @@ public enum PlanTier
|
||||
Free = 0,
|
||||
Pro = 1,
|
||||
Business = 2,
|
||||
Enterprise = 3
|
||||
Enterprise = 3,
|
||||
// Appended (not inserted) so existing stored tier ints (Cafe / SubscriptionPayment /
|
||||
// PlanDefinition) keep their meaning — no data migration needed. Display & upgrade
|
||||
// ordering is driven by PlatformPlanDefinition.SortOrder, NOT this numeric value,
|
||||
// and gating uses explicit tier checks (never `tier >= X`).
|
||||
Starter = 4
|
||||
}
|
||||
|
||||
@@ -1,43 +1,37 @@
|
||||
using Meezi.Core.Constants;
|
||||
using Meezi.Core.Enums;
|
||||
|
||||
namespace Meezi.Core.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Serializable per-plan numeric limits, stored as PlatformPlanDefinition.LimitsJson
|
||||
/// and editable by admins. Missing fields default to "unlimited" (or 0 for opt-in
|
||||
/// quotas) so older stored JSON stays safe. Defaults come from <see cref="PlanLimits"/>.
|
||||
/// </summary>
|
||||
public class PlanLimitsData
|
||||
{
|
||||
public int MaxOrdersPerDay { get; set; } = int.MaxValue;
|
||||
public int MaxTables { get; set; } = int.MaxValue;
|
||||
public int MaxTerminals { get; set; } = int.MaxValue;
|
||||
public int MaxCustomers { get; set; } = int.MaxValue;
|
||||
public int MaxSmsPerMonth { get; set; } = int.MaxValue;
|
||||
public int MaxSmsPerMonth { get; set; } = 0;
|
||||
public int MaxBranches { get; set; } = int.MaxValue;
|
||||
public int MaxReportHistoryDays { get; set; } = int.MaxValue;
|
||||
public int MaxMenuCategories { get; set; } = int.MaxValue;
|
||||
public int MaxMenuItems { get; set; } = int.MaxValue;
|
||||
public int MaxMenuAi3dPerMonth { get; set; } = 0;
|
||||
|
||||
public static PlanLimitsData ForTier(Enums.PlanTier tier) => tier switch
|
||||
public static PlanLimitsData ForTier(PlanTier tier) => new()
|
||||
{
|
||||
Enums.PlanTier.Free => new PlanLimitsData
|
||||
{
|
||||
MaxOrdersPerDay = 50,
|
||||
MaxTerminals = 1,
|
||||
MaxCustomers = 50,
|
||||
MaxSmsPerMonth = 0,
|
||||
MaxBranches = 1,
|
||||
MaxReportHistoryDays = 8
|
||||
},
|
||||
Enums.PlanTier.Pro => new PlanLimitsData
|
||||
{
|
||||
MaxOrdersPerDay = int.MaxValue,
|
||||
MaxTerminals = 3,
|
||||
MaxCustomers = int.MaxValue,
|
||||
MaxSmsPerMonth = 50,
|
||||
MaxBranches = 3,
|
||||
MaxReportHistoryDays = 90
|
||||
},
|
||||
Enums.PlanTier.Business => new PlanLimitsData
|
||||
{
|
||||
MaxOrdersPerDay = int.MaxValue,
|
||||
MaxTerminals = int.MaxValue,
|
||||
MaxCustomers = int.MaxValue,
|
||||
MaxSmsPerMonth = 200,
|
||||
MaxBranches = int.MaxValue,
|
||||
MaxReportHistoryDays = int.MaxValue
|
||||
},
|
||||
_ => new PlanLimitsData()
|
||||
MaxOrdersPerDay = PlanLimits.MaxOrdersPerDay(tier),
|
||||
MaxTables = PlanLimits.MaxTables(tier),
|
||||
MaxTerminals = PlanLimits.MaxTerminals(tier),
|
||||
MaxCustomers = PlanLimits.MaxCustomers(tier),
|
||||
MaxSmsPerMonth = PlanLimits.MaxSmsPerMonth(tier),
|
||||
MaxBranches = PlanLimits.MaxBranches(tier),
|
||||
MaxReportHistoryDays = PlanLimits.MaxReportHistoryDays(tier),
|
||||
MaxMenuCategories = PlanLimits.MaxMenuCategories(tier),
|
||||
MaxMenuItems = PlanLimits.MaxMenuItems(tier),
|
||||
MaxMenuAi3dPerMonth = PlanLimits.MaxMenuAi3dPerMonth(tier),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,50 +274,47 @@ public static class PlatformDataSeeder
|
||||
logger.LogInformation("Platform upgrade: added {Count} features", newFeatures.Count);
|
||||
}
|
||||
|
||||
var plans = await db.PlatformPlanDefinitions.ToListAsync();
|
||||
var changed = 0;
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
if (plan.Tier is PlanTier.Free or PlanTier.Enterprise)
|
||||
continue;
|
||||
|
||||
var keys = plan.Tier == PlanTier.Business || plan.Tier == PlanTier.Enterprise
|
||||
? new[] { "menu_3d", "menu_3d_ai", "discover_profile" }
|
||||
: new[] { "menu_3d", "discover_profile" };
|
||||
var merged = MergeFeaturesJson(plan.FeaturesJson ?? "[]", keys);
|
||||
if (merged == plan.FeaturesJson) continue;
|
||||
plan.FeaturesJson = merged;
|
||||
changed++;
|
||||
}
|
||||
|
||||
if (changed > 0)
|
||||
// One-time: bring plan definitions to the current matrix (Free·Starter·Pro·
|
||||
// Business·Enterprise). Existing plans were never admin-editable before this, so
|
||||
// updating their limits/features/order/price to the canonical defaults is safe.
|
||||
// Version-guarded so it runs once and never clobbers later admin edits.
|
||||
const string matrixVersionKey = "catalog.planMatrixVersion";
|
||||
const string matrixVersion = "2";
|
||||
var verSetting = await db.PlatformSettings.FirstOrDefaultAsync(s => s.Key == matrixVersionKey);
|
||||
if (verSetting?.Value != matrixVersion)
|
||||
{
|
||||
// Tier is unique across all rows (incl. soft-deleted), so at most one row per tier.
|
||||
var byTier = (await db.PlatformPlanDefinitions.IgnoreQueryFilters().ToListAsync())
|
||||
.ToDictionary(p => p.Tier);
|
||||
foreach (var def in CanonicalPlans())
|
||||
{
|
||||
if (byTier.TryGetValue(def.Tier, out var ex))
|
||||
{
|
||||
ex.DisplayNameFa = def.DisplayNameFa;
|
||||
ex.DisplayNameEn = def.DisplayNameEn;
|
||||
ex.MonthlyPriceToman = def.MonthlyPriceToman;
|
||||
ex.IsBillableOnline = def.IsBillableOnline;
|
||||
ex.SortOrder = def.SortOrder;
|
||||
ex.LimitsJson = def.LimitsJson;
|
||||
ex.FeaturesJson = def.FeaturesJson;
|
||||
ex.DeletedAt = null; // ensure all five plans are active
|
||||
}
|
||||
else
|
||||
{
|
||||
db.PlatformPlanDefinitions.Add(def);
|
||||
}
|
||||
}
|
||||
if (verSetting is null)
|
||||
db.PlatformSettings.Add(S(matrixVersionKey, matrixVersion, "catalog", "نسخه ماتریس پلنها"));
|
||||
else
|
||||
verSetting.Value = matrixVersion;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Platform upgrade: updated features on {Count} plans", changed);
|
||||
logger.LogInformation("Platform upgrade: applied plan matrix v{Version}", matrixVersion);
|
||||
}
|
||||
|
||||
await EnsureIntegrationSettingsAsync(db, logger);
|
||||
}
|
||||
|
||||
private static string MergeFeaturesJson(string json, params string[] keys)
|
||||
{
|
||||
var list = JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
|
||||
if (list.Contains("*"))
|
||||
return json;
|
||||
|
||||
var updated = false;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!list.Contains(key))
|
||||
{
|
||||
list.Add(key);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return updated ? JsonSerializer.Serialize(list, JsonOpts) : json;
|
||||
}
|
||||
|
||||
private static async Task EnsureIntegrationSettingsAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
var defaults = new[]
|
||||
@@ -368,68 +365,47 @@ public static class PlatformDataSeeder
|
||||
logger.LogInformation("Platform seed: system admin phone {Phone}", phone);
|
||||
}
|
||||
|
||||
// ── Canonical plan matrix (Free·Starter·Pro·Business·Enterprise) ─────────────
|
||||
// Single source of plan DEFAULTS. Free features are broad (KDS, queue, Koja,
|
||||
// offline, reviews, reservations, coupons, employees); paid tiers add the rest.
|
||||
private static readonly string[] FreeFeatures =
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "kds", "queue", "inventory",
|
||||
"reservations", "reviews", "coupons", "discover_profile", "offline", "employees"
|
||||
};
|
||||
private static readonly string[] StarterFeatures =
|
||||
FreeFeatures.Concat(new[] { "watermark_removed", "custom_menu_styling", "review_reply" }).ToArray();
|
||||
private static readonly string[] ProFeatures =
|
||||
StarterFeatures.Concat(new[] { "crm", "reports", "taxes", "hr", "delivery", "expenses", "branches" }).ToArray();
|
||||
private static readonly string[] BusinessFeatures =
|
||||
ProFeatures.Concat(new[] { "menu_3d", "menu_3d_ai" }).ToArray();
|
||||
|
||||
private static PlatformPlanDefinition Plan(
|
||||
string id, PlanTier tier, string fa, string en, decimal price, bool billable, int sort, string[] features) => new()
|
||||
{
|
||||
Id = id,
|
||||
Tier = tier,
|
||||
DisplayNameFa = fa,
|
||||
DisplayNameEn = en,
|
||||
MonthlyPriceToman = price,
|
||||
IsBillableOnline = billable,
|
||||
SortOrder = sort,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(tier), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(features, JsonOpts)
|
||||
};
|
||||
|
||||
private static PlatformPlanDefinition[] CanonicalPlans() =>
|
||||
[
|
||||
Plan("plan_free", PlanTier.Free, "رایگان", "Free", 0, false, 0, FreeFeatures),
|
||||
Plan("plan_starter", PlanTier.Starter, "پایه", "Starter", 690_000, true, 1, StarterFeatures),
|
||||
Plan("plan_pro", PlanTier.Pro, "حرفهای", "Pro", 1_490_000, true, 2, ProFeatures),
|
||||
Plan("plan_business", PlanTier.Business, "کسبوکار", "Business", 3_490_000, true, 3, BusinessFeatures),
|
||||
Plan("plan_enterprise", PlanTier.Enterprise, "سازمانی", "Enterprise", 0, false, 4, new[] { "*" }),
|
||||
];
|
||||
|
||||
private static async Task SeedPlansAsync(AppDbContext db, ILogger logger)
|
||||
{
|
||||
var plans = new[]
|
||||
{
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_free",
|
||||
Tier = PlanTier.Free,
|
||||
DisplayNameFa = "رایگان",
|
||||
DisplayNameEn = "Free",
|
||||
MonthlyPriceToman = 0,
|
||||
IsBillableOnline = false,
|
||||
SortOrder = 0,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Free), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[] { "pos", "menu", "tables", "qr_menu" }, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_pro",
|
||||
Tier = PlanTier.Pro,
|
||||
DisplayNameFa = "حرفهای",
|
||||
DisplayNameEn = "Pro",
|
||||
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Pro),
|
||||
IsBillableOnline = true,
|
||||
SortOrder = 1,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Pro), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
|
||||
"menu_3d", "discover_profile"
|
||||
}, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_business",
|
||||
Tier = PlanTier.Business,
|
||||
DisplayNameFa = "کسبوکار",
|
||||
DisplayNameEn = "Business",
|
||||
MonthlyPriceToman = PlanPricing.MonthlyToman(PlanTier.Business),
|
||||
IsBillableOnline = true,
|
||||
SortOrder = 2,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Business), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[]
|
||||
{
|
||||
"pos", "menu", "tables", "qr_menu", "crm", "coupons", "reports", "kds", "inventory",
|
||||
"hr", "sms", "reservations", "delivery", "expenses", "branches",
|
||||
"menu_3d", "menu_3d_ai", "discover_profile"
|
||||
}, JsonOpts)
|
||||
},
|
||||
new PlatformPlanDefinition
|
||||
{
|
||||
Id = "plan_enterprise",
|
||||
Tier = PlanTier.Enterprise,
|
||||
DisplayNameFa = "سازمانی",
|
||||
DisplayNameEn = "Enterprise",
|
||||
MonthlyPriceToman = 0,
|
||||
IsBillableOnline = false,
|
||||
SortOrder = 3,
|
||||
LimitsJson = JsonSerializer.Serialize(PlanLimitsData.ForTier(PlanTier.Enterprise), JsonOpts),
|
||||
FeaturesJson = JsonSerializer.Serialize(new[] { "*" }, JsonOpts)
|
||||
}
|
||||
};
|
||||
var plans = CanonicalPlans();
|
||||
|
||||
// Tier (not Id) carries the unique constraint, so dedupe on Tier — an
|
||||
// existing Free plan may have a different Id, and inserting another
|
||||
@@ -473,7 +449,14 @@ public static class PlatformDataSeeder
|
||||
F("queue", "صف", "Queue", "operations"),
|
||||
F("menu_3d", "منوی سهبعدی", "3D menu", "growth"),
|
||||
F("menu_3d_ai", "تولید ۳D با هوش مصنوعی", "AI 3D menu", "growth"),
|
||||
F("discover_profile", "پروفایل کشف", "Discover profile", "growth")
|
||||
F("discover_profile", "پروفایل کشف", "Discover profile", "growth"),
|
||||
F("offline", "حالت آفلاین", "Offline mode", "core"),
|
||||
F("employees", "کارکنان", "Employees", "operations"),
|
||||
F("watermark_removed", "حذف واترمارک منو", "Remove menu watermark", "growth"),
|
||||
F("custom_menu_styling", "طراحی اختصاصی منو", "Custom menu styling", "growth"),
|
||||
F("review_reply", "پاسخ به نظرات", "Reply to reviews", "growth"),
|
||||
F("api", "API عمومی", "Public API", "integrations"),
|
||||
F("white_label", "وایتلیبل", "White-label", "integrations")
|
||||
};
|
||||
|
||||
// Key carries the unique constraint, so dedupe on Key (not Id).
|
||||
|
||||
@@ -1114,7 +1114,29 @@
|
||||
"title": "خطط الاشتراك",
|
||||
"monthlyPrice": "السعر الشهري (تومان)",
|
||||
"maxOrders": "حد الطلبات اليومي",
|
||||
"saved": "تم الحفظ"
|
||||
"saved": "تم الحفظ",
|
||||
"active": "مفعل",
|
||||
"nameFa": "الاسم (فارسي)",
|
||||
"nameEn": "الاسم (إنجليزي)",
|
||||
"sortOrder": "الترتيب",
|
||||
"billable": "قابل للدفع عبر الإنترنت",
|
||||
"limitsTitle": "الحدود",
|
||||
"featuresTitle": "الميزات",
|
||||
"allFeatures": "كل الميزات",
|
||||
"allFeaturesNote": "تشمل هذه الباقة جميع الميزات الحالية والمستقبلية.",
|
||||
"save": "حفظ",
|
||||
"limits": {
|
||||
"maxOrders": "طلبات/يوم",
|
||||
"maxTables": "الطاولات",
|
||||
"maxTerminals": "أجهزة POS",
|
||||
"maxBranches": "الفروع",
|
||||
"maxCategories": "فئات القائمة",
|
||||
"maxItems": "أصناف القائمة",
|
||||
"maxCustomers": "العملاء",
|
||||
"maxReportDays": "سجل التقارير (أيام)",
|
||||
"maxSms": "رسائل/شهر",
|
||||
"maxAi3d": "3D/شهر"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "إعدادات التطبيق",
|
||||
|
||||
@@ -1107,7 +1107,29 @@
|
||||
"title": "Subscription plans",
|
||||
"monthlyPrice": "Monthly price (Toman)",
|
||||
"maxOrders": "Max orders per day",
|
||||
"saved": "Plan saved"
|
||||
"saved": "Plan saved",
|
||||
"active": "Active",
|
||||
"nameFa": "Name (Persian)",
|
||||
"nameEn": "Name (English)",
|
||||
"sortOrder": "Sort order",
|
||||
"billable": "Billable online",
|
||||
"limitsTitle": "Limits",
|
||||
"featuresTitle": "Features",
|
||||
"allFeatures": "All features",
|
||||
"allFeaturesNote": "This plan includes all features (current and future).",
|
||||
"save": "Save",
|
||||
"limits": {
|
||||
"maxOrders": "Orders/day",
|
||||
"maxTables": "Tables",
|
||||
"maxTerminals": "POS terminals",
|
||||
"maxBranches": "Branches",
|
||||
"maxCategories": "Menu categories",
|
||||
"maxItems": "Menu items",
|
||||
"maxCustomers": "Customers",
|
||||
"maxReportDays": "Report history (days)",
|
||||
"maxSms": "SMS/month",
|
||||
"maxAi3d": "AI 3D/month"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Application settings",
|
||||
|
||||
@@ -1107,7 +1107,29 @@
|
||||
"title": "پلنها و قیمتگذاری",
|
||||
"monthlyPrice": "قیمت ماهانه (تومان)",
|
||||
"maxOrders": "سقف سفارش روزانه",
|
||||
"saved": "پلن ذخیره شد"
|
||||
"saved": "پلن ذخیره شد",
|
||||
"active": "فعال",
|
||||
"nameFa": "نام (فارسی)",
|
||||
"nameEn": "نام (انگلیسی)",
|
||||
"sortOrder": "ترتیب",
|
||||
"billable": "قابل پرداخت آنلاین",
|
||||
"limitsTitle": "محدودیتها",
|
||||
"featuresTitle": "امکانات",
|
||||
"allFeatures": "همه امکانات",
|
||||
"allFeaturesNote": "این پلن به همه امکانات (فعلی و آینده) دسترسی دارد.",
|
||||
"save": "ذخیره",
|
||||
"limits": {
|
||||
"maxOrders": "سفارش روزانه",
|
||||
"maxTables": "میزها",
|
||||
"maxTerminals": "پایانه POS",
|
||||
"maxBranches": "شعب",
|
||||
"maxCategories": "دسته منو",
|
||||
"maxItems": "آیتم منو",
|
||||
"maxCustomers": "مشتریان",
|
||||
"maxReportDays": "تاریخچه گزارش (روز)",
|
||||
"maxSms": "پیامک ماهانه",
|
||||
"maxAi3d": "تولید ۳D ماهانه"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "تنظیمات اپلیکیشن",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
AdminNotificationRow,
|
||||
AdminPlan,
|
||||
AdminStats,
|
||||
PlanLimitsData,
|
||||
GatewayCredentials,
|
||||
PaymentGatewayConfig,
|
||||
PlatformFeature,
|
||||
@@ -131,45 +132,167 @@ function StatCard({ label, value }: { label: string; value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
|
||||
const PLAN_UNLIMITED = 2147483647;
|
||||
|
||||
const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
|
||||
{ key: "maxOrdersPerDay", label: "maxOrders" },
|
||||
{ key: "maxTables", label: "maxTables" },
|
||||
{ key: "maxTerminals", label: "maxTerminals" },
|
||||
{ key: "maxBranches", label: "maxBranches" },
|
||||
{ key: "maxMenuCategories", label: "maxCategories" },
|
||||
{ key: "maxMenuItems", label: "maxItems" },
|
||||
{ key: "maxCustomers", label: "maxCustomers" },
|
||||
{ key: "maxReportHistoryDays", label: "maxReportDays" },
|
||||
{ key: "maxSmsPerMonth", label: "maxSms" },
|
||||
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
|
||||
];
|
||||
|
||||
function LimitField({ label, value, onChange }: { label: string; value: number; onChange: (n: number) => void }) {
|
||||
const unlimited = value >= PLAN_UNLIMITED;
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 p-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
<label className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<input type="checkbox" checked={unlimited} onChange={(e) => onChange(e.target.checked ? PLAN_UNLIMITED : 0)} />∞
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1 h-8 text-sm"
|
||||
disabled={unlimited}
|
||||
value={unlimited ? "" : value}
|
||||
onChange={(e) => onChange(Math.max(0, Number(e.target.value)))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanCard({
|
||||
plan,
|
||||
features,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
plan: AdminPlan;
|
||||
features: PlatformFeature[];
|
||||
onSave: (p: AdminPlan) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const t = useTranslations("admin.plans");
|
||||
const [price, setPrice] = useState(plan.monthlyPriceToman);
|
||||
const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
|
||||
const [draft, setDraft] = useState<AdminPlan>(plan);
|
||||
// Re-sync from server after a save/refetch.
|
||||
useEffect(() => { setDraft(plan); }, [plan]);
|
||||
|
||||
// Sync server values if they change (e.g. after successful save + refetch)
|
||||
useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
|
||||
useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
|
||||
const setField = <K extends keyof AdminPlan>(k: K, v: AdminPlan[K]) =>
|
||||
setDraft((d) => ({ ...d, [k]: v }));
|
||||
const setLimit = (k: keyof PlanLimitsData, v: number) =>
|
||||
setDraft((d) => ({ ...d, limits: { ...d.limits, [k]: v } }));
|
||||
|
||||
const flush = () =>
|
||||
onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
|
||||
const wildcard = draft.featureKeys.includes("*");
|
||||
const toggleFeature = (key: string, on: boolean) =>
|
||||
setDraft((d) => {
|
||||
const set = new Set(d.featureKeys.filter((k) => k !== "*"));
|
||||
if (on) set.add(key);
|
||||
else set.delete(key);
|
||||
return { ...d, featureKeys: Array.from(set) };
|
||||
});
|
||||
|
||||
const groups = Array.from(new Set(features.map((f) => f.moduleGroup)));
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl border border-border/80">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{plan.displayNameFa}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{plan.tier}</p>
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 pb-2">
|
||||
<div>
|
||||
<CardTitle className="text-base">{draft.displayNameFa || draft.tier}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{draft.tier}</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={draft.isActive} onChange={(e) => setField("isActive", e.target.checked)} />
|
||||
{t("active")}
|
||||
</label>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-sm">
|
||||
{t("monthlyPrice")}
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(Number(e.target.value))}
|
||||
onBlur={flush}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("maxOrders")}
|
||||
<Input
|
||||
type="number"
|
||||
className="mt-1"
|
||||
value={maxOrders}
|
||||
onChange={(e) => setMaxOrders(Number(e.target.value))}
|
||||
onBlur={flush}
|
||||
/>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<label className="text-sm">
|
||||
{t("nameFa")}
|
||||
<Input className="mt-1 h-8" value={draft.displayNameFa} onChange={(e) => setField("displayNameFa", e.target.value)} dir="rtl" />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("nameEn")}
|
||||
<Input className="mt-1 h-8" value={draft.displayNameEn ?? ""} onChange={(e) => setField("displayNameEn", e.target.value)} dir="ltr" />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("monthlyPrice")}
|
||||
<Input type="number" className="mt-1 h-8" value={draft.monthlyPriceToman} onChange={(e) => setField("monthlyPriceToman", Number(e.target.value))} />
|
||||
</label>
|
||||
<label className="text-sm">
|
||||
{t("sortOrder")}
|
||||
<Input type="number" className="mt-1 h-8" value={draft.sortOrder} onChange={(e) => setField("sortOrder", Number(e.target.value))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex w-fit items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={draft.isBillableOnline} onChange={(e) => setField("isBillableOnline", e.target.checked)} />
|
||||
{t("billable")}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold text-muted-foreground">{t("limitsTitle")}</p>
|
||||
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<LimitField key={f.key} label={t(`limits.${f.label}`)} value={draft.limits[f.key] ?? PLAN_UNLIMITED} onChange={(v) => setLimit(f.key, v)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t("featuresTitle")}</p>
|
||||
<label className="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" checked={wildcard} onChange={(e) => setField("featureKeys", e.target.checked ? ["*"] : [])} />
|
||||
{t("allFeatures")}
|
||||
</label>
|
||||
</div>
|
||||
{wildcard ? (
|
||||
<p className="text-xs text-muted-foreground">{t("allFeaturesNote")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{groups.map((g) => (
|
||||
<div key={g}>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wide text-muted-foreground">{g}</p>
|
||||
<div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{features
|
||||
.filter((f) => f.moduleGroup === g)
|
||||
.map((f) => (
|
||||
<label
|
||||
key={f.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-border/60 px-2 py-1.5 text-sm",
|
||||
!f.isEnabledGlobally && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<input type="checkbox" checked={draft.featureKeys.includes(f.key)} onChange={(e) => toggleFeature(f.key, e.target.checked)} />
|
||||
<span className="truncate">{f.displayNameFa}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -182,6 +305,10 @@ export function AdminPlansScreen() {
|
||||
queryKey: ["admin", "plans"],
|
||||
queryFn: () => adminGet<AdminPlan[]>("/api/admin/plans"),
|
||||
});
|
||||
const { data: features = [] } = useQuery({
|
||||
queryKey: ["admin", "features"],
|
||||
queryFn: () => adminGet<PlatformFeature[]>("/api/admin/features"),
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (plan: AdminPlan) =>
|
||||
@@ -201,11 +328,19 @@ export function AdminPlansScreen() {
|
||||
},
|
||||
});
|
||||
|
||||
const ordered = [...plans].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-lg font-medium">{t("title")}</h1>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard key={plan.tier} plan={plan} onSave={(p) => save.mutate(p)} />
|
||||
{ordered.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.tier}
|
||||
plan={plan}
|
||||
features={features}
|
||||
onSave={(p) => save.mutate(p)}
|
||||
saving={save.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,15 @@ export type AdminStats = {
|
||||
|
||||
export type PlanLimitsData = {
|
||||
maxOrdersPerDay: number;
|
||||
maxTables: number;
|
||||
maxTerminals: number;
|
||||
maxCustomers: number;
|
||||
maxSmsPerMonth: number;
|
||||
maxBranches: number;
|
||||
maxReportHistoryDays: number;
|
||||
maxMenuCategories: number;
|
||||
maxMenuItems: number;
|
||||
maxMenuAi3dPerMonth: number;
|
||||
};
|
||||
|
||||
export type AdminPlan = {
|
||||
|
||||
Reference in New Issue
Block a user