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

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:
soroush.asadi
2026-06-12 09:23:50 +03:30
parent 615d5348de
commit 00649d0248
24 changed files with 3953 additions and 188 deletions
@@ -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 },
+131 -42
View File
@@ -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")}