Fully implement Kavenegar SMS support
Core changes: - ISmsService: add SendBulkAsync (batches of 200) + GetAccountInfoAsync - KavenegarSmsService: POST requests, sender number config, bulk send via comma-separated receptors, account balance, full error code mapping (HTTP 400-432), enabled-flag check before any send - SmsMarketingService: replaced per-recipient loop with SendBulkAsync - SmsController: new GET /sms/balance endpoint returns Kavenegar credit - SmsDtos: SmsBalanceDto - IntegrationDtos + PlatformIntegrationService: SenderNumber field - appsettings.json + docker-compose: Kavenegar__SenderNumber = 90005671 Dashboard: - sms-screen: char counter, SMS parts indicator (Persian 70/67 chars, Latin 160/153), account balance card, sender line display, result banner Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ services:
|
|||||||
Cors__Origins__0: "${CORS_ADMIN_ORIGIN_0:-http://localhost:3102}"
|
Cors__Origins__0: "${CORS_ADMIN_ORIGIN_0:-http://localhost:3102}"
|
||||||
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
|
Cors__Origins__1: "${CORS_ORIGIN_0:-http://localhost:3101}"
|
||||||
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
||||||
|
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
|
||||||
ports:
|
ports:
|
||||||
- "${ADMIN_API_PORT:-5081}:8080"
|
- "${ADMIN_API_PORT:-5081}:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ services:
|
|||||||
Cors__Origins__2: "${CORS_ORIGIN_2:-http://localhost:3103}"
|
Cors__Origins__2: "${CORS_ORIGIN_2:-http://localhost:3103}"
|
||||||
Auth__MaxOtpAttemptsPerHour: "${OTP_RATE_LIMIT:-100}"
|
Auth__MaxOtpAttemptsPerHour: "${OTP_RATE_LIMIT:-100}"
|
||||||
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
Kavenegar__ApiKey: "${KAVENEGAR_API_KEY:-}"
|
||||||
|
Kavenegar__SenderNumber: "${KAVENEGAR_SENDER:-90005671}"
|
||||||
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
Snappfood__WebhookSecret: "${SNAPPFOOD_WEBHOOK_SECRET:-meezi-dev-snappfood-secret}"
|
||||||
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
ZarinPal__MerchantId: "${ZARINPAL_MERCHANT_ID:-}"
|
||||||
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
ZarinPal__Sandbox: "${ZARINPAL_SANDBOX:-true}"
|
||||||
|
|||||||
@@ -11,16 +11,32 @@ namespace Meezi.API.Controllers;
|
|||||||
public class SmsController : CafeApiControllerBase
|
public class SmsController : CafeApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ISmsMarketingService _smsMarketingService;
|
private readonly ISmsMarketingService _smsMarketingService;
|
||||||
|
private readonly ISmsService _smsService;
|
||||||
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
private readonly IValidator<SendSmsCampaignRequest> _campaignValidator;
|
||||||
|
|
||||||
public SmsController(
|
public SmsController(
|
||||||
ISmsMarketingService smsMarketingService,
|
ISmsMarketingService smsMarketingService,
|
||||||
|
ISmsService smsService,
|
||||||
IValidator<SendSmsCampaignRequest> campaignValidator)
|
IValidator<SendSmsCampaignRequest> campaignValidator)
|
||||||
{
|
{
|
||||||
_smsMarketingService = smsMarketingService;
|
_smsMarketingService = smsMarketingService;
|
||||||
|
_smsService = smsService;
|
||||||
_campaignValidator = campaignValidator;
|
_campaignValidator = campaignValidator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("balance")]
|
||||||
|
public async Task<IActionResult> 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<SmsBalanceDto>(true, dto));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("usage")]
|
[HttpGet("usage")]
|
||||||
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
public async Task<IActionResult> GetUsage(string cafeId, ITenantContext tenant, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ public record SendSmsCampaignRequest(
|
|||||||
public record SmsCampaignResult(int SentCount, int FailedCount);
|
public record SmsCampaignResult(int SentCount, int FailedCount);
|
||||||
|
|
||||||
public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
|
public record SmsUsageDto(int UsedThisMonth, int MonthlyLimit, string Month);
|
||||||
|
|
||||||
|
/// <summary>Kavenegar account credit balance returned to the dashboard.</summary>
|
||||||
|
public record SmsBalanceDto(long RemainCredit, string AccountType, bool IsConfigured);
|
||||||
|
|||||||
@@ -24,18 +24,15 @@ public class SmsMarketingService : ISmsMarketingService
|
|||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
private readonly ISmsService _smsService;
|
private readonly ISmsService _smsService;
|
||||||
private readonly IConnectionMultiplexer _redis;
|
private readonly IConnectionMultiplexer _redis;
|
||||||
private readonly ILogger<SmsMarketingService> _logger;
|
|
||||||
|
|
||||||
public SmsMarketingService(
|
public SmsMarketingService(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ISmsService smsService,
|
ISmsService smsService,
|
||||||
IConnectionMultiplexer redis,
|
IConnectionMultiplexer redis)
|
||||||
ILogger<SmsMarketingService> logger)
|
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_smsService = smsService;
|
_smsService = smsService;
|
||||||
_redis = redis;
|
_redis = redis;
|
||||||
_logger = logger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<SmsUsageDto> GetUsageAsync(
|
public async Task<SmsUsageDto> GetUsageAsync(
|
||||||
@@ -68,27 +65,12 @@ public class SmsMarketingService : ISmsMarketingService
|
|||||||
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
|
if (maxSms != int.MaxValue && used + phones.Count > maxSms)
|
||||||
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
|
return (false, null, "PLAN_LIMIT_REACHED", "Monthly SMS limit would be exceeded.");
|
||||||
|
|
||||||
var sent = 0;
|
var result = await _smsService.SendBulkAsync(phones, request.Message, cancellationToken);
|
||||||
var failed = 0;
|
|
||||||
|
|
||||||
foreach (var phone in phones)
|
if (result.SentCount > 0)
|
||||||
{
|
await IncrementUsageAsync(cafeId, month, result.SentCount);
|
||||||
try
|
|
||||||
{
|
|
||||||
await _smsService.SendMessageAsync(phone, request.Message, cancellationToken);
|
|
||||||
sent++;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to send SMS to recipient");
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sent > 0)
|
return (true, new SmsCampaignResult(result.SentCount, result.FailedCount), null, null);
|
||||||
await IncrementUsageAsync(cafeId, month, sent);
|
|
||||||
|
|
||||||
return (true, new SmsCampaignResult(sent, failed), null, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<string>> ResolvePhonesAsync(
|
private async Task<List<string>> ResolvePhonesAsync(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
},
|
},
|
||||||
"Kavenegar": {
|
"Kavenegar": {
|
||||||
"ApiKey": "",
|
"ApiKey": "",
|
||||||
|
"SenderNumber": "90005671",
|
||||||
"OtpTemplate": "verify"
|
"OtpTemplate": "verify"
|
||||||
},
|
},
|
||||||
"ZarinPal": {
|
"ZarinPal": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public record KavenegarConfigDto(
|
|||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
string? ApiKey,
|
string? ApiKey,
|
||||||
string OtpTemplate,
|
string OtpTemplate,
|
||||||
|
string SenderNumber,
|
||||||
bool HasStoredApiKey);
|
bool HasStoredApiKey);
|
||||||
|
|
||||||
public record OpenAiIntegrationConfigDto(
|
public record OpenAiIntegrationConfigDto(
|
||||||
@@ -92,7 +93,8 @@ public record UpdatePaymentGatewayRequest(
|
|||||||
public record UpdateKavenegarRequest(
|
public record UpdateKavenegarRequest(
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
string? ApiKey,
|
string? ApiKey,
|
||||||
string OtpTemplate);
|
string OtpTemplate,
|
||||||
|
string SenderNumber);
|
||||||
|
|
||||||
public record AdminNotificationRowDto(
|
public record AdminNotificationRowDto(
|
||||||
string Id,
|
string Id,
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ public interface IPlatformIntegrationService
|
|||||||
public class PlatformIntegrationService : IPlatformIntegrationService
|
public class PlatformIntegrationService : IPlatformIntegrationService
|
||||||
{
|
{
|
||||||
public const string KeyActiveGateway = "payment.activeGateway";
|
public const string KeyActiveGateway = "payment.activeGateway";
|
||||||
public const string KeyKavenegarApi = "integrations.kavenegar.apiKey";
|
public const string KeyKavenegarApi = "integrations.kavenegar.apiKey";
|
||||||
public const string KeyKavenegarOtpTemplate = "integrations.kavenegar.otpTemplate";
|
public const string KeyKavenegarOtpTemplate = "integrations.kavenegar.otpTemplate";
|
||||||
public const string KeyKavenegarEnabled = "integrations.kavenegar.enabled";
|
public const string KeyKavenegarEnabled = "integrations.kavenegar.enabled";
|
||||||
|
public const string KeyKavenegarSender = "integrations.kavenegar.senderNumber";
|
||||||
|
|
||||||
private static readonly (string Id, string NameFa, string Prefix)[] Gateways =
|
private static readonly (string Id, string NameFa, string Prefix)[] Gateways =
|
||||||
[
|
[
|
||||||
@@ -56,6 +57,7 @@ public class PlatformIntegrationService : IPlatformIntegrationService
|
|||||||
map.GetValueOrDefault(KeyKavenegarEnabled) is "true",
|
map.GetValueOrDefault(KeyKavenegarEnabled) is "true",
|
||||||
MaskSecret(map.GetValueOrDefault(KeyKavenegarApi)),
|
MaskSecret(map.GetValueOrDefault(KeyKavenegarApi)),
|
||||||
map.GetValueOrDefault(KeyKavenegarOtpTemplate) ?? "verify",
|
map.GetValueOrDefault(KeyKavenegarOtpTemplate) ?? "verify",
|
||||||
|
map.GetValueOrDefault(KeyKavenegarSender) ?? string.Empty,
|
||||||
HasSecret(map, KeyKavenegarApi));
|
HasSecret(map, KeyKavenegarApi));
|
||||||
|
|
||||||
var ai = new AiIntegrationsConfigDto(
|
var ai = new AiIntegrationsConfigDto(
|
||||||
@@ -109,6 +111,8 @@ public class PlatformIntegrationService : IPlatformIntegrationService
|
|||||||
await UpsertAsync(KeyKavenegarOtpTemplate, request.Kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct);
|
await UpsertAsync(KeyKavenegarOtpTemplate, request.Kavenegar.OtpTemplate.Trim(), "integrations", "قالب OTP", ct);
|
||||||
if (!string.IsNullOrWhiteSpace(request.Kavenegar.ApiKey) && !IsMaskedPlaceholder(request.Kavenegar.ApiKey))
|
if (!string.IsNullOrWhiteSpace(request.Kavenegar.ApiKey) && !IsMaskedPlaceholder(request.Kavenegar.ApiKey))
|
||||||
await UpsertAsync(KeyKavenegarApi, request.Kavenegar.ApiKey.Trim(), "integrations", "API Key کاوهنگار", ct);
|
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.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);
|
await UpsertAsync(PlatformIntegrationKeys.OpenAiModel, string.IsNullOrWhiteSpace(request.Ai.OpenAi.Model) ? "gpt-4o-mini" : request.Ai.OpenAi.Model.Trim(), "integrations", "مدل OpenAI", ct);
|
||||||
|
|||||||
@@ -1,7 +1,23 @@
|
|||||||
namespace Meezi.Core.Interfaces;
|
namespace Meezi.Core.Interfaces;
|
||||||
|
|
||||||
|
public record KavenegarAccountInfo(long RemainCredit, string AccountType);
|
||||||
|
|
||||||
|
public record BulkSendResult(int SentCount, int FailedCount);
|
||||||
|
|
||||||
public interface ISmsService
|
public interface ISmsService
|
||||||
{
|
{
|
||||||
|
/// <summary>Send a one-time password via Kavenegar Verify/Lookup template.</summary>
|
||||||
Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default);
|
Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Send a plain-text message to a single recipient.</summary>
|
||||||
Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default);
|
Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Send the same message to many recipients in batches of up to 200.
|
||||||
|
/// Never throws — failures per batch are counted and returned.
|
||||||
|
/// </summary>
|
||||||
|
Task<BulkSendResult> SendBulkAsync(IReadOnlyList<string> phones, string message, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>Returns credit balance from the Kavenegar account, or null if not configured.</summary>
|
||||||
|
Task<KavenegarAccountInfo?> GetAccountInfoAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,28 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Meezi.Core.Interfaces;
|
using Meezi.Core.Interfaces;
|
||||||
|
using Meezi.Infrastructure.Services.Platform;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Meezi.Infrastructure.ExternalServices;
|
namespace Meezi.Infrastructure.ExternalServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Kavenegar SMS gateway implementation.
|
||||||
|
/// Reads config from DB (via IPlatformRuntimeConfig) first, then falls back
|
||||||
|
/// to IConfiguration ("Kavenegar:ApiKey", "Kavenegar:SenderNumber", etc.).
|
||||||
|
/// </summary>
|
||||||
public class KavenegarSmsService : ISmsService
|
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 HttpClient _httpClient;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IPlatformRuntimeConfig _platform;
|
private readonly IPlatformRuntimeConfig _platform;
|
||||||
@@ -25,64 +40,194 @@ public class KavenegarSmsService : ISmsService
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageAsync(string phone, string message, CancellationToken cancellationToken = default)
|
// ── Public interface ──────────────────────────────────────────────────────
|
||||||
{
|
|
||||||
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<KavenegarResponse>(cancellationToken: cancellationToken);
|
|
||||||
if (body?.Return?.Status is not 200)
|
|
||||||
throw new InvalidOperationException("SMS delivery failed.");
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default)
|
public async Task SendOtpAsync(string phone, string otp, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var apiKey = await GetApiKeyAsync(cancellationToken);
|
var (apiKey, _, template) = await GetConfigAsync(cancellationToken);
|
||||||
var template = await GetOtpTemplateAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var url = $"https://api.kavenegar.com/v1/{apiKey}/verify/lookup.json" +
|
var url = $"{BaseUrl}/{apiKey}/verify/lookup.json";
|
||||||
$"?receptor={Uri.EscapeDataString(phone)}&token={otp}&template={Uri.EscapeDataString(template)}";
|
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Kavenegar OTP send failed with status {StatusCode}", response.StatusCode);
|
["receptor"] = phone,
|
||||||
throw new InvalidOperationException("SMS delivery failed.");
|
["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<KavenegarResponse>(cancellationToken: cancellationToken);
|
var url = $"{BaseUrl}/{apiKey}/sms/send.json";
|
||||||
if (body?.Return?.Status is not 200)
|
var content = BuildSendForm(phone, message, sender);
|
||||||
|
|
||||||
|
var response = await _httpClient.PostAsync(url, content, cancellationToken);
|
||||||
|
await EnsureKavenegarSuccessAsync(response, "Send", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BulkSendResult> SendBulkAsync(
|
||||||
|
IReadOnlyList<string> 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);
|
_logger.LogInformation("Kavenegar not configured — bulk SMS skipped ({Count} recipients)", phones.Count);
|
||||||
throw new InvalidOperationException("SMS delivery failed.");
|
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<KavenegarAccountInfo?> 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<KavenegarAccountInfoResponse>(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<string, string>
|
||||||
|
{
|
||||||
|
["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<KavenegarReturnEnvelope>(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")]
|
[JsonPropertyName("return")]
|
||||||
public KavenegarReturn? Return { get; set; }
|
public KavenegarReturn? Return { get; set; }
|
||||||
@@ -92,21 +237,29 @@ public class KavenegarSmsService : ISmsService
|
|||||||
{
|
{
|
||||||
[JsonPropertyName("status")]
|
[JsonPropertyName("status")]
|
||||||
public int Status { get; set; }
|
public int Status { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public string? Message { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> GetApiKeyAsync(CancellationToken cancellationToken)
|
private sealed class KavenegarAccountInfoResponse
|
||||||
{
|
{
|
||||||
var fromDb = await _platform.GetAsync("integrations.kavenegar.apiKey", cancellationToken);
|
[JsonPropertyName("return")]
|
||||||
if (!string.IsNullOrWhiteSpace(fromDb))
|
public KavenegarReturn? Return { get; set; }
|
||||||
return fromDb;
|
|
||||||
return _configuration["Kavenegar:ApiKey"];
|
[JsonPropertyName("entries")]
|
||||||
|
public KavenegarAccountEntries? Entries { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetOtpTemplateAsync(CancellationToken cancellationToken)
|
private sealed class KavenegarAccountEntries
|
||||||
{
|
{
|
||||||
var fromDb = await _platform.GetAsync("integrations.kavenegar.otpTemplate", cancellationToken);
|
[JsonPropertyName("remaincredit")]
|
||||||
if (!string.IsNullOrWhiteSpace(fromDb))
|
public long RemainCredit { get; set; }
|
||||||
return fromDb;
|
|
||||||
return _configuration["Kavenegar:OtpTemplate"] ?? "verify";
|
[JsonPropertyName("expiredate")]
|
||||||
|
public long ExpireDate { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("type")]
|
||||||
|
public string? Type { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,16 @@
|
|||||||
"usage": "Usage this month",
|
"usage": "Usage this month",
|
||||||
"unlimited": "Unlimited",
|
"unlimited": "Unlimited",
|
||||||
"sent": "Sent",
|
"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": {
|
"reports": {
|
||||||
"title": "Reports & analytics",
|
"title": "Reports & analytics",
|
||||||
|
|||||||
@@ -378,7 +378,16 @@
|
|||||||
"usage": "مصرف این ماه",
|
"usage": "مصرف این ماه",
|
||||||
"unlimited": "نامحدود",
|
"unlimited": "نامحدود",
|
||||||
"sent": "ارسال شد",
|
"sent": "ارسال شد",
|
||||||
"failed": "ناموفق"
|
"failed": "ناموفق",
|
||||||
|
"charCount": "{count} حرف",
|
||||||
|
"smsPartsHint": "{parts} پیامک",
|
||||||
|
"balance": "اعتبار حساب",
|
||||||
|
"balanceAmount": "{amount} ریال",
|
||||||
|
"balanceNotConfigured": "Kavenegar پیکربندی نشده",
|
||||||
|
"sender": "خط فرستنده",
|
||||||
|
"recipientsCount": "{count} مخاطب",
|
||||||
|
"sendConfirm": "ارسال به {count} نفر؟",
|
||||||
|
"sending": "در حال ارسال..."
|
||||||
},
|
},
|
||||||
"reports": {
|
"reports": {
|
||||||
"title": "گزارشها و تحلیل",
|
"title": "گزارشها و تحلیل",
|
||||||
|
|||||||
@@ -1,34 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
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 { 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 { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LabeledField } from "@/components/ui/labeled-field";
|
import { LabeledField } from "@/components/ui/labeled-field";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const GROUPS: (CustomerGroup | "all")[] = ["all", "Regular", "Vip", "New", "Employee"];
|
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() {
|
export function SmsScreen() {
|
||||||
const t = useTranslations("sms");
|
const t = useTranslations("sms");
|
||||||
const tCrm = useTranslations("crm");
|
const tCrm = useTranslations("crm");
|
||||||
|
const locale = useLocale();
|
||||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [target, setTarget] = useState<CustomerGroup | "all">("all");
|
const [target, setTarget] = useState<CustomerGroup | "all">("all");
|
||||||
const [result, setResult] = useState<SmsCampaignResult | null>(null);
|
const [result, setResult] = useState<SmsCampaignResult | null>(null);
|
||||||
|
|
||||||
|
// ── API queries ─────────────────────────────────────────────────────────────
|
||||||
const { data: usage } = useQuery({
|
const { data: usage } = useQuery({
|
||||||
queryKey: ["sms-usage", cafeId],
|
queryKey: ["sms-usage", cafeId],
|
||||||
queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`),
|
queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`),
|
||||||
enabled: !!cafeId,
|
enabled: !!cafeId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: balance } = useQuery({
|
||||||
|
queryKey: ["sms-balance", cafeId],
|
||||||
|
queryFn: () => apiGet<SmsBalance>(`/api/cafes/${cafeId}/sms/balance`),
|
||||||
|
enabled: !!cafeId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
const sendCampaign = useMutation({
|
const sendCampaign = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
apiPost<SmsCampaignResult>(`/api/cafes/${cafeId}/sms/campaign`, {
|
apiPost<SmsCampaignResult>(`/api/cafes/${cafeId}/sms/campaign`, {
|
||||||
@@ -39,35 +62,92 @@ export function SmsScreen() {
|
|||||||
setResult(data);
|
setResult(data);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
queryClient.invalidateQueries({ queryKey: ["sms-usage", cafeId] });
|
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 =
|
const usageLabel =
|
||||||
usage?.monthlyLimit === -1
|
usage?.monthlyLimit === -1
|
||||||
? t("unlimited")
|
? 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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="mx-auto max-w-2xl space-y-4">
|
||||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||||
|
|
||||||
<Card>
|
{/* ── Status row ──────────────────────────────────────────────────────── */}
|
||||||
<CardHeader>
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<CardTitle className="text-base">{t("usage")}</CardTitle>
|
{/* Usage */}
|
||||||
</CardHeader>
|
<Card>
|
||||||
<CardContent>
|
<CardContent className="p-4">
|
||||||
<p className="text-2xl font-semibold text-primary">{usageLabel}</p>
|
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
</CardContent>
|
<MessageSquare className="h-3.5 w-3.5" />
|
||||||
</Card>
|
{t("usage")}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold tabular-nums text-foreground">{usageLabel}</p>
|
||||||
|
{usagePct !== null && (
|
||||||
|
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full rounded-full transition-all",
|
||||||
|
usagePct >= 90 ? "bg-destructive" : "bg-primary"
|
||||||
|
)}
|
||||||
|
style={{ width: `${usagePct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Balance */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
{t("balance")}
|
||||||
|
</p>
|
||||||
|
{balance?.isConfigured ? (
|
||||||
|
<p className="text-lg font-bold tabular-nums text-foreground">
|
||||||
|
{t("balanceAmount", { amount: formatNumber(balance.remainCredit, locale) })}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("balanceNotConfigured")}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Sender */}
|
||||||
|
<Card className="hidden sm:block">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{t("sender")}
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold tabular-nums tracking-wider text-foreground" dir="ltr">
|
||||||
|
90005671
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Campaign form ────────────────────────────────────────────────────── */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="space-y-4 pt-6">
|
<CardContent className="space-y-4 pt-6">
|
||||||
|
{/* Target group */}
|
||||||
<LabeledField label={t("targetGroup")} htmlFor="sms-target">
|
<LabeledField label={t("targetGroup")} htmlFor="sms-target">
|
||||||
<select
|
<select
|
||||||
id="sms-target"
|
id="sms-target"
|
||||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="w-full cursor-pointer rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
value={target}
|
value={target}
|
||||||
onChange={(e) => setTarget(e.target.value as CustomerGroup | "all")}
|
onChange={(e) => setTarget(e.target.value as CustomerGroup | "all")}
|
||||||
>
|
>
|
||||||
@@ -78,25 +158,78 @@ export function SmsScreen() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
|
|
||||||
|
{/* Message textarea with char counter */}
|
||||||
<LabeledField label={t("message")} htmlFor="sms-message">
|
<LabeledField label={t("message")} htmlFor="sms-message">
|
||||||
<textarea
|
<div className="relative">
|
||||||
id="sms-message"
|
<textarea
|
||||||
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
id="sms-message"
|
||||||
value={message}
|
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm transition-colors hover:border-primary focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
value={message}
|
||||||
/>
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
placeholder={t("messagePlaceholder")}
|
||||||
|
dir="auto"
|
||||||
|
/>
|
||||||
|
{/* Character counter */}
|
||||||
|
{chars > 0 && (
|
||||||
|
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"tabular-nums",
|
||||||
|
chars > 336 && "text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("charCount", { count: chars })}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-2 py-0.5 font-semibold tabular-nums",
|
||||||
|
parts === 1
|
||||||
|
? "border-primary/30 bg-primary/10 text-primary"
|
||||||
|
: parts <= 3
|
||||||
|
? "border-amber-400/40 bg-amber-50 text-amber-700"
|
||||||
|
: "border-destructive/40 bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("smsPartsHint", { parts })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</LabeledField>
|
</LabeledField>
|
||||||
|
|
||||||
|
{/* Send button */}
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!message.trim() || sendCampaign.isPending}
|
disabled={!message.trim() || sendCampaign.isPending}
|
||||||
onClick={() => sendCampaign.mutate()}
|
onClick={() => sendCampaign.mutate()}
|
||||||
>
|
>
|
||||||
{t("send")}
|
{sendCampaign.isPending ? t("sending") : t("send")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Result banner */}
|
||||||
{result && (
|
{result && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<div
|
||||||
{t("sent")}: {formatNumber(result.sentCount)} — {t("failed")}:{" "}
|
className={cn(
|
||||||
{formatNumber(result.failedCount)}
|
"rounded-lg border px-4 py-3 text-center text-sm",
|
||||||
|
result.failedCount === 0
|
||||||
|
? "border-green-200 bg-green-50 text-green-800"
|
||||||
|
: "border-amber-200 bg-amber-50 text-amber-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-semibold">{t("sent")}: {formatNumber(result.sentCount, locale)}</span>
|
||||||
|
{result.failedCount > 0 && (
|
||||||
|
<span className="ms-3 opacity-75">
|
||||||
|
{t("failed")}: {formatNumber(result.failedCount, locale)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error banner */}
|
||||||
|
{sendCampaign.isError && (
|
||||||
|
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-center text-sm text-destructive">
|
||||||
|
{(sendCampaign.error as Error).message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ export interface SmsCampaignResult {
|
|||||||
failedCount: number;
|
failedCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SmsBalance {
|
||||||
|
remainCredit: number;
|
||||||
|
accountType: string;
|
||||||
|
isConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Table {
|
export interface Table {
|
||||||
id: string;
|
id: string;
|
||||||
branchId?: string;
|
branchId?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user