feat(sms): bring-your-own-provider — cafés use their own SMS account
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 5m16s
CI/CD / CI · API (dotnet build + test) (push) Successful in 40s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 5m16s
The platform no longer sells SMS. Each café saves its OWN Kavenegar API key + sender line (new Cafes columns + migration) and campaigns are sent and billed through that account. Backend: - GET/PUT /sms/settings (Manager/Owner; key echoed masked, verified against the provider before saving) - campaign + balance use the café's credentials; SMS_NOT_CONFIGURED error when missing; plan-tier SMS gating removed everywhere (PlanLimitChecker, SmsMarketingService, billing status) - platform Kavenegar config stays ONLY for login OTPs (env/DB) - design-time DbContext factory so `dotnet ef migrations add` works without booting the host Dashboard: - SMS screen: provider-settings card, not-configured callout, campaign form disabled until configured; quota bar removed (usage stays as info) - subscription screen + plan comparison no longer show SMS limits Admin panel: - Kavenegar/SMS section removed from integrations (request field now optional; stored OTP config untouched) - SMS limit field removed from the plan editor - nav label "درگاه و پیامک" → "درگاه پرداخت و AI" fa/en/ar translations. 86 tests pass; all tsc clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1020,7 +1020,7 @@
|
||||
"title": "مدیریت سامانه",
|
||||
"dashboard": "داشبورد",
|
||||
"plans": "اشتراک و قیمت",
|
||||
"integrations": "درگاه و پیامک",
|
||||
"integrations": "درگاه پرداخت و AI",
|
||||
"notifications": "اعلانها",
|
||||
"settings": "تنظیمات اپ",
|
||||
"features": "قابلیتها",
|
||||
|
||||
@@ -143,7 +143,7 @@ const LIMIT_FIELDS: { key: keyof PlanLimitsData; label: string }[] = [
|
||||
{ key: "maxMenuItems", label: "maxItems" },
|
||||
{ key: "maxCustomers", label: "maxCustomers" },
|
||||
{ key: "maxReportHistoryDays", label: "maxReportDays" },
|
||||
{ key: "maxSmsPerMonth", label: "maxSms" },
|
||||
// No SMS limit — marketing SMS is bring-your-own-provider per café.
|
||||
{ key: "maxMenuAi3dPerMonth", label: "maxAi3d" },
|
||||
];
|
||||
|
||||
@@ -701,11 +701,6 @@ export function AdminIntegrationsScreen() {
|
||||
hasStoredClientSecret: prev?.hasStoredClientSecret ?? false,
|
||||
...patch,
|
||||
});
|
||||
const [kavenegar, setKavenegar] = useState({
|
||||
isEnabled: true,
|
||||
apiKey: "",
|
||||
otpTemplate: "verify",
|
||||
});
|
||||
const [openAi, setOpenAi] = useState({
|
||||
isEnabled: false,
|
||||
apiKey: "",
|
||||
@@ -722,11 +717,6 @@ export function AdminIntegrationsScreen() {
|
||||
if (!data) return;
|
||||
setActiveGateway(data.activePaymentGateway);
|
||||
setGateways(data.paymentGateways.map((g) => ({ ...g })));
|
||||
setKavenegar({
|
||||
isEnabled: data.kavenegar.isEnabled,
|
||||
apiKey: data.kavenegar.apiKey ?? "",
|
||||
otpTemplate: data.kavenegar.otpTemplate,
|
||||
});
|
||||
setOpenAi({
|
||||
isEnabled: data.ai.openAi.isEnabled,
|
||||
apiKey: data.ai.openAi.apiKey ?? "",
|
||||
@@ -770,7 +760,7 @@ export function AdminIntegrationsScreen() {
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
kavenegar,
|
||||
// SMS is bring-your-own-provider per café — no platform SMS config here.
|
||||
ai: { openAi, meshy },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -998,39 +988,6 @@ export function AdminIntegrationsScreen() {
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("kavenegarTitle")}
|
||||
</p>
|
||||
<Card className="rounded-xl border border-border/80 p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Toggle
|
||||
checked={kavenegar.isEnabled}
|
||||
onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
|
||||
/>
|
||||
<span>{t("enabled")}</span>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
{t("apiKey")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
placeholder={data?.kavenegar.hasStoredApiKey ? "••••••••" : ""}
|
||||
value={kavenegar.apiKey}
|
||||
onChange={(e) => setKavenegar((k) => ({ ...k, apiKey: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("otpTemplate")}
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={kavenegar.otpTemplate}
|
||||
onChange={(e) => setKavenegar((k) => ({ ...k, otpTemplate: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground">
|
||||
{t("aiTitle")}
|
||||
|
||||
@@ -481,10 +481,36 @@
|
||||
"targetGroup": "المجموعة المستهدفة",
|
||||
"allCustomers": "كل العملاء",
|
||||
"send": "إرسال",
|
||||
"usage": "الاستخدام هذا الشهر",
|
||||
"usage": "المُرسَل هذا الشهر",
|
||||
"unlimited": "غير محدود",
|
||||
"sent": "تم الإرسال",
|
||||
"failed": "فشل"
|
||||
"failed": "فشل",
|
||||
"charCount": "{count} حرفاً",
|
||||
"smsPartsHint": "{parts} رسالة",
|
||||
"balance": "رصيد حسابك",
|
||||
"balanceAmount": "{amount} ريال",
|
||||
"balanceNotConfigured": "خدمة SMS غير مفعّلة",
|
||||
"sender": "خط الإرسال",
|
||||
"recipientsCount": "{count} مستلماً",
|
||||
"sendConfirm": "إرسال إلى {count} شخصاً؟",
|
||||
"sending": "جارٍ الإرسال...",
|
||||
"byoHint": "تُرسل الرسائل عبر حسابك وخطك الخاص — تُحتسب تكلفة الإرسال مباشرة لدى مزوّد SMS الخاص بك.",
|
||||
"notConfiguredOwner": "لإرسال الرسائل، احفظ أولاً مفتاح API ورقم خط كاوهنگار في الإعدادات أعلاه.",
|
||||
"notConfiguredStaff": "لم يقم مدير المقهى بإعداد خدمة SMS بعد.",
|
||||
"settings": {
|
||||
"title": "إعدادات مزوّد SMS",
|
||||
"hint": "أنشئ مفتاح API من لوحة كاوهنگار (kavenegar.com) وأدخله مع رقم خط الإرسال.",
|
||||
"apiKey": "مفتاح API",
|
||||
"apiKeyPlaceholder": "API Key",
|
||||
"senderNumber": "رقم خط الإرسال",
|
||||
"senderPlaceholder": "10004346...",
|
||||
"configured": "خدمة SMS مفعّلة.",
|
||||
"notConfigured": "لم يتم الإعداد بعد.",
|
||||
"save": "حفظ",
|
||||
"saving": "جارٍ التحقق…",
|
||||
"saved": "تم حفظ إعدادات SMS.",
|
||||
"saveFailed": "مفتاح API غير صالح أو فشل الحفظ."
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
"title": "التقارير والتحليلات",
|
||||
|
||||
@@ -500,19 +500,36 @@
|
||||
"targetGroup": "Target group",
|
||||
"allCustomers": "All customers",
|
||||
"send": "Send",
|
||||
"usage": "Usage this month",
|
||||
"usage": "Sent this month",
|
||||
"unlimited": "Unlimited",
|
||||
"sent": "Sent",
|
||||
"failed": "Failed",
|
||||
"charCount": "{count} chars",
|
||||
"smsPartsHint": "{parts} SMS",
|
||||
"balance": "Account credit",
|
||||
"balance": "Your account credit",
|
||||
"balanceAmount": "{amount} Rials",
|
||||
"balanceNotConfigured": "Kavenegar not configured",
|
||||
"balanceNotConfigured": "SMS service not set up",
|
||||
"sender": "Sender line",
|
||||
"recipientsCount": "{count} recipients",
|
||||
"sendConfirm": "Send to {count} people?",
|
||||
"sending": "Sending..."
|
||||
"sending": "Sending...",
|
||||
"byoHint": "SMS is sent through your OWN provider account and line — sending costs are billed directly by your SMS provider.",
|
||||
"notConfiguredOwner": "To send SMS, first save your Kavenegar API key and sender line in the settings above.",
|
||||
"notConfiguredStaff": "The SMS service has not been set up by the café manager yet.",
|
||||
"settings": {
|
||||
"title": "SMS provider settings",
|
||||
"hint": "Create an API key in your Kavenegar panel (kavenegar.com) and enter it with your sender line number.",
|
||||
"apiKey": "Kavenegar API key",
|
||||
"apiKeyPlaceholder": "API Key",
|
||||
"senderNumber": "Sender line number",
|
||||
"senderPlaceholder": "10004346...",
|
||||
"configured": "SMS service is active.",
|
||||
"notConfigured": "Not set up yet.",
|
||||
"save": "Save",
|
||||
"saving": "Verifying…",
|
||||
"saved": "SMS settings saved.",
|
||||
"saveFailed": "The API key is invalid or saving failed."
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
"title": "Reports & analytics",
|
||||
|
||||
@@ -500,19 +500,36 @@
|
||||
"targetGroup": "گروه هدف",
|
||||
"allCustomers": "همه مشتریان",
|
||||
"send": "ارسال",
|
||||
"usage": "مصرف این ماه",
|
||||
"usage": "ارسالشده این ماه",
|
||||
"unlimited": "نامحدود",
|
||||
"sent": "ارسال شد",
|
||||
"failed": "ناموفق",
|
||||
"charCount": "{count} حرف",
|
||||
"smsPartsHint": "{parts} پیامک",
|
||||
"balance": "اعتبار حساب",
|
||||
"balance": "اعتبار حساب شما",
|
||||
"balanceAmount": "{amount} ریال",
|
||||
"balanceNotConfigured": "Kavenegar پیکربندی نشده",
|
||||
"balanceNotConfigured": "سرویس پیامک راهاندازی نشده",
|
||||
"sender": "خط فرستنده",
|
||||
"recipientsCount": "{count} مخاطب",
|
||||
"sendConfirm": "ارسال به {count} نفر؟",
|
||||
"sending": "در حال ارسال..."
|
||||
"sending": "در حال ارسال...",
|
||||
"byoHint": "پیامک با حساب و خط اختصاصی خود شما ارسال میشود — هزینه ارسال مستقیماً با اپراتور پیامک شماست.",
|
||||
"notConfiguredOwner": "برای ارسال پیامک ابتدا کلید API و شماره خط کاوهنگار خود را در تنظیمات بالا ثبت کنید.",
|
||||
"notConfiguredStaff": "سرویس پیامک هنوز توسط مدیر کافه راهاندازی نشده است.",
|
||||
"settings": {
|
||||
"title": "تنظیمات سرویس پیامک",
|
||||
"hint": "از پنل کاوهنگار (kavenegar.com) کلید API بسازید و همراه شماره خط خود وارد کنید.",
|
||||
"apiKey": "کلید API کاوهنگار",
|
||||
"apiKeyPlaceholder": "API Key",
|
||||
"senderNumber": "شماره خط ارسال",
|
||||
"senderPlaceholder": "10004346...",
|
||||
"configured": "سرویس پیامک فعال است.",
|
||||
"notConfigured": "هنوز راهاندازی نشده.",
|
||||
"save": "ذخیره",
|
||||
"saving": "در حال بررسی…",
|
||||
"saved": "تنظیمات پیامک ذخیره شد.",
|
||||
"saveFailed": "کلید API نامعتبر است یا ذخیره ناموفق بود."
|
||||
}
|
||||
},
|
||||
"reports": {
|
||||
"title": "گزارشها و تحلیل",
|
||||
@@ -1481,7 +1498,7 @@
|
||||
"title": "مدیریت سامانه",
|
||||
"dashboard": "داشبورد",
|
||||
"plans": "اشتراک و قیمت",
|
||||
"integrations": "درگاه و پیامک",
|
||||
"integrations": "درگاه پرداخت و AI",
|
||||
"notifications": "اعلانها",
|
||||
"settings": "تنظیمات اپ",
|
||||
"features": "قابلیتها",
|
||||
|
||||
@@ -19,13 +19,13 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
/** Limit rows shown at the top of the comparison, in display order. */
|
||||
// NOTE: no SMS row — marketing SMS is bring-your-own-provider, not a plan limit.
|
||||
const LIMIT_ROWS: { key: keyof PlanLimits; zeroAsDash?: boolean }[] = [
|
||||
{ key: "maxOrdersPerDay" },
|
||||
{ key: "maxBranches" },
|
||||
{ key: "maxTerminals" },
|
||||
{ key: "maxTables" },
|
||||
{ key: "maxCustomers" },
|
||||
{ key: "maxSmsPerMonth", zeroAsDash: true },
|
||||
{ key: "maxMenuItems" },
|
||||
{ key: "maxReportHistoryDays" },
|
||||
{ key: "maxMenuAi3dPerMonth", zeroAsDash: true },
|
||||
|
||||
@@ -3,17 +3,20 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
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, SmsBalance } from "@/lib/api/types";
|
||||
import { KeyRound, MessageSquare, Settings2, Zap } from "lucide-react";
|
||||
import { apiGet, apiPost, apiPut } from "@/lib/api/client";
|
||||
import type { CustomerGroup, SmsCampaignResult, SmsSettings, SmsUsage, SmsBalance } from "@/lib/api/types";
|
||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { notify, notifyError } from "@/lib/notify";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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"];
|
||||
const MANAGER_ROLES = new Set(["Owner", "Manager"]);
|
||||
|
||||
/** Kavenegar SMS character limits. */
|
||||
function calcSmsParts(text: string): { chars: number; parts: number } {
|
||||
@@ -32,13 +35,21 @@ export function SmsScreen() {
|
||||
const tCrm = useTranslations("crm");
|
||||
const locale = useLocale();
|
||||
const cafeId = useAuthStore((s) => s.user?.cafeId);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const queryClient = useQueryClient();
|
||||
const canManage = MANAGER_ROLES.has(role ?? "");
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
const [target, setTarget] = useState<CustomerGroup | "all">("all");
|
||||
const [result, setResult] = useState<SmsCampaignResult | null>(null);
|
||||
|
||||
// ── API queries ─────────────────────────────────────────────────────────────
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ["sms-settings", cafeId],
|
||||
queryFn: () => apiGet<SmsSettings>(`/api/cafes/${cafeId}/sms/settings`),
|
||||
enabled: !!cafeId && canManage,
|
||||
});
|
||||
|
||||
const { data: usage } = useQuery({
|
||||
queryKey: ["sms-usage", cafeId],
|
||||
queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`),
|
||||
@@ -69,47 +80,38 @@ export function SmsScreen() {
|
||||
// ── 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, locale)} / ${formatNumber(usage?.monthlyLimit ?? 0, locale)}`;
|
||||
// Provider configured? Balance endpoint answers for every role; the settings
|
||||
// endpoint refines it for managers (e.g. key saved but provider unreachable).
|
||||
const isConfigured = settings?.isConfigured ?? balance?.isConfigured ?? false;
|
||||
|
||||
if (!cafeId) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-4">
|
||||
<h2 className="text-xl font-bold">{t("title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("byoHint")}</p>
|
||||
|
||||
{/* ── Provider settings (Owner/Manager) ────────────────────────────────── */}
|
||||
{canManage ? (
|
||||
<ProviderSettingsCard cafeId={cafeId} settings={settings} />
|
||||
) : null}
|
||||
|
||||
{/* ── Status row ──────────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{/* Usage */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Usage this month (informational — your provider account is the only cap) */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{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>
|
||||
)}
|
||||
<p className="text-lg font-bold tabular-nums text-foreground">
|
||||
{formatNumber(usage?.usedThisMonth ?? 0, locale)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Balance */}
|
||||
{/* Balance of the café's own provider account */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
@@ -125,23 +127,17 @@ export function SmsScreen() {
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* ── Not configured callout ───────────────────────────────────────────── */}
|
||||
{!isConfigured ? (
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{canManage ? t("notConfiguredOwner") : t("notConfiguredStaff")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Campaign form ────────────────────────────────────────────────────── */}
|
||||
<Card>
|
||||
<Card className={cn(!isConfigured && "pointer-events-none opacity-50")}>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
{/* Target group */}
|
||||
<LabeledField label={t("targetGroup")} htmlFor="sms-target">
|
||||
@@ -201,7 +197,7 @@ export function SmsScreen() {
|
||||
{/* Send button */}
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!message.trim() || sendCampaign.isPending}
|
||||
disabled={!message.trim() || sendCampaign.isPending || !isConfigured}
|
||||
onClick={() => sendCampaign.mutate()}
|
||||
>
|
||||
{sendCampaign.isPending ? t("sending") : t("send")}
|
||||
@@ -237,3 +233,96 @@ export function SmsScreen() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring-your-own-provider credentials: the café's Kavenegar API key + sender
|
||||
* line. The platform does not sell SMS — every campaign goes through and is
|
||||
* billed to the café's own provider account.
|
||||
*/
|
||||
function ProviderSettingsCard({
|
||||
cafeId,
|
||||
settings,
|
||||
}: {
|
||||
cafeId: string;
|
||||
settings?: SmsSettings;
|
||||
}) {
|
||||
const t = useTranslations("sms.settings");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [sender, setSender] = useState<string | null>(null);
|
||||
|
||||
const senderValue = sender ?? settings?.senderNumber ?? "";
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
apiPut<SmsSettings>(`/api/cafes/${cafeId}/sms/settings`, {
|
||||
// Empty key field = keep the existing stored key.
|
||||
apiKey: apiKey.trim() || null,
|
||||
senderNumber: senderValue.trim(),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
notify.success(t("saved"));
|
||||
setApiKey("");
|
||||
void queryClient.invalidateQueries({ queryKey: ["sms-settings", cafeId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["sms-balance", cafeId] });
|
||||
},
|
||||
onError: (err) => notifyError(err, t("saveFailed")),
|
||||
});
|
||||
|
||||
const canSave =
|
||||
senderValue.trim().length > 0 && (apiKey.trim().length > 0 || !!settings?.isConfigured);
|
||||
|
||||
return (
|
||||
<Card className="border-primary/20">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Settings2 className="h-4 w-4 text-primary" aria-hidden />
|
||||
{t("title")}
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("hint")}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<LabeledField label={t("apiKey")} htmlFor="sms-api-key">
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute start-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="sms-api-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
dir="ltr"
|
||||
className="ps-9"
|
||||
placeholder={settings?.apiKeyMasked ?? t("apiKeyPlaceholder")}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</LabeledField>
|
||||
<LabeledField label={t("senderNumber")} htmlFor="sms-sender">
|
||||
<Input
|
||||
id="sms-sender"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
placeholder={t("senderPlaceholder")}
|
||||
value={senderValue}
|
||||
onChange={(e) => setSender(e.target.value)}
|
||||
/>
|
||||
</LabeledField>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{settings?.isConfigured ? t("configured") : t("notConfigured")}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canSave || save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ type BillingStatus = {
|
||||
ordersDailyLimit: number | null;
|
||||
customersCount: number;
|
||||
customersLimit: number | null;
|
||||
smsUsedThisMonth: number;
|
||||
smsMonthlyLimit: number;
|
||||
menu3dEnabled: boolean;
|
||||
discoverProfileEnabled: boolean;
|
||||
isPlanExpired: boolean;
|
||||
@@ -164,11 +162,6 @@ export function SubscriptionScreen() {
|
||||
{status.customersLimit != null &&
|
||||
` / ${formatNumber(status.customersLimit)}`}
|
||||
</li>
|
||||
<li>
|
||||
{t("smsUsage")}: {formatNumber(status.smsUsedThisMonth)}
|
||||
{status.smsMonthlyLimit >= 0 &&
|
||||
` / ${formatNumber(status.smsMonthlyLimit)}`}
|
||||
</li>
|
||||
<li>
|
||||
{t("featureMenu3d")}:{" "}
|
||||
{status.menu3dEnabled ? t("featureOn") : t("featureOff")}
|
||||
|
||||
@@ -202,6 +202,13 @@ export interface SmsBalance {
|
||||
isConfigured: boolean;
|
||||
}
|
||||
|
||||
/** Café's own SMS provider settings (bring-your-own-provider; key comes back masked). */
|
||||
export interface SmsSettings {
|
||||
isConfigured: boolean;
|
||||
apiKeyMasked?: string | null;
|
||||
senderNumber?: string | null;
|
||||
}
|
||||
|
||||
export interface Table {
|
||||
id: string;
|
||||
branchId?: string;
|
||||
|
||||
Reference in New Issue
Block a user