|
|
|
@@ -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<CustomerGroup | "all">("all");
|
|
|
|
|
const [result, setResult] = useState<SmsCampaignResult | null>(null);
|
|
|
|
|
const [target, setTarget] = useState<CustomerGroup | "all">("all");
|
|
|
|
|
const [result, setResult] = useState<SmsCampaignResult | null>(null);
|
|
|
|
|
|
|
|
|
|
// ── API queries ─────────────────────────────────────────────────────────────
|
|
|
|
|
const { data: usage } = useQuery({
|
|
|
|
|
queryKey: ["sms-usage", cafeId],
|
|
|
|
|
queryFn: () => apiGet<SmsUsage>(`/api/cafes/${cafeId}/sms/usage`),
|
|
|
|
|
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({
|
|
|
|
|
mutationFn: () =>
|
|
|
|
|
apiPost<SmsCampaignResult>(`/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 (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="mx-auto max-w-2xl space-y-4">
|
|
|
|
|
<h2 className="text-xl font-bold">{t("title")}</h2>
|
|
|
|
|
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="text-base">{t("usage")}</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<p className="text-2xl font-semibold text-primary">{usageLabel}</p>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
{/* ── Status row ──────────────────────────────────────────────────────── */}
|
|
|
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
|
|
|
|
{/* Usage */}
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
</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>
|
|
|
|
|
<CardContent className="space-y-4 pt-6">
|
|
|
|
|
{/* Target group */}
|
|
|
|
|
<LabeledField label={t("targetGroup")} htmlFor="sms-target">
|
|
|
|
|
<select
|
|
|
|
|
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}
|
|
|
|
|
onChange={(e) => setTarget(e.target.value as CustomerGroup | "all")}
|
|
|
|
|
>
|
|
|
|
@@ -78,25 +158,78 @@ export function SmsScreen() {
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</LabeledField>
|
|
|
|
|
|
|
|
|
|
{/* Message textarea with char counter */}
|
|
|
|
|
<LabeledField label={t("message")} htmlFor="sms-message">
|
|
|
|
|
<textarea
|
|
|
|
|
id="sms-message"
|
|
|
|
|
className="min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
|
|
|
value={message}
|
|
|
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<textarea
|
|
|
|
|
id="sms-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"
|
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
{/* Send button */}
|
|
|
|
|
<Button
|
|
|
|
|
className="w-full"
|
|
|
|
|
disabled={!message.trim() || sendCampaign.isPending}
|
|
|
|
|
onClick={() => sendCampaign.mutate()}
|
|
|
|
|
>
|
|
|
|
|
{t("send")}
|
|
|
|
|
{sendCampaign.isPending ? t("sending") : t("send")}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{/* Result banner */}
|
|
|
|
|
{result && (
|
|
|
|
|
<p className="text-center text-sm text-muted-foreground">
|
|
|
|
|
{t("sent")}: {formatNumber(result.sentCount)} — {t("failed")}:{" "}
|
|
|
|
|
{formatNumber(result.failedCount)}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"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>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|