diff --git a/docker-compose.admin.yml b/docker-compose.admin.yml index d0ef233..6f4ed0f 100644 --- a/docker-compose.admin.yml +++ b/docker-compose.admin.yml @@ -35,6 +35,7 @@ services: Cors__Origins__0: "${CORS_ADMIN_ORIGIN_0:-http://localhost:3102}" Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}" Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}" + Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}" ports: - "${ADMIN_API_PORT:-5081}:8080" healthcheck: diff --git a/docker-compose.yml b/docker-compose.yml index 282792a..f9ace7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,6 +81,7 @@ services: Cors__Origins__2: "${CORS_ORIGIN_2:-http://localhost:3103}" Auth__MaxOtpAttemptsPerHour: "${OTP_RATE_LIMIT:-100}" Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}" + Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}" Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}" ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}" ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}" diff --git a/src/Meezi.API/Controllers/SmsController.cs b/src/Meezi.API/Controllers/SmsController.cs index fc67984..6d43313 100644 --- a/src/Meezi.API/Controllers/SmsController.cs +++ b/src/Meezi.API/Controllers/SmsController.cs @@ -11,16 +11,32 @@ namespace Meezi.API.Controllers; public class SmsController : CafeApiControllerBase { private readonly ISmsMarketingService _smsMarketingService; + private readonly ISmsService _smsService; private readonly IValidator _campaignValidator; public SmsController( ISmsMarketingService smsMarketingService, + ISmsService smsService, IValidator campaignValidator) { _smsMarketingService = smsMarketingService; + _smsService = smsService; _campaignValidator = campaignValidator; } + [HttpGet("balance")] + public async Task GetBalance(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) + { + if (EnsureCafeAccess(cafeId, tenant) is { } denied) return denied; + + var info = await _smsService.GetAccountInfoAsync(cancellationToken); + var dto = info is not null + ? new SmsBalanceDto(info.RemainCredit, info.AccountType, true) + : new SmsBalanceDto(0, "master", false); + + return Ok(new ApiResponse(true, dto)); + } + [HttpGet("usage")] public async Task GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken) { diff --git a/src/Meezi.API/Models/Crm/SmsDtos.cs b/src/Meezi.API/Models/Crm/SmsDtos.cs index e7f8caa..6787a4d 100644 --- a/src/Meezi.API/Models/Crm/SmsDtos.cs +++ b/src/Meezi.API/Models/Crm/SmsDtos.cs @@ -10,3 +10,6 @@ public record SendSmsCampaignRequest( public record SmsCampaignResult(int SentCount, int FailedCount); public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month); + +/// Kavenegar account credit balance returned to the dashboard. +public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured); diff --git a/src/Meezi.API/Services/SmsMarketingService.cs b/src/Meezi.API/Services/SmsMarketingService.cs index 3088c92..bd81975 100644 --- a/src/Meezi.API/Services/SmsMarketingService.cs +++ b/src/Meezi.API/Services/SmsMarketingService.cs @@ -24,18 +24,15 @@ public class SmsMarketingService : ISmsMarketingService private readonly AppDbContext _db; private readonly ISmsService _smsService; private readonly IConnectionMultiplexer _redis; - private readonly ILogger _logger; public SmsMarketingService( AppDbContext db, ISmsService smsService, - IConnectionMultiplexer redis, - ILogger logger) + IConnectionMultiplexer redis) { _db = db; _smsService = smsService; _redis = redis; - _logger = logger; } public async Task GetUsageAsync( @@ -68,27 +65,12 @@ public class SmsMarketingService : ISmsMarketingService if (maxSms != int.MaxValue && used + phones.Count > maxSms) return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded."); - var sent = 0; - var failed = 0; + var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken); - foreach (var phone in phones) - { - try - { - await _smsService.SendMessageAsync(phone, request.Message, cancellationToken); - sent++; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to send SMS to recipient"); - failed++; - } - } + if (result.SentCount > 0) + await IncrementUsageAsync(cafeId, month, result.SentCount); - if (sent > 0) - await IncrementUsageAsync(cafeId, month, sent); - - return (true, new SmsCampaignResult(sent, failed), null, null); + return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null); } private async Task> ResolvePhonesAsync( diff --git a/src/Meezi.API/appsettings.json b/src/Meezi.API/appsettings.json index 1b7bf96..667f251 100644 --- a/src/Meezi.API/appsettings.json +++ b/src/Meezi.API/appsettings.json @@ -37,6 +37,7 @@ }, "Kavenegar": { "ApiKey": "", + "SenderNumber": "90005671", "OtpTemplate": "verify" }, "ZarinPal": { diff --git a/src/Meezi.Admin.API/Models/IntegrationDtos.cs b/src/Meezi.Admin.API/Models/IntegrationDtos.cs index 62a19a0..ddfc5ed 100644 --- a/src/Meezi.Admin.API/Models/IntegrationDtos.cs +++ b/src/Meezi.Admin.API/Models/IntegrationDtos.cs @@ -26,6 +26,7 @@ public record KavenegarConfigDto( bool IsEnabled, string? ApiKey, string OtpTemplate, + string SenderNumber, bool HasStoredApiKey); public record OpenAiIntegrationConfigDto( @@ -92,7 +93,8 @@ public record UpdatePaymentGatewayRequest( public record UpdateKavenegarRequest( bool IsEnabled, string? ApiKey, - string OtpTemplate); + string OtpTemplate, + string SenderNumber); public record AdminNotificationRowDto( string Id, diff --git a/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs b/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs index b156d5b..5436ef3 100644 --- a/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs +++ b/src/Meezi.Admin.API/Services/PlatformIntegrationService.cs @@ -17,9 +17,10 @@ public interface IPlatformIntegrationService public class PlatformIntegrationService : IPlatformIntegrationService { public const string KeyActiveGateway = "payment.activeGateway"; - public const string KeyKavenegarApi = "integrations.kavenegar.apiKey"; - public const string KeyKavenegarOtpTemplate = "integrations.kavenegar.otpTemplate"; - public const string KeyKavenegarEnabled = "integrations.kavenegar.enabled"; + public const string KeyKavenegarApi = "integrations.kavenegar.apiKey"; + public const string KeyKavenegarOtpTemplate = "integrations.kavenegar.otpTemplate"; + public const string KeyKavenegarEnabled = "integrations.kavenegar.enabled"; + public const string KeyKavenegarSender = "integrations.kavenegar.senderNumber"; private static readonly (string Id, string NameFa, string Prefix)[] Gateways = [ @@ -56,6 +57,7 @@ public class PlatformIntegrationService : IPlatformIntegrationService map.GetValueOrDefault(KeyKavenegarEnabled) is "true", MaskSecret(map.GetValueOrDefault(KeyKavenegarApi)), map.GetValueOrDefault(KeyKavenegarOtpTemplate) ?? "verify", + map.GetValueOrDefault(KeyKavenegarSender) ?? string.Empty, HasSecret(map, KeyKavenegarApi)); var ai = new AiIntegrationsConfigDto( @@ -109,6 +111,8 @@ public class PlatformIntegrationService : IPlatformIntegrationService await UpsertAsync(KeyKavenegarOtpTemplate, request.Kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct); if (!string.IsNullOrWhiteSpace(request.Kavenegar.ApiKey) && !IsMaskedPlaceholder(request.Kavenegar.ApiKey)) await UpsertAsync(KeyKavenegarApi, request.Kavenegar.ApiKey.Trim(), "integrations", "API Key کاوه‌نگار", ct); + if (!string.IsNullOrWhiteSpace(request.Kavenegar.SenderNumber)) + await UpsertAsync(KeyKavenegarSender, request.Kavenegar.SenderNumber.Trim(), "integrations", "شماره فرستنده کاوه‌نگار", ct); await UpsertAsync(PlatformIntegrationKeys.OpenAiEnabled, request.Ai.OpenAi.IsEnabled ? "true" : "false", "integrations", "فعال OpenAI", ct); await UpsertAsync(PlatformIntegrationKeys.OpenAiModel, string.IsNullOrWhiteSpace(request.Ai.OpenAi.Model) ? "gpt-4o-mini" : request.Ai.OpenAi.Model.Trim(), "integrations", "مدل OpenAI", ct); diff --git a/src/Meezi.Core/Interfaces/ISmsService.cs b/src/Meezi.Core/Interfaces/ISmsService.cs index f9f037f..2f057c8 100644 --- a/src/Meezi.Core/Interfaces/ISmsService.cs +++ b/src/Meezi.Core/Interfaces/ISmsService.cs @@ -1,7 +1,23 @@ namespace Meezi.Core.Interfaces; +public record KavenegarAccountInfo(long RemainCredit, string AccountType); + +public record BulkSendResult(int SentCount, int FailedCount); + public interface ISmsService { + /// Send a one-time password via Kavenegar Verify/Lookup template. Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default); + + /// Send a plain-text message to a single recipient. Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default); + + /// + /// Send the same message to many recipients in batches of up to 200. + /// Never throws — failures per batch are counted and returned. + /// + Task SendBulkAsync(IReadOnlyList phones, string message, CancellationToken cancellationToken = default); + + /// Returns credit balance from the Kavenegar account, or null if not configured. + Task GetAccountInfoAsync(CancellationToken cancellationToken = default); } diff --git a/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs b/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs index 5ab65a4..0001b7b 100644 --- a/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs +++ b/src/Meezi.Infrastructure/ExternalServices/KavenegarSmsService.cs @@ -1,13 +1,28 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; using Meezi.Core.Interfaces; +using Meezi.Infrastructure.Services.Platform; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Meezi.Infrastructure.ExternalServices; +/// +/// Kavenegar SMS gateway implementation. +/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back +/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.). +/// public class KavenegarSmsService : ISmsService { + // ── DB config keys ──────────────────────────────────────────────────────── + private const string DbKeyApiKey = "integrations.kavenegar.apiKey"; + private const string DbKeyEnabled = "integrations.kavenegar.enabled"; + private const string DbKeySender = "integrations.kavenegar.senderNumber"; + private const string DbKeyOtpTemplate = "integrations.kavenegar.otpTemplate"; + + private const string BaseUrl = "https://api.kavenegar.com/v1"; + private const int MaxBatchSize = 200; + private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; private readonly IPlatformRuntimeConfig _platform; @@ -25,64 +40,194 @@ public class KavenegarSmsService : ISmsService _logger = logger; } - public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default) - { - var apiKey = await GetApiKeyAsync(cancellationToken); - if (string.IsNullOrWhiteSpace(apiKey)) - { - _logger.LogInformation( - "Kavenegar API key not configured — SMS to {Phone}: {Message}", - phone, - message); - return; - } - - var url = - $"https://api.kavenegar.com/v1/{apiKey}/sms/send.json" + - $"?receptor={Uri.EscapeDataString(phone)}&message={Uri.EscapeDataString(message)}"; - - var response = await _httpClient.GetAsync(url, cancellationToken); - if (!response.IsSuccessStatusCode) - { - _logger.LogWarning("Kavenegar SMS send failed with status {StatusCode}", response.StatusCode); - throw new InvalidOperationException("SMS delivery failed."); - } - - var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - if (body?.Return?.Status is not 200) - throw new InvalidOperationException("SMS delivery failed."); - } + // ── Public interface ────────────────────────────────────────────────────── public async Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default) { - var apiKey = await GetApiKeyAsync(cancellationToken); - var template = await GetOtpTemplateAsync(cancellationToken); - + var (apiKey, _, template) = await GetConfigAsync(cancellationToken); if (string.IsNullOrWhiteSpace(apiKey)) { - _logger.LogInformation("Kavenegar API key not configured — OTP for {Phone} (dev only, not sent via SMS)", phone); + _logger.LogInformation("Kavenegar not configured — OTP for {Phone} not sent", phone); return; } - var url = $"https://api.kavenegar.com/v1/{apiKey}/verify/lookup.json" + - $"?receptor={Uri.EscapeDataString(phone)}&token={otp}&template={Uri.EscapeDataString(template)}"; - - var response = await _httpClient.GetAsync(url, cancellationToken); - if (!response.IsSuccessStatusCode) + var url = $"{BaseUrl}/{apiKey}/verify/lookup.json"; + var content = new FormUrlEncodedContent(new Dictionary { - _logger.LogWarning("Kavenegar OTP send failed with status {StatusCode}", response.StatusCode); - throw new InvalidOperationException("SMS delivery failed."); + ["receptor"] = phone, + ["token"] = otp, + ["template"] = template, + }); + + var response = await _httpClient.PostAsync(url, content, cancellationToken); + await EnsureKavenegarSuccessAsync(response, "OTP", cancellationToken); + } + + public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default) + { + var (apiKey, sender, _) = await GetConfigAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(apiKey)) + { + _logger.LogInformation("Kavenegar not configured — SMS to {Phone}: {Message}", phone, message); + return; } - var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); - if (body?.Return?.Status is not 200) + var url = $"{BaseUrl}/{apiKey}/sms/send.json"; + var content = BuildSendForm(phone, message, sender); + + var response = await _httpClient.PostAsync(url, content, cancellationToken); + await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken); + } + + public async Task SendBulkAsync( + IReadOnlyList phones, + string message, + CancellationToken cancellationToken = default) + { + if (phones.Count == 0) return new BulkSendResult(0, 0); + + var (apiKey, sender, _) = await GetConfigAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(apiKey)) { - _logger.LogWarning("Kavenegar returned status {Status}", body?.Return?.Status); - throw new InvalidOperationException("SMS delivery failed."); + _logger.LogInformation("Kavenegar not configured — bulk SMS skipped ({Count} recipients)", phones.Count); + return new BulkSendResult(0, phones.Count); + } + + var url = $"{BaseUrl}/{apiKey}/sms/send.json"; + int sent = 0, failed = 0; + + foreach (var batch in phones.Chunk(MaxBatchSize)) + { + try + { + // Kavenegar /sms/send.json accepts comma-separated receptors + var content = BuildSendForm(string.Join(",", batch), message, sender); + var response = await _httpClient.PostAsync(url, content, cancellationToken); + await EnsureKavenegarSuccessAsync(response, "BulkSend", cancellationToken); + sent += batch.Length; + _logger.LogInformation("Kavenegar bulk batch: {Count} sent", batch.Length); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Kavenegar bulk batch failed ({Count} recipients)", batch.Length); + failed += batch.Length; + } + } + + return new BulkSendResult(sent, failed); + } + + public async Task GetAccountInfoAsync(CancellationToken cancellationToken = default) + { + var (apiKey, _, _) = await GetConfigAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(apiKey)) return null; + + try + { + var url = $"{BaseUrl}/{apiKey}/account/info.json"; + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Kavenegar account info returned HTTP {Status}", response.StatusCode); + return null; + } + + var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (body?.Return?.Status is not 200 || body.Entries is null) + return null; + + return new KavenegarAccountInfo(body.Entries.RemainCredit, body.Entries.Type ?? "master"); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch Kavenegar account info"); + return null; } } - private sealed class KavenegarResponse + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static FormUrlEncodedContent BuildSendForm(string receptor, string message, string sender) + { + var dict = new Dictionary + { + ["receptor"] = receptor, + ["message"] = message, + }; + if (!string.IsNullOrWhiteSpace(sender)) + dict["sender"] = sender; + return new FormUrlEncodedContent(dict); + } + + private async Task EnsureKavenegarSuccessAsync( + HttpResponseMessage response, + string operation, + CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + { + var errorCode = (int)response.StatusCode; + var detail = KavenegarHttpError(errorCode); + _logger.LogWarning("Kavenegar {Op} HTTP {Code}: {Detail}", operation, errorCode, detail); + throw new InvalidOperationException($"Kavenegar {operation} failed (HTTP {errorCode}): {detail}"); + } + + var body = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + if (body?.Return?.Status is not 200) + { + var status = body?.Return?.Status ?? -1; + _logger.LogWarning("Kavenegar {Op} returned status {Status}: {Message}", operation, status, body?.Return?.Message); + throw new InvalidOperationException($"Kavenegar {operation} failed (status {status}): {body?.Return?.Message}"); + } + } + + private static string KavenegarHttpError(int code) => code switch + { + 400 => "Missing or invalid parameters", + 401 => "Account is inactive", + 403 => "Invalid API key", + 404 => "Method not found", + 405 => "Wrong HTTP method", + 411 => "Invalid recipient number", + 412 => "Invalid sender number", + 413 => "Message empty or too long", + 414 => "Too many recipients", + 417 => "Invalid scheduled date", + 418 => "Insufficient credit", + 422 => "Invalid characters in message", + 424 => "OTP template not found", + 426 => "IP is not whitelisted", + 428 => "Voice call requires numeric token", + 432 => "Code parameter missing in OTP template", + _ => "Unknown error" + }; + + private async Task<(string? ApiKey, string Sender, string OtpTemplate)> GetConfigAsync(CancellationToken ct) + { + var enabled = await _platform.GetAsync(DbKeyEnabled, ct); + // If explicitly disabled in DB, short-circuit + if (enabled is "false") + return (null, string.Empty, "verify"); + + var apiKey = await _platform.GetAsync(DbKeyApiKey, ct); + if (string.IsNullOrWhiteSpace(apiKey)) + apiKey = _configuration["Kavenegar:ApiKey"]; + + var sender = await _platform.GetAsync(DbKeySender, ct); + if (string.IsNullOrWhiteSpace(sender)) + sender = _configuration["Kavenegar:SenderNumber"] ?? string.Empty; + + var template = await _platform.GetAsync(DbKeyOtpTemplate, ct); + if (string.IsNullOrWhiteSpace(template)) + template = _configuration["Kavenegar:OtpTemplate"] ?? "verify"; + + return (apiKey, sender, template); + } + + // ── Response models ─────────────────────────────────────────────────────── + + private sealed class KavenegarReturnEnvelope { [JsonPropertyName("return")] public KavenegarReturn? Return { get; set; } @@ -92,21 +237,29 @@ public class KavenegarSmsService : ISmsService { [JsonPropertyName("status")] public int Status { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } } - private async Task GetApiKeyAsync(CancellationToken cancellationToken) + private sealed class KavenegarAccountInfoResponse { - var fromDb = await _platform.GetAsync("integrations.kavenegar.apiKey", cancellationToken); - if (!string.IsNullOrWhiteSpace(fromDb)) - return fromDb; - return _configuration["Kavenegar:ApiKey"]; + [JsonPropertyName("return")] + public KavenegarReturn? Return { get; set; } + + [JsonPropertyName("entries")] + public KavenegarAccountEntries? Entries { get; set; } } - private async Task GetOtpTemplateAsync(CancellationToken cancellationToken) + private sealed class KavenegarAccountEntries { - var fromDb = await _platform.GetAsync("integrations.kavenegar.otpTemplate", cancellationToken); - if (!string.IsNullOrWhiteSpace(fromDb)) - return fromDb; - return _configuration["Kavenegar:OtpTemplate"] ?? "verify"; + [JsonPropertyName("remaincredit")] + public long RemainCredit { get; set; } + + [JsonPropertyName("expiredate")] + public long ExpireDate { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } } } diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json index 7b55f83..c96f735 100644 --- a/web/dashboard/messages/en.json +++ b/web/dashboard/messages/en.json @@ -378,7 +378,16 @@ "usage": "Usage this month", "unlimited": "Unlimited", "sent": "Sent", - "failed": "Failed" + "failed": "Failed", + "charCount": "{count} chars", + "smsPartsHint": "{parts} SMS", + "balance": "Account credit", + "balanceAmount": "{amount} Rials", + "balanceNotConfigured": "Kavenegar not configured", + "sender": "Sender line", + "recipientsCount": "{count} recipients", + "sendConfirm": "Send to {count} people?", + "sending": "Sending..." }, "reports": { "title": "Reports & analytics", diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json index a4d2f08..af2ec22 100644 --- a/web/dashboard/messages/fa.json +++ b/web/dashboard/messages/fa.json @@ -378,7 +378,16 @@ "usage": "مصرف این ماه", "unlimited": "نامحدود", "sent": "ارسال شد", - "failed": "ناموفق" + "failed": "ناموفق", + "charCount": "{count} حرف", + "smsPartsHint": "{parts} پیامک", + "balance": "اعتبار حساب", + "balanceAmount": "{amount} ریال", + "balanceNotConfigured": "Kavenegar پیکربندی نشده", + "sender": "خط فرستنده", + "recipientsCount": "{count} مخاطب", + "sendConfirm": "ارسال به {count} نفر؟", + "sending": "در حال ارسال..." }, "reports": { "title": "گزارش‌ها و تحلیل", diff --git a/web/dashboard/src/components/sms/sms-screen.tsx b/web/dashboard/src/components/sms/sms-screen.tsx index 58acab4..daee3e1 100644 --- a/web/dashboard/src/components/sms/sms-screen.tsx +++ b/web/dashboard/src/components/sms/sms-screen.tsx @@ -1,34 +1,57 @@ "use client"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; +import { useTranslations, useLocale } from "next-intl"; +import { MessageSquare, Zap, Users } from "lucide-react"; import { apiGet, apiPost } from "@/lib/api/client"; -import type { CustomerGroup, SmsCampaignResult, SmsUsage } from "@/lib/api/types"; +import type { CustomerGroup, SmsCampaignResult, SmsUsage, SmsBalance } from "@/lib/api/types"; import { useAuthStore } from "@/lib/stores/auth.store"; import { formatNumber } from "@/lib/format"; import { Button } from "@/components/ui/button"; import { LabeledField } from "@/components/ui/labeled-field"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"]; +/** Kavenegar SMS character limits. */ +function calcSmsParts(text: string): { chars: number; parts: number } { + const chars = text.length; + if (chars === 0) return { chars: 0, parts: 0 }; + // Persian / Arabic chars → use 70-char parts (single) / 67-char parts (multi) + const hasPersian = /[؀-ۿ]/.test(text); + const single = hasPersian ? 70 : 160; + const multi = hasPersian ? 67 : 153; + const parts = chars <= single ? 1 : Math.ceil(chars / multi); + return { chars, parts }; +} + export function SmsScreen() { - const t = useTranslations("sms"); + const t = useTranslations("sms"); const tCrm = useTranslations("crm"); + const locale = useLocale(); const cafeId = useAuthStore((s) => s.user?.cafeId); const queryClient = useQueryClient(); const [message, setMessage] = useState(""); - const [target, setTarget] = useState("all"); - const [result, setResult] = useState(null); + const [target, setTarget] = useState("all"); + const [result, setResult] = useState(null); + // ── API queries ───────────────────────────────────────────────────────────── const { data: usage } = useQuery({ queryKey: ["sms-usage", cafeId], queryFn: () => apiGet(`/api/cafes/${cafeId}/sms/usage`), enabled: !!cafeId, }); + const { data: balance } = useQuery({ + queryKey: ["sms-balance", cafeId], + queryFn: () => apiGet(`/api/cafes/${cafeId}/sms/balance`), + enabled: !!cafeId, + staleTime: 60_000, + }); + const sendCampaign = useMutation({ mutationFn: () => apiPost(`/api/cafes/${cafeId}/sms/campaign`, { @@ -39,35 +62,92 @@ export function SmsScreen() { setResult(data); setMessage(""); queryClient.invalidateQueries({ queryKey: ["sms-usage", cafeId] }); + queryClient.invalidateQueries({ queryKey: ["sms-balance", cafeId] }); }, }); - if (!cafeId) return null; + // ── Derived state ──────────────────────────────────────────────────────────── + const { chars, parts } = useMemo(() => calcSmsParts(message), [message]); + + const usagePct = useMemo(() => { + if (!usage || usage.monthlyLimit <= 0) return null; + return Math.min(100, Math.round((usage.usedThisMonth / usage.monthlyLimit) * 100)); + }, [usage]); const usageLabel = usage?.monthlyLimit === -1 ? t("unlimited") - : `${formatNumber(usage?.usedThisMonth ?? 0)} / ${formatNumber(usage?.monthlyLimit ?? 0)}`; + : `${formatNumber(usage?.usedThisMonth ?? 0, locale)} / ${formatNumber(usage?.monthlyLimit ?? 0, locale)}`; + + if (!cafeId) return null; return ( -
+

{t("title")}

- - - {t("usage")} - - -

{usageLabel}

-
-
+ {/* ── Status row ──────────────────────────────────────────────────────── */} +
+ {/* Usage */} + + +

+ + {t("usage")} +

+

{usageLabel}

+ {usagePct !== null && ( +
+
= 90 ? "bg-destructive" : "bg-primary" + )} + style={{ width: `${usagePct}%` }} + /> +
+ )} + + + {/* Balance */} + + +

+ + {t("balance")} +

+ {balance?.isConfigured ? ( +

+ {t("balanceAmount", { amount: formatNumber(balance.remainCredit, locale) })} +

+ ) : ( +

{t("balanceNotConfigured")}

+ )} +
+
+ + {/* Sender */} + + +

+ + {t("sender")} +

+

+ 90005671 +

+
+
+
+ + {/* ── Campaign form ────────────────────────────────────────────────────── */} + {/* Target group */} + + {/* Message textarea with char counter */} -