Merge origin/main; internationalize new AI auto-reply UI

Brings in the i18n PR (#348) that landed on main and localizes the new
strings this branch added so nothing regresses the new next-intl
convention:
- ai-config.tsx: resolved import conflict; handoff-target picker strings
  moved to Settings.aiConfig (handoffTo / handoffToDesc / handoffQueue).
- message-bubble.tsx: "AI" badge + tooltip → Inbox.bubble
  (aiBadge / aiBadgeTitle).
- ai-thread-banner.tsx: take-over/resume banner + toasts → new
  Inbox.aiBanner namespace.

The AI Agents section (/agents playground, setup tabs, usage card) is
still English on main, so those strings are left as-is for a future
section-wide i18n pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-07-08 16:35:12 +03:00
commit 2311b7ea35
75 changed files with 3527 additions and 1676 deletions

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@
import { use, useEffect, useState } from "react" import { use, useEffect, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { import {
AutomationBuilder, AutomationBuilder,
@ -19,6 +20,7 @@ export default function EditAutomationPage({
}) { }) {
const { id } = use(params) const { id } = use(params)
const router = useRouter() const router = useRouter()
const t = useTranslations("Automations.edit")
const [initial, setInitial] = useState<BuilderInitial | null>(null) const [initial, setInitial] = useState<BuilderInitial | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -27,7 +29,7 @@ export default function EditAutomationPage({
async function load() { async function load() {
const res = await fetch(`/api/automations/${id}`) const res = await fetch(`/api/automations/${id}`)
if (!res.ok) { if (!res.ok) {
if (!cancelled) setError(`Failed to load (${res.status})`) if (!cancelled) setError(t("loadError", { status: res.status }))
return return
} }
const body = await res.json() const body = await res.json()
@ -56,7 +58,7 @@ export default function EditAutomationPage({
onClick={() => router.push("/automations")} onClick={() => router.push("/automations")}
className="text-sm text-primary hover:text-primary/80" className="text-sm text-primary hover:text-primary/80"
> >
Back to Automations {t("back")}
</button> </button>
</div> </div>
) )

View File

@ -10,6 +10,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
} from "lucide-react" } from "lucide-react"
import { useTranslations } from "next-intl"
import { createClient } from "@/lib/supabase/client" import { createClient } from "@/lib/supabase/client"
import type { import type {
@ -28,6 +29,7 @@ export default function AutomationLogsPage({
}) { }) {
const { id } = use(params) const { id } = use(params)
const router = useRouter() const router = useRouter()
const t = useTranslations("Automations.logs")
const [automation, setAutomation] = useState<Automation | null>(null) const [automation, setAutomation] = useState<Automation | null>(null)
const [logs, setLogs] = useState<AutomationLog[] | null>(null) const [logs, setLogs] = useState<AutomationLog[] | null>(null)
@ -56,7 +58,7 @@ export default function AutomationLogsPage({
setAutomation(autRes.data as Automation | null) setAutomation(autRes.data as Automation | null)
setLogs((logRes.data ?? []) as AutomationLog[]) setLogs((logRes.data ?? []) as AutomationLog[])
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to load logs") setError(err instanceof Error ? err.message : t("loadError"))
} }
} }
load() load()
@ -67,7 +69,7 @@ export default function AutomationLogsPage({
<div className="flex h-64 flex-col items-center justify-center gap-3"> <div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => router.push("/automations")}> <Button variant="outline" onClick={() => router.push("/automations")}>
Back {t("back")}
</Button> </Button>
</div> </div>
) )
@ -88,21 +90,21 @@ export default function AutomationLogsPage({
type="button" type="button"
onClick={() => router.push("/automations")} onClick={() => router.push("/automations")}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back" aria-label={t("backAria")}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</button> </button>
<div> <div>
<h1 className="text-2xl font-bold text-foreground">{automation.name}</h1> <h1 className="text-2xl font-bold text-foreground">{automation.name}</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Execution logs</p> <p className="mt-0.5 text-sm text-muted-foreground">{t("title")}</p>
</div> </div>
</div> </div>
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40"> <div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<p className="text-sm text-foreground">No executions yet</p> <p className="text-sm text-foreground">{t("emptyTitle")}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Trigger this automation to see runs here. {t("emptyDesc")}
</p> </p>
</div> </div>
) : ( ) : (
@ -124,14 +126,14 @@ export default function AutomationLogsPage({
) : ( ) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />
)} )}
<StatusBadge status={log.status} /> <StatusBadge status={log.status} t={t} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground"> <div className="truncate text-sm font-medium text-foreground">
{log.contact?.name ?? log.contact?.phone ?? "Unknown contact"} {log.contact?.name ?? log.contact?.phone ?? t("unknownContact")}
</div> </div>
<div className="truncate text-xs text-muted-foreground"> <div className="truncate text-xs text-muted-foreground">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step {log.trigger_event} · {log.steps_executed?.length ?? 0}{" "}
{log.steps_executed?.length === 1 ? "" : "s"} {log.steps_executed?.length === 1 ? t("step", { count: 1 }).replace("1 ", "") : t("stepPlural", { count: log.steps_executed?.length ?? 0 }).replace(/^[0-9]+ /, "")}
</div> </div>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@ -150,7 +152,7 @@ export default function AutomationLogsPage({
<StepRow key={i} result={r} /> <StepRow key={i} result={r} />
))} ))}
{(log.steps_executed ?? []).length === 0 && ( {(log.steps_executed ?? []).length === 0 && (
<li className="text-xs text-muted-foreground">No steps recorded.</li> <li className="text-xs text-muted-foreground">{t("noSteps")}</li>
)} )}
</ul> </ul>
</div> </div>
@ -164,7 +166,7 @@ export default function AutomationLogsPage({
) )
} }
function StatusBadge({ status }: { status: AutomationLog["status"] }) { function StatusBadge({ status, t }: { status: AutomationLog["status"], t: ReturnType<typeof useTranslations> }) {
const classes = const classes =
status === "success" status === "success"
? "border-primary/30 bg-primary/10 text-primary" ? "border-primary/30 bg-primary/10 text-primary"
@ -178,7 +180,7 @@ function StatusBadge({ status }: { status: AutomationLog["status"] }) {
classes, classes,
)} )}
> >
{status} {t(`status.${status}`)}
</span> </span>
) )
} }

View File

@ -20,6 +20,7 @@ import {
import { createClient } from "@/lib/supabase/client" import { createClient } from "@/lib/supabase/client"
import { useCan } from "@/hooks/use-can" import { useCan } from "@/hooks/use-can"
import { useTranslations } from "next-intl"
import type { Automation } from "@/types" import type { Automation } from "@/types"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { GatedButton } from "@/components/ui/gated-button" import { GatedButton } from "@/components/ui/gated-button"
@ -60,6 +61,7 @@ const TEMPLATE_ICON: Record<TemplateSlug, typeof Zap> = {
export default function AutomationsPage() { export default function AutomationsPage() {
const router = useRouter() const router = useRouter()
const canCreate = useCan("send-messages") const canCreate = useCan("send-messages")
const t = useTranslations("Automations.list")
const [automations, setAutomations] = useState<Automation[] | null>(null) const [automations, setAutomations] = useState<Automation[] | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [pendingDelete, setPendingDelete] = useState<Automation | null>(null) const [pendingDelete, setPendingDelete] = useState<Automation | null>(null)
@ -99,20 +101,20 @@ export default function AutomationsPage() {
prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev, prev?.map((x) => (x.id === a.id ? { ...x, is_active: !next } : x)) ?? prev,
) )
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to update") toast.error(body?.error ?? t("toasts.updateError"))
return return
} }
toast.success(next ? "Automation activated" : "Automation paused") toast.success(next ? t("toasts.activated") : t("toasts.paused"))
} }
async function duplicate(a: Automation) { async function duplicate(a: Automation) {
const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" }) const res = await fetch(`/api/automations/${a.id}/duplicate`, { method: "POST" })
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to duplicate") toast.error(body?.error ?? t("toasts.duplicateError"))
return return
} }
toast.success("Automation duplicated") toast.success(t("toasts.duplicated"))
load() load()
} }
@ -123,10 +125,10 @@ export default function AutomationsPage() {
setDeleting(false) setDeleting(false)
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})) const body = await res.json().catch(() => ({}))
toast.error(body?.error ?? "Failed to delete") toast.error(body?.error ?? t("toasts.deleteError"))
return return
} }
toast.success("Automation deleted") toast.success(t("toasts.deleted"))
setPendingDelete(null) setPendingDelete(null)
load() load()
} }
@ -140,7 +142,7 @@ export default function AutomationsPage() {
<div className="flex h-64 flex-col items-center justify-center gap-2"> <div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}> <Button variant="outline" onClick={() => window.location.reload()}>
Retry {t("retry")}
</Button> </Button>
</div> </div>
) )
@ -160,9 +162,9 @@ export default function AutomationsPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Automations</h1> <h1 className="text-2xl font-bold text-foreground">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Build workflows that react to WhatsApp® events automatically. {t("subtitle")}
</p> </p>
</div> </div>
<GatedButton <GatedButton
@ -172,13 +174,13 @@ export default function AutomationsPage() {
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create Automation {t("create")}
</GatedButton> </GatedButton>
</div> </div>
{showTemplates && ( {showTemplates && (
<section> <section>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Quick-start templates</h2> <h2 className="mb-3 text-sm font-semibold text-muted-foreground">{t("templatesTitle")}</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{TEMPLATE_ORDER.map((slug) => { {TEMPLATE_ORDER.map((slug) => {
const t = AUTOMATION_TEMPLATES[slug] const t = AUTOMATION_TEMPLATES[slug]
@ -206,9 +208,9 @@ export default function AutomationsPage() {
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10"> <div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Zap className="h-6 w-6 text-primary" /> <Zap className="h-6 w-6 text-primary" />
</div> </div>
<p className="mt-3 text-sm font-medium text-foreground">No automations yet</p> <p className="mt-3 text-sm font-medium text-foreground">{t("emptyTitle")}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Pick a template above or create one from scratch. {t("emptyDesc")}
</p> </p>
</div> </div>
) : ( ) : (
@ -222,6 +224,7 @@ export default function AutomationsPage() {
onDuplicate={() => duplicate(a)} onDuplicate={() => duplicate(a)}
onLogs={() => router.push(`/automations/${a.id}/logs`)} onLogs={() => router.push(`/automations/${a.id}/logs`)}
onDelete={() => setPendingDelete(a)} onDelete={() => setPendingDelete(a)}
t={t}
/> />
))} ))}
</ul> </ul>
@ -230,11 +233,9 @@ export default function AutomationsPage() {
<Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}> <Dialog open={!!pendingDelete} onOpenChange={(v) => !v && setPendingDelete(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete automation</DialogTitle> <DialogTitle>{t("deleteTitle")}</DialogTitle>
<DialogDescription> <DialogDescription>
This permanently removes{" "} {t("deleteDesc", { name: pendingDelete?.name ?? "" })}
<span className="text-foreground">{pendingDelete?.name}</span> and its execution
history. This cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -243,7 +244,7 @@ export default function AutomationsPage() {
onClick={() => setPendingDelete(null)} onClick={() => setPendingDelete(null)}
disabled={deleting} disabled={deleting}
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -251,7 +252,7 @@ export default function AutomationsPage() {
disabled={deleting} disabled={deleting}
> >
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />} {deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Delete {t("delete")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -267,6 +268,7 @@ function AutomationCard({
onDuplicate, onDuplicate,
onLogs, onLogs,
onDelete, onDelete,
t,
}: { }: {
automation: Automation automation: Automation
onToggle: (next: boolean) => void onToggle: (next: boolean) => void
@ -274,6 +276,7 @@ function AutomationCard({
onDuplicate: () => void onDuplicate: () => void
onLogs: () => void onLogs: () => void
onDelete: () => void onDelete: () => void
t: ReturnType<typeof useTranslations>
}) { }) {
const meta = triggerMeta(automation.trigger_type) const meta = triggerMeta(automation.trigger_type)
return ( return (
@ -315,10 +318,12 @@ function AutomationCard({
{meta.label} {meta.label}
</span> </span>
<span className="tabular-nums"> <span className="tabular-nums">
{automation.execution_count} run{automation.execution_count === 1 ? "" : "s"} {automation.execution_count === 1
? t("runs", { count: automation.execution_count })
: t("runsPlural", { count: automation.execution_count })}
</span> </span>
<span aria-hidden>·</span> <span aria-hidden>·</span>
<span>last {formatRelative(automation.last_executed_at)}</span> <span>{t("lastRun", { time: formatRelative(automation.last_executed_at) })}</span>
</div> </div>
</button> </button>
@ -326,7 +331,7 @@ function AutomationCard({
<Switch <Switch
checked={automation.is_active} checked={automation.is_active}
onCheckedChange={(v) => onToggle(!!v)} onCheckedChange={(v) => onToggle(!!v)}
aria-label={automation.is_active ? "Deactivate" : "Activate"} aria-label={automation.is_active ? t("deactivate") : t("activate")}
/> />
<DropdownMenu> <DropdownMenu>
@ -339,20 +344,20 @@ function AutomationCard({
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={onEdit}> <DropdownMenuItem onClick={onEdit}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
Edit {t("edit")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={onDuplicate}> <DropdownMenuItem onClick={onDuplicate}>
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
Duplicate {t("duplicate")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={onLogs}> <DropdownMenuItem onClick={onLogs}>
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
View Logs {t("viewLogs")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}> <DropdownMenuItem variant="destructive" onClick={onDelete}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
Delete {t("delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -38,6 +38,7 @@ import {
getBroadcastStatus, getBroadcastStatus,
getRecipientStatus, getRecipientStatus,
} from '@/lib/broadcast-status'; } from '@/lib/broadcast-status';
import { useTranslations } from 'next-intl';
interface StatCardProps { interface StatCardProps {
label: string; label: string;
@ -144,6 +145,8 @@ function downloadBlob(filename: string, content: string) {
export default function BroadcastDetailPage() { export default function BroadcastDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const t = useTranslations('Broadcasts.detail');
const tStatus = useTranslations('Broadcasts.status');
const broadcastId = params.id as string; const broadcastId = params.id as string;
const [broadcast, setBroadcast] = useState<Broadcast | null>(null); const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
@ -179,7 +182,7 @@ export default function BroadcastDetailPage() {
if (recsError) throw recsError; if (recsError) throw recsError;
setRecipients(recs ?? []); setRecipients(recs ?? []);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcast'); setError(err instanceof Error ? err.message : t('notFound'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -199,14 +202,13 @@ export default function BroadcastDetailPage() {
function handleExport() { function handleExport() {
if (!broadcast) return; if (!broadcast) return;
const header = [ const header = [
'Contact', t('table.contact'),
'Phone', t('table.phone'),
'Status', t('table.status'),
'Sent At', t('table.sent'),
'Delivered At', t('table.delivered'),
'Read At', t('table.read'),
'Replied At', t('table.error'),
'Error',
]; ];
const rows = recipients.map((r) => [ const rows = recipients.map((r) => [
r.contact?.name ?? '', r.contact?.name ?? '',
@ -215,7 +217,6 @@ export default function BroadcastDetailPage() {
r.sent_at ?? '', r.sent_at ?? '',
r.delivered_at ?? '', r.delivered_at ?? '',
r.read_at ?? '', r.read_at ?? '',
r.replied_at ?? '',
r.error_message ?? '', r.error_message ?? '',
]); ]);
const csv = toCsv([header, ...rows]); const csv = toCsv([header, ...rows]);
@ -236,10 +237,10 @@ export default function BroadcastDetailPage() {
.eq('id', broadcastId); .eq('id', broadcastId);
setDeleting(false); setDeleting(false);
if (delErr) { if (delErr) {
toast.error(`Failed to delete: ${delErr.message}`); toast.error(t('toastFailedDelete', { error: delErr.message }));
return; return;
} }
toast.success('Broadcast deleted'); toast.success(t('toastDeleted'));
router.push('/broadcasts'); router.push('/broadcasts');
} }
@ -254,9 +255,9 @@ export default function BroadcastDetailPage() {
if (error || !broadcast) { if (error || !broadcast) {
return ( return (
<div className="flex h-64 flex-col items-center justify-center gap-2"> <div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error ?? 'Broadcast not found'}</p> <p className="text-sm text-red-400">{error ?? t('notFound')}</p>
<Button variant="outline" onClick={() => router.push('/broadcasts')}> <Button variant="outline" onClick={() => router.push('/broadcasts')}>
Back to Broadcasts {t('backToBroadcasts')}
</Button> </Button>
</div> </div>
); );
@ -265,10 +266,10 @@ export default function BroadcastDetailPage() {
const status = getBroadcastStatus(broadcast.status); const status = getBroadcastStatus(broadcast.status);
const funnelSteps: FunnelStep[] = [ const funnelSteps: FunnelStep[] = [
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' }, { label: t('stats.sent'), value: broadcast.sent_count, color: 'bg-primary' },
{ label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' }, { label: t('stats.delivered'), value: broadcast.delivered_count, color: 'bg-teal-500' },
{ label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' }, { label: t('stats.read'), value: broadcast.read_count, color: 'bg-blue-500' },
{ label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' }, { label: t('stats.replied'), value: broadcast.replied_count, color: 'bg-indigo-500' },
]; ];
return ( return (
@ -290,14 +291,14 @@ export default function BroadcastDetailPage() {
<span <span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`} className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
> >
{status.label} {tStatus(status.label)}
</span> </span>
</div> </div>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground"> <div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>Template: {broadcast.template_name}</span> <span>{t('template', { name: broadcast.template_name })}</span>
<span>-</span> <span>-</span>
<span> <span>
Created {new Date(broadcast.created_at).toLocaleDateString()} {t('createdAt', { date: new Date(broadcast.created_at).toLocaleDateString() })}
</span> </span>
</div> </div>
</div> </div>
@ -309,7 +310,7 @@ export default function BroadcastDetailPage() {
funnel inconsistent. */} funnel inconsistent. */}
{confirmDelete ? ( {confirmDelete ? (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm"> <div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm">
<span className="text-red-300">Delete this broadcast?</span> <span className="text-red-300">{t('deletePrompt')}</span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -317,7 +318,7 @@ export default function BroadcastDetailPage() {
disabled={deleting} disabled={deleting}
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted" className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -325,7 +326,7 @@ export default function BroadcastDetailPage() {
disabled={deleting} disabled={deleting}
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50" className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
> >
{deleting ? 'Deleting…' : 'Confirm'} {deleting ? t('deleting') : t('confirm')}
</Button> </Button>
</div> </div>
) : ( ) : (
@ -336,13 +337,13 @@ export default function BroadcastDetailPage() {
onClick={() => setConfirmDelete(true)} onClick={() => setConfirmDelete(true)}
title={ title={
broadcast.status === 'sending' broadcast.status === 'sending'
? 'Cannot delete while a broadcast is actively sending' ? t('cannotDeleteSending')
: 'Delete this broadcast' : t('deleteHover')
} }
className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40" className="border-red-500/30 bg-transparent text-red-400 hover:bg-red-500/10 disabled:opacity-40"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete {t('delete')}
</Button> </Button>
)} )}
</div> </div>
@ -350,42 +351,42 @@ export default function BroadcastDetailPage() {
{/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */} {/* Stats — 6 cards: Total / Sent / Delivered / Read / Replied / Failed */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard <StatCard
label="Total Recipients" label={t('stats.totalRecipients')}
value={broadcast.total_recipients} value={broadcast.total_recipients}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<Users className="h-4 w-4" />} icon={<Users className="h-4 w-4" />}
color="bg-muted text-muted-foreground" color="bg-muted text-muted-foreground"
/> />
<StatCard <StatCard
label="Sent" label={t('stats.sent')}
value={broadcast.sent_count} value={broadcast.sent_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<Send className="h-4 w-4" />} icon={<Send className="h-4 w-4" />}
color="bg-primary/10 text-primary" color="bg-primary/10 text-primary"
/> />
<StatCard <StatCard
label="Delivered" label={t('stats.delivered')}
value={broadcast.delivered_count} value={broadcast.delivered_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<CheckCheck className="h-4 w-4" />} icon={<CheckCheck className="h-4 w-4" />}
color="bg-teal-500/10 text-teal-400" color="bg-teal-500/10 text-teal-400"
/> />
<StatCard <StatCard
label="Read" label={t('stats.read')}
value={broadcast.read_count} value={broadcast.read_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<Eye className="h-4 w-4" />} icon={<Eye className="h-4 w-4" />}
color="bg-blue-500/10 text-blue-400" color="bg-blue-500/10 text-blue-400"
/> />
<StatCard <StatCard
label="Replied" label={t('stats.replied')}
value={broadcast.replied_count} value={broadcast.replied_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<MessageCircle className="h-4 w-4" />} icon={<MessageCircle className="h-4 w-4" />}
color="bg-indigo-500/10 text-indigo-400" color="bg-indigo-500/10 text-indigo-400"
/> />
<StatCard <StatCard
label="Failed" label={t('stats.failed')}
value={broadcast.failed_count} value={broadcast.failed_count}
total={broadcast.total_recipients} total={broadcast.total_recipients}
icon={<AlertCircle className="h-4 w-4" />} icon={<AlertCircle className="h-4 w-4" />}
@ -399,8 +400,9 @@ export default function BroadcastDetailPage() {
<div className="rounded-xl border border-border bg-card"> <div className="rounded-xl border border-border bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3"> <div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground"> <h2 className="text-sm font-medium text-foreground">
Recipients ({filteredRecipients.length} {statusFilter !== 'all'
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''}) ? t('recipientsHeader', { filtered: filteredRecipients.length, total: recipients.length })
: t('recipientsHeaderAll', { total: recipients.length })}
</h2> </h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DropdownMenu> <DropdownMenu>
@ -415,8 +417,8 @@ export default function BroadcastDetailPage() {
> >
<Filter className="h-3.5 w-3.5" /> <Filter className="h-3.5 w-3.5" />
{statusFilter === 'all' {statusFilter === 'all'
? 'All statuses' ? t('allStatuses')
: getRecipientStatus(statusFilter).label} : tStatus(getRecipientStatus(statusFilter).label)}
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="border-border bg-popover"> <DropdownMenuContent className="border-border bg-popover">
@ -426,7 +428,7 @@ export default function BroadcastDetailPage() {
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground' statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
} }
> >
All statuses {t('allStatuses')}
</DropdownMenuItem> </DropdownMenuItem>
{RECIPIENT_STATUSES.map((s) => ( {RECIPIENT_STATUSES.map((s) => (
<DropdownMenuItem <DropdownMenuItem
@ -438,7 +440,7 @@ export default function BroadcastDetailPage() {
: 'text-popover-foreground' : 'text-popover-foreground'
} }
> >
{getRecipientStatus(s).label} {tStatus(getRecipientStatus(s).label)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@ -452,7 +454,7 @@ export default function BroadcastDetailPage() {
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
<Download className="h-3.5 w-3.5" /> <Download className="h-3.5 w-3.5" />
Export CSV {t('exportCsv')}
</Button> </Button>
</div> </div>
</div> </div>
@ -461,8 +463,8 @@ export default function BroadcastDetailPage() {
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{recipients.length === 0 {recipients.length === 0
? 'No recipients found.' ? t('noRecipients')
: 'No recipients match this filter.'} : t('noRecipientsFilter')}
</p> </p>
</div> </div>
) : ( ) : (
@ -470,13 +472,13 @@ export default function BroadcastDetailPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-border hover:bg-transparent"> <TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Contact</TableHead> <TableHead className="text-muted-foreground">{t('table.contact')}</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead> <TableHead className="text-muted-foreground">{t('table.phone')}</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead> <TableHead className="text-muted-foreground">{t('table.status')}</TableHead>
<TableHead className="text-muted-foreground">Sent</TableHead> <TableHead className="text-muted-foreground">{t('table.sent')}</TableHead>
<TableHead className="text-muted-foreground">Delivered</TableHead> <TableHead className="text-muted-foreground">{t('table.delivered')}</TableHead>
<TableHead className="text-muted-foreground">Read</TableHead> <TableHead className="text-muted-foreground">{t('table.read')}</TableHead>
<TableHead className="text-muted-foreground">Error</TableHead> <TableHead className="text-muted-foreground">{t('table.error')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -494,7 +496,7 @@ export default function BroadcastDetailPage() {
<span <span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`} className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
> >
{rStatus.label} {tStatus(rStatus.label)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">

View File

@ -12,16 +12,18 @@ import { Step3Personalize } from '@/components/broadcasts/step3-personalize';
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send'; import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
import { useBroadcastSending } from '@/hooks/use-broadcast-sending'; import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import { useTranslations } from 'next-intl';
const steps = [ const steps = [
{ label: 'Template', key: 'template' }, { label: 'template', key: 'template' },
{ label: 'Audience', key: 'audience' }, { label: 'audience', key: 'audience' },
{ label: 'Personalize', key: 'personalize' }, { label: 'personalize', key: 'personalize' },
{ label: 'Send', key: 'send' }, { label: 'send', key: 'send' },
] as const; ] as const;
export default function NewBroadcastPage() { export default function NewBroadcastPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations('Broadcasts.new');
const { accountId } = useAuth(); const { accountId } = useAuth();
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending(); const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
@ -82,7 +84,7 @@ export default function NewBroadcastPage() {
*/ */
async function handleSaveDraft() { async function handleSaveDraft() {
if (!template || !name.trim()) { if (!template || !name.trim()) {
toast.error('Give the broadcast a name before saving a draft.'); toast.error(t('toastGiveName'));
return; return;
} }
const supabase = createClient(); const supabase = createClient();
@ -91,11 +93,11 @@ export default function NewBroadcastPage() {
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user) { if (!user) {
toast.error('Not signed in.'); toast.error(t('toastNotSignedIn'));
return; return;
} }
if (!accountId) { if (!accountId) {
toast.error('Your profile is not linked to an account.'); toast.error(t('toastNotLinked'));
return; return;
} }
@ -120,10 +122,10 @@ export default function NewBroadcastPage() {
}); });
if (error) { if (error) {
toast.error(`Failed to save draft: ${error.message}`); toast.error(t('toastFailedDraft', { error: error.message }));
return; return;
} }
toast.success('Draft saved'); toast.success(t('toastDraftSaved'));
router.push('/broadcasts'); router.push('/broadcasts');
} }
@ -131,9 +133,9 @@ export default function NewBroadcastPage() {
<div className="mx-auto max-w-3xl space-y-8"> <div className="mx-auto max-w-3xl space-y-8">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-foreground">New Broadcast</h1> <h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Create and send a broadcast message to your contacts. {t('subtitle')}
</p> </p>
</div> </div>
@ -162,7 +164,7 @@ export default function NewBroadcastPage() {
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground' isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
}`} }`}
> >
{step.label} {t(`steps.${step.label}`)}
</span> </span>
</div> </div>
{index < steps.length - 1 && ( {index < steps.length - 1 && (

View File

@ -17,6 +17,7 @@ import { Radio, Plus, Loader2 } from 'lucide-react';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button'; import { GatedButton } from '@/components/ui/gated-button';
import { getBroadcastStatus } from '@/lib/broadcast-status'; import { getBroadcastStatus } from '@/lib/broadcast-status';
import { useTranslations } from 'next-intl';
/** /**
* Poll cadence while any broadcast is sending. Kept modest so we don't * Poll cadence while any broadcast is sending. Kept modest so we don't
@ -58,6 +59,8 @@ function RateCell({
export default function BroadcastsPage() { export default function BroadcastsPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations('Broadcasts.page');
const tStatus = useTranslations('Broadcasts.status');
const canCreate = useCan('send-messages'); const canCreate = useCan('send-messages');
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]); const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -77,7 +80,7 @@ export default function BroadcastsPage() {
if (fetchError) throw fetchError; if (fetchError) throw fetchError;
setBroadcasts(data ?? []); setBroadcasts(data ?? []);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcasts'); setError(err instanceof Error ? err.message : t('errorLoad'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -141,7 +144,7 @@ export default function BroadcastsPage() {
<div className="flex h-64 flex-col items-center justify-center gap-2"> <div className="flex h-64 flex-col items-center justify-center gap-2">
<p className="text-sm text-red-400">{error}</p> <p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => window.location.reload()}> <Button variant="outline" onClick={() => window.location.reload()}>
Retry {t('retry')}
</Button> </Button>
</div> </div>
); );
@ -179,9 +182,9 @@ export default function BroadcastsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Broadcasts</h1> <h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Send bulk messages to your contacts using approved templates. {t('subtitle')}
</p> </p>
</div> </div>
<GatedButton <GatedButton
@ -191,16 +194,16 @@ export default function BroadcastsPage() {
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
New Broadcast {t('newBroadcast')}
</GatedButton> </GatedButton>
</div> </div>
{broadcasts.length === 0 ? ( {broadcasts.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card"> <div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card">
<Radio className="mb-3 h-10 w-10 text-muted-foreground" /> <Radio className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">No broadcasts yet</p> <p className="text-sm font-medium text-foreground">{t('noBroadcastsYet')}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Create your first broadcast to reach your contacts at scale. {t('createFirst')}
</p> </p>
<GatedButton <GatedButton
canAct={canCreate} canAct={canCreate}
@ -209,7 +212,7 @@ export default function BroadcastsPage() {
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90" className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
New Broadcast {t('newBroadcast')}
</GatedButton> </GatedButton>
</div> </div>
) : ( ) : (
@ -217,15 +220,15 @@ export default function BroadcastsPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-border hover:bg-transparent"> <TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Name</TableHead> <TableHead className="text-muted-foreground">{t('table.name')}</TableHead>
<TableHead className="hidden text-muted-foreground md:table-cell">Template</TableHead> <TableHead className="hidden text-muted-foreground md:table-cell">{t('table.template')}</TableHead>
<TableHead className="hidden text-right text-muted-foreground sm:table-cell"> <TableHead className="hidden text-right text-muted-foreground sm:table-cell">
Recipients {t('table.recipients')}
</TableHead> </TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Delivery</TableHead> <TableHead className="hidden text-muted-foreground lg:table-cell">{t('table.delivery')}</TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Read</TableHead> <TableHead className="hidden text-muted-foreground lg:table-cell">{t('table.read')}</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead> <TableHead className="text-muted-foreground">{t('table.status')}</TableHead>
<TableHead className="hidden text-muted-foreground sm:table-cell">Date</TableHead> <TableHead className="hidden text-muted-foreground sm:table-cell">{t('table.date')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -270,7 +273,7 @@ export default function BroadcastsPage() {
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" /> <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-yellow-400" />
</span> </span>
)} )}
{status.label} {tStatus(status.label)}
</span> </span>
</TableCell> </TableCell>
<TableCell className="hidden text-muted-foreground sm:table-cell"> <TableCell className="hidden text-muted-foreground sm:table-cell">

View File

@ -14,6 +14,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -55,7 +56,7 @@ import { ImportModal } from '@/components/contacts/import-modal';
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager'; import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button'; import { GatedButton } from '@/components/ui/gated-button';
import { Checkbox } from '@/components/ui/checkbox'; import { useTranslations } from 'next-intl';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@ -64,6 +65,7 @@ interface ContactWithTags extends Contact {
} }
export default function ContactsPage() { export default function ContactsPage() {
const t = useTranslations('Contacts.page');
const supabase = createClient(); const supabase = createClient();
const canEdit = useCan('send-messages'); const canEdit = useCan('send-messages');
const canEditSettings = useCan('edit-settings'); const canEditSettings = useCan('edit-settings');
@ -144,7 +146,7 @@ export default function ContactsPage() {
}); });
if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) { if (error) {
toast.error('Failed to load contacts'); toast.error(t('toastFailedLoad'));
setLoading(false); setLoading(false);
return; return;
} }
@ -166,7 +168,7 @@ export default function ContactsPage() {
const { data, count: exactCount, error } = await query; const { data, count: exactCount, error } = await query;
if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) { if (error) {
toast.error('Failed to load contacts'); toast.error(t('toastFailedLoad'));
setLoading(false); setLoading(false);
return; return;
} }
@ -205,7 +207,7 @@ export default function ContactsPage() {
setContacts(enriched); setContacts(enriched);
setLoading(false); setLoading(false);
}, [supabase, page, search, selectedTagIds, tagsMap]); }, [supabase, page, search, selectedTagIds, tagsMap, t]);
// Load-once-on-mount-ish data fetches. Each setter inside runs // Load-once-on-mount-ish data fetches. Each setter inside runs
// inside an async promise completion (Supabase await), not // inside an async promise completion (Supabase await), not
@ -257,9 +259,9 @@ export default function ContactsPage() {
.eq('id', deleteTarget.id); .eq('id', deleteTarget.id);
if (error) { if (error) {
toast.error('Failed to delete contact'); toast.error(t('toastFailedDelete'));
} else { } else {
toast.success('Contact deleted'); toast.success(t('toastDeleted'));
fetchContacts(); fetchContacts();
} }
@ -301,9 +303,9 @@ export default function ContactsPage() {
const { error } = await supabase.from('contacts').delete().in('id', ids); const { error } = await supabase.from('contacts').delete().in('id', ids);
if (error) { if (error) {
toast.error('Failed to delete contacts'); toast.error(t('toastBulkFailedDelete'));
} else { } else {
toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`); toast.success(t('toastBulkDeleted', { count: ids.length }));
setSelected(new Set()); setSelected(new Set());
fetchContacts(); fetchContacts();
} }
@ -342,9 +344,9 @@ export default function ContactsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Contacts</h1> <h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`} {totalCount > 0 ? t('subtitle', { count: totalCount }) : t('subtitleZero')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -355,7 +357,7 @@ export default function ContactsPage() {
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
<SlidersHorizontal className="size-4" /> <SlidersHorizontal className="size-4" />
Custom fields {t('customFieldsBtn')}
</Button> </Button>
)} )}
<GatedButton <GatedButton
@ -366,7 +368,7 @@ export default function ContactsPage() {
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
<Upload className="size-4" /> <Upload className="size-4" />
Import {t('importBtn')}
</GatedButton> </GatedButton>
<GatedButton <GatedButton
canAct={canEdit} canAct={canEdit}
@ -375,7 +377,7 @@ export default function ContactsPage() {
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
<Plus className="size-4" /> <Plus className="size-4" />
Add Contact {t('addContactBtn')}
</GatedButton> </GatedButton>
</div> </div>
</div> </div>
@ -393,7 +395,7 @@ export default function ContactsPage() {
// set shrinks/grows, page N may no longer be valid. // set shrinks/grows, page N may no longer be valid.
setPage(0); setPage(0);
}} }}
placeholder="Search by name, phone, or email..." placeholder={t('searchPlaceholder')}
className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground" className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
@ -408,7 +410,7 @@ export default function ContactsPage() {
} }
> >
<Filter className="size-4" /> <Filter className="size-4" />
Filter by tags {t('filterByTags')}
{selectedTagIds.length > 0 && ( {selectedTagIds.length > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground"> <span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
{selectedTagIds.length} {selectedTagIds.length}
@ -418,20 +420,20 @@ export default function ContactsPage() {
<PopoverContent align="start" className="w-64 p-0"> <PopoverContent align="start" className="w-64 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-border"> <div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-sm font-medium text-popover-foreground"> <span className="text-sm font-medium text-popover-foreground">
Filter by tags {t('filterByTags')}
</span> </span>
{selectedTagIds.length > 0 && ( {selectedTagIds.length > 0 && (
<button <button
onClick={clearTagFilters} onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
Clear all {t('clearAll')}
</button> </button>
)} )}
</div> </div>
{allTags.length === 0 ? ( {allTags.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center"> <p className="px-3 py-4 text-sm text-muted-foreground text-center">
No tags yet. {t('noTagsYet')}
</p> </p>
) : ( ) : (
<div className="max-h-64 overflow-y-auto py-1"> <div className="max-h-64 overflow-y-auto py-1">
@ -490,7 +492,7 @@ export default function ContactsPage() {
onClick={clearTagFilters} onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground px-1" className="text-xs text-muted-foreground hover:text-foreground px-1"
> >
Clear all {t('clearAll')}
</button> </button>
</div> </div>
)} )}
@ -500,8 +502,7 @@ export default function ContactsPage() {
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2"> <div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2">
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
<span className="font-medium">{selected.size}</span>{' '} {t('selectedCount', { count: selected.size })}
{selected.size === 1 ? 'contact' : 'contacts'} selected
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@ -510,7 +511,7 @@ export default function ContactsPage() {
onClick={() => setSelected(new Set())} onClick={() => setSelected(new Set())}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
Clear {t('clearSelection')}
</Button> </Button>
<GatedButton <GatedButton
variant="destructive" variant="destructive"
@ -520,7 +521,7 @@ export default function ContactsPage() {
onClick={() => setBulkDeleteOpen(true)} onClick={() => setBulkDeleteOpen(true)}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete selected {t('deleteSelected')}
</GatedButton> </GatedButton>
</div> </div>
</div> </div>
@ -540,12 +541,12 @@ export default function ContactsPage() {
aria-label="Select all contacts on this page" aria-label="Select all contacts on this page"
/> />
</TableHead> </TableHead>
<TableHead className="text-muted-foreground">Name</TableHead> <TableHead className="text-muted-foreground">{t('tableColumns.name')}</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead> <TableHead className="text-muted-foreground">{t('tableColumns.phone')}</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead> <TableHead className="text-muted-foreground hidden md:table-cell">{t('tableColumns.email')}</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Company</TableHead> <TableHead className="text-muted-foreground hidden lg:table-cell">{t('tableColumns.company')}</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Tags</TableHead> <TableHead className="text-muted-foreground hidden md:table-cell">{t('tableColumns.tags')}</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Created</TableHead> <TableHead className="text-muted-foreground hidden lg:table-cell">{t('tableColumns.createdAt')}</TableHead>
<TableHead className="text-muted-foreground w-12" /> <TableHead className="text-muted-foreground w-12" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -555,7 +556,7 @@ export default function ContactsPage() {
<TableCell colSpan={8} className="text-center py-12"> <TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Loader2 className="size-6 animate-spin text-primary" /> <Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading contacts...</p> <p className="text-sm text-muted-foreground">{t('loading')}</p>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -566,8 +567,8 @@ export default function ContactsPage() {
<Users className="size-8 text-muted-foreground" /> <Users className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{hasActiveFilters {hasActiveFilters
? 'No contacts match your filters.' ? t('noContactsMatch')
: 'No contacts yet.'} : t('noContactsYet')}
</p> </p>
{!hasActiveFilters && ( {!hasActiveFilters && (
<GatedButton <GatedButton
@ -579,7 +580,7 @@ export default function ContactsPage() {
className="mt-2 border-border text-muted-foreground hover:bg-muted" className="mt-2 border-border text-muted-foreground hover:bg-muted"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
Add your first contact {t('addFirstContact')}
</GatedButton> </GatedButton>
)} )}
</div> </div>
@ -600,7 +601,7 @@ export default function ContactsPage() {
/> />
</TableCell> </TableCell>
<TableCell className="text-foreground font-medium"> <TableCell className="text-foreground font-medium">
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>} {contact.name || <span className="text-muted-foreground italic">{t('unnamed')}</span>}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground font-mono text-xs"> <TableCell className="text-muted-foreground font-mono text-xs">
{contact.phone} {contact.phone}
@ -669,7 +670,7 @@ export default function ContactsPage() {
className="text-popover-foreground focus:bg-muted focus:text-foreground" className="text-popover-foreground focus:bg-muted focus:text-foreground"
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit {t('editAction')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
@ -680,7 +681,7 @@ export default function ContactsPage() {
}} }}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete {t('deleteAction')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -696,8 +697,11 @@ export default function ContactsPage() {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '} {t('showingPagination', {
{totalCount} start: page * PAGE_SIZE + 1,
end: Math.min((page + 1) * PAGE_SIZE, totalCount),
total: totalCount
})}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@ -710,7 +714,7 @@ export default function ContactsPage() {
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
</Button> </Button>
<span className="text-xs text-muted-foreground px-2"> <span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {totalPages} {t('pageCount', { page: page + 1, total: totalPages })}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@ -768,13 +772,9 @@ export default function ContactsPage() {
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> <Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm"> <DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Delete Contact</DialogTitle> <DialogTitle className="text-popover-foreground">{t('deleteContactTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '} {t('deleteContactDesc', { name: deleteTarget?.name || deleteTarget?.phone || '' })}
<span className="text-popover-foreground font-medium">
{deleteTarget?.name || deleteTarget?.phone}
</span>
? This action cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -783,7 +783,7 @@ export default function ContactsPage() {
onClick={() => setDeleteConfirmOpen(false)} onClick={() => setDeleteConfirmOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -791,7 +791,7 @@ export default function ContactsPage() {
disabled={deleting} disabled={deleting}
> >
{deleting && <Loader2 className="size-4 animate-spin" />} {deleting && <Loader2 className="size-4 animate-spin" />}
Delete {t('deleteBtn')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -802,14 +802,10 @@ export default function ContactsPage() {
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm"> <DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'} {t('deleteBulkTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '} {t('deleteBulkDesc', { count: selected.size })}
<span className="text-popover-foreground font-medium">
{selected.size} {selected.size === 1 ? 'contact' : 'contacts'}
</span>
? This action cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -818,7 +814,7 @@ export default function ContactsPage() {
onClick={() => setBulkDeleteOpen(false)} onClick={() => setBulkDeleteOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -826,7 +822,7 @@ export default function ContactsPage() {
disabled={deleting} disabled={deleting}
> >
{deleting && <Loader2 className="size-4 animate-spin" />} {deleting && <Loader2 className="size-4 animate-spin" />}
Delete {t('deleteBtn')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -34,9 +34,12 @@ import { PipelineDonut } from '@/components/dashboard/pipeline-donut'
import { ResponseTimeChart } from '@/components/dashboard/response-time-chart' import { ResponseTimeChart } from '@/components/dashboard/response-time-chart'
import { ActivityFeed } from '@/components/dashboard/activity-feed' import { ActivityFeed } from '@/components/dashboard/activity-feed'
import { useTranslations } from 'next-intl'
type RangeDays = 7 | 30 | 90 type RangeDays = 7 | 30 | 90
export default function DashboardPage() { export default function DashboardPage() {
const t = useTranslations('Dashboard.page')
const { defaultCurrency } = useAuth() const { defaultCurrency } = useAuth()
const [metrics, setMetrics] = useState<MetricsBundle | null>(null) const [metrics, setMetrics] = useState<MetricsBundle | null>(null)
const [metricsLoading, setMetricsLoading] = useState(true) const [metricsLoading, setMetricsLoading] = useState(true)
@ -122,9 +125,9 @@ export default function DashboardPage() {
<div className="space-y-5"> <div className="space-y-5">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1> <h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Live analytics across conversations, contacts, deals, broadcasts, and automations. {t('description')}
</p> </p>
</div> </div>
@ -135,16 +138,20 @@ export default function DashboardPage() {
) : ( ) : (
<> <>
<MetricCard <MetricCard
title="Active Conversations" title={t('activeConversations')}
value={metrics.activeConversations.current.toLocaleString()} value={metrics.activeConversations.current.toLocaleString()}
icon={MessageSquare} icon={MessageSquare}
delta={{ delta={{
sign: metrics.activeConversations.previous, sign: metrics.activeConversations.previous,
label: deltaLabel(metrics.activeConversations.previous, 'new today vs yesterday'), label: deltaLabel(
metrics.activeConversations.previous,
t('newTodayVsYesterday'),
t('noChange', { suffix: t('newTodayVsYesterday') })
),
}} }}
/> />
<MetricCard <MetricCard
title="New Contacts Today" title={t('newContactsToday')}
value={metrics.newContactsToday.current.toLocaleString()} value={metrics.newContactsToday.current.toLocaleString()}
icon={UserPlus} icon={UserPlus}
delta={{ delta={{
@ -152,18 +159,19 @@ export default function DashboardPage() {
metrics.newContactsToday.current - metrics.newContactsToday.previous, metrics.newContactsToday.current - metrics.newContactsToday.previous,
label: deltaLabel( label: deltaLabel(
metrics.newContactsToday.current - metrics.newContactsToday.previous, metrics.newContactsToday.current - metrics.newContactsToday.previous,
'vs yesterday', t('vsYesterday'),
t('noChange', { suffix: t('vsYesterday') })
), ),
}} }}
/> />
<MetricCard <MetricCard
title="Open Deals Value" title={t('openDealsValue')}
value={formatCurrency(metrics.openDealsValue, defaultCurrency)} value={formatCurrency(metrics.openDealsValue, defaultCurrency)}
icon={DollarSign} icon={DollarSign}
subtitle={`${metrics.openDealsCount} open deal${metrics.openDealsCount === 1 ? '' : 's'}`} subtitle={t('openDeals', { count: metrics.openDealsCount })}
/> />
<MetricCard <MetricCard
title="Messages Sent Today" title={t('messagesSentToday')}
value={metrics.messagesSentToday.current.toLocaleString()} value={metrics.messagesSentToday.current.toLocaleString()}
icon={Send} icon={Send}
delta={{ delta={{
@ -171,7 +179,8 @@ export default function DashboardPage() {
metrics.messagesSentToday.current - metrics.messagesSentToday.previous, metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
label: deltaLabel( label: deltaLabel(
metrics.messagesSentToday.current - metrics.messagesSentToday.previous, metrics.messagesSentToday.current - metrics.messagesSentToday.previous,
'vs yesterday', t('vsYesterday'),
t('noChange', { suffix: t('vsYesterday') })
), ),
}} }}
/> />
@ -218,8 +227,8 @@ export default function DashboardPage() {
// ------------------------------------------------------------ // ------------------------------------------------------------
function deltaLabel(delta: number, suffix: string): string { function deltaLabel(delta: number, suffix: string, noChangeLabel: string): string {
if (delta === 0) return `No change ${suffix}` if (delta === 0) return noChangeLabel
const sign = delta > 0 ? '+' : '' const sign = delta > 0 ? '+' : ''
return `${sign}${delta.toLocaleString()} ${suffix}` return `${sign}${delta.toLocaleString()} ${suffix}`
} }

View File

@ -5,6 +5,8 @@ import { useParams, useRouter } from "next/navigation";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { FlowEditorShell } from "@/components/flows/flow-editor-shell"; import { FlowEditorShell } from "@/components/flows/flow-editor-shell";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
@ -23,6 +25,7 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
export default function FlowEditorPage() { export default function FlowEditorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const t = useTranslations("Flows.edit");
const [flow, setFlow] = useState<FlowRow | null>(null); const [flow, setFlow] = useState<FlowRow | null>(null);
const [nodes, setNodes] = useState<FlowNodeRow[]>([]); const [nodes, setNodes] = useState<FlowNodeRow[]>([]);
@ -51,7 +54,7 @@ export default function FlowEditorPage() {
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error(err); console.error(err);
toast.error("Couldn't load flow."); toast.error(t("loadError"));
} }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@ -72,13 +75,13 @@ export default function FlowEditorPage() {
if (notFound || !flow) { if (notFound || !flow) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3"> <div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p> <p className="text-sm text-muted-foreground">{t("notFound")}</p>
<button <button
type="button" type="button"
onClick={() => router.push("/flows")} onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80" className="text-sm text-primary hover:opacity-80"
> >
← Back to flows {t("backToFlows")}
</button> </button>
</div> </div>
); );

View File

@ -17,6 +17,8 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -96,6 +98,8 @@ const STATUS_META: Record<
export default function FlowRunsPage() { export default function FlowRunsPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const t = useTranslations("Flows.logs");
const tEdit = useTranslations("Flows.edit");
const [flow, setFlow] = useState<{ id: string; name: string } | null>(null); const [flow, setFlow] = useState<{ id: string; name: string } | null>(null);
const [runs, setRuns] = useState<RunRow[]>([]); const [runs, setRuns] = useState<RunRow[]>([]);
@ -128,7 +132,7 @@ export default function FlowRunsPage() {
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error(err); console.error(err);
toast.error("Couldn't load runs."); toast.error(t("loadError"));
} }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@ -158,13 +162,13 @@ export default function FlowRunsPage() {
if (notFound || !flow) { if (notFound || !flow) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3"> <div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-muted-foreground">Flow not found.</p> <p className="text-sm text-muted-foreground">{tEdit("notFound")}</p>
<button <button
type="button" type="button"
onClick={() => router.push("/flows")} onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80" className="text-sm text-primary hover:opacity-80"
> >
← Back to flows {tEdit("backToFlows")}
</button> </button>
</div> </div>
); );
@ -180,16 +184,14 @@ export default function FlowRunsPage() {
<ArrowLeft className="h-3 w-3" /> <ArrowLeft className="h-3 w-3" />
{flow.name} {flow.name}
</button> </button>
<h1 className="text-xl font-semibold text-foreground">Runs</h1> <h1 className="text-xl font-semibold text-foreground">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
The 50 most recent times this flow ran. Expand a row to see the engine&apos;s {t("description")}
per-step log.
</p> </p>
{runs.length === 0 ? ( {runs.length === 0 ? (
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground"> <div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
No runs yet. Trigger the flow from a personal WhatsApp number to see {t("emptyState")}
it appear here.
</div> </div>
) : ( ) : (
<div className="mt-6 flex flex-col gap-2"> <div className="mt-6 flex flex-col gap-2">
@ -200,6 +202,7 @@ export default function FlowRunsPage() {
events={events.filter((e) => e.flow_run_id === run.id)} events={events.filter((e) => e.flow_run_id === run.id)}
expanded={expanded.has(run.id)} expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)} onToggle={() => toggle(run.id)}
t={t}
/> />
))} ))}
</div> </div>
@ -213,16 +216,18 @@ function RunCard({
events, events,
expanded, expanded,
onToggle, onToggle,
t,
}: { }: {
run: RunRow; run: RunRow;
events: EventRow[]; events: EventRow[];
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const meta = STATUS_META[run.status]; const meta = STATUS_META[run.status];
const StatusIcon = meta.icon; const StatusIcon = meta.icon;
const contactLabel = const contactLabel =
run.contact?.name?.trim() || run.contact?.phone || "Unknown contact"; run.contact?.name?.trim() || run.contact?.phone || t("unknownContact");
const duration = run.ended_at const duration = run.ended_at
? formatDistanceToNow(new Date(run.ended_at), { ? formatDistanceToNow(new Date(run.ended_at), {
addSuffix: false, addSuffix: false,
@ -247,20 +252,32 @@ function RunCard({
</span> </span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}> <Badge variant="outline" className={cn("gap-1", meta.classes)}>
<StatusIcon className="h-3 w-3" /> <StatusIcon className="h-3 w-3" />
{meta.label} {t(
run.status === "active"
? "statusActive"
: run.status === "completed"
? "statusCompleted"
: run.status === "handed_off"
? "statusHandedOff"
: run.status === "timed_out"
? "statusTimedOut"
: run.status === "paused_by_agent"
? "statusPaused"
: "statusFailed"
)}
</Badge> </Badge>
{run.status === "active" && run.current_node_key && ( {run.status === "active" && run.current_node_key && (
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"> <code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
at {run.current_node_key} {t("atNode", { node: run.current_node_key })}
</code> </code>
)} )}
</div> </div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"> <div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>Started {format(new Date(run.started_at), "PP p")}</span> <span>{t("started", { time: format(new Date(run.started_at), "PP p") })}</span>
{run.reprompt_count > 0 && ( {run.reprompt_count > 0 && (
<span>· {run.reprompt_count} re-prompts</span> <span>· {t("reprompts", { count: run.reprompt_count })}</span>
)} )}
{duration && <span>· ran for {duration}</span>} {duration && <span>· {t("ranFor", { duration })}</span>}
</div> </div>
</div> </div>
</button> </button>
@ -269,7 +286,7 @@ function RunCard({
{Object.keys(run.vars).length > 0 && ( {Object.keys(run.vars).length > 0 && (
<details className="mb-3"> <details className="mb-3">
<summary className="cursor-pointer text-xs text-muted-foreground"> <summary className="cursor-pointer text-xs text-muted-foreground">
Captured vars ({Object.keys(run.vars).length}) {t("capturedVars", { count: Object.keys(run.vars).length })}
</summary> </summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground"> <pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
{JSON.stringify(run.vars, null, 2)} {JSON.stringify(run.vars, null, 2)}
@ -279,7 +296,7 @@ function RunCard({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{events.length === 0 ? ( {events.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No events recorded for this run. {t("noEvents")}
</p> </p>
) : ( ) : (
events.map((ev, ix) => <EventLine key={ix} ev={ev} />) events.map((ev, ix) => <EventLine key={ix} ev={ev} />)

View File

@ -18,6 +18,7 @@ import {
FileText, FileText,
} from "lucide-react"; } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCan } from "@/hooks/use-can"; import { useCan } from "@/hooks/use-can";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button"; import { GatedButton } from "@/components/ui/gated-button";
@ -54,11 +55,11 @@ interface FlowRow {
updated_at: string; updated_at: string;
} }
const STATUS_LABELS: Record<FlowRow["status"], string> = { const STATUS_LABELS = (t: ReturnType<typeof useTranslations>): Record<FlowRow["status"], string> => ({
draft: "Draft", draft: t("statusDraft"),
active: "Active", active: t("statusActive"),
archived: "Archived", archived: t("statusArchived"),
}; });
const STATUS_COLORS: Record<FlowRow["status"], string> = { const STATUS_COLORS: Record<FlowRow["status"], string> = {
draft: "border-border bg-muted text-muted-foreground", draft: "border-border bg-muted text-muted-foreground",
@ -84,6 +85,7 @@ const TEMPLATE_ICONS = {
export default function FlowsPage() { export default function FlowsPage() {
const router = useRouter(); const router = useRouter();
const canCreate = useCan("send-messages"); const canCreate = useCan("send-messages");
const t = useTranslations("Flows.list");
const [flows, setFlows] = useState<FlowRow[]>([]); const [flows, setFlows] = useState<FlowRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@ -115,7 +117,7 @@ export default function FlowsPage() {
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error(err); console.error(err);
toast.error("Couldn't load flows."); toast.error(t("loadError"));
} }
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
@ -146,7 +148,7 @@ export default function FlowsPage() {
router.push(`/flows/${json.flow.id}`); router.push(`/flows/${json.flow.id}`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error("Couldn't create flow."); toast.error(t("createError"));
} finally { } finally {
setCreating(false); setCreating(false);
} }
@ -168,7 +170,7 @@ export default function FlowsPage() {
setCreateOpen(false); setCreateOpen(false);
router.push(`/flows/${json.flow.id}`); router.push(`/flows/${json.flow.id}`);
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Clone failed"; const msg = err instanceof Error ? err.message : t("cloneError");
toast.error(msg); toast.error(msg);
} finally { } finally {
setCreating(false); setCreating(false);
@ -176,18 +178,16 @@ export default function FlowsPage() {
} }
async function handleDelete(flow: FlowRow) { async function handleDelete(flow: FlowRow) {
const yes = window.confirm( const yes = window.confirm(t("deleteConfirm", { name: flow.name }));
`Delete "${flow.name}"? Any active runs will end immediately.`,
);
if (!yes) return; if (!yes) return;
try { try {
const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" }); const res = await fetch(`/api/flows/${flow.id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Delete failed: ${res.status}`); if (!res.ok) throw new Error(`Delete failed: ${res.status}`);
setFlows((prev) => prev.filter((f) => f.id !== flow.id)); setFlows((prev) => prev.filter((f) => f.id !== flow.id));
toast.success("Flow deleted."); toast.success(t("deleteSuccess"));
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error("Couldn't delete flow."); toast.error(t("deleteError"));
} }
} }
@ -204,14 +204,13 @@ export default function FlowsPage() {
<header className="flex flex-wrap items-end justify-between gap-3"> <header className="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">Flows</h1> <h1 className="text-2xl font-semibold text-foreground">{t("title")}</h1>
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300"> <span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
Beta {t("beta")}
</span> </span>
</div> </div>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Build branching, button-driven WhatsApp conversations. Useful for {t("description")}
menus, FAQs, and triage before a human steps in.
</p> </p>
</div> </div>
<GatedButton <GatedButton
@ -220,7 +219,7 @@ export default function FlowsPage() {
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
New flow {t("newFlow")}
</GatedButton> </GatedButton>
</header> </header>
@ -228,6 +227,7 @@ export default function FlowsPage() {
<EmptyState <EmptyState
onCreate={() => setCreateOpen(true)} onCreate={() => setCreateOpen(true)}
canCreate={canCreate} canCreate={canCreate}
t={t}
/> />
) : ( ) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@ -237,6 +237,7 @@ export default function FlowsPage() {
flow={flow} flow={flow}
onEdit={() => router.push(`/flows/${flow.id}`)} onEdit={() => router.push(`/flows/${flow.id}`)}
onDelete={() => handleDelete(flow)} onDelete={() => handleDelete(flow)}
t={t}
/> />
))} ))}
</div> </div>
@ -249,37 +250,37 @@ export default function FlowsPage() {
sm-scoped 384px wins at every real desktop breakpoint. */} sm-scoped 384px wins at every real desktop breakpoint. */}
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground"> <DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
<DialogHeader> <DialogHeader>
<DialogTitle>Create a new flow</DialogTitle> <DialogTitle>{t("createTitle")}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Start from a template or build from scratch. {t("createDesc")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{templates.length > 0 && ( {templates.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Start from a template {t("startTemplate")}
</p> </p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{templates.map((t) => { {templates.map((template) => {
const Icon = TEMPLATE_ICONS[t.icon] ?? FileText; const Icon = TEMPLATE_ICONS[template.icon] ?? FileText;
return ( return (
<button <button
key={t.slug} key={template.slug}
type="button" type="button"
onClick={() => handleUseTemplate(t.slug)} onClick={() => handleUseTemplate(template.slug)}
disabled={creating} disabled={creating}
className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50" className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50"
> >
<Icon className="h-5 w-5 text-primary" /> <Icon className="h-5 w-5 text-primary" />
<span className="text-sm font-semibold text-popover-foreground"> <span className="text-sm font-semibold text-popover-foreground">
{t.name} {template.name}
</span> </span>
<span className="text-xs leading-relaxed text-muted-foreground"> <span className="text-xs leading-relaxed text-muted-foreground">
{t.description} {template.description}
</span> </span>
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground"> <span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
{t.node_count} {t.node_count === 1 ? "node" : "nodes"} {t("nodeCount", { count: template.node_count })}
</span> </span>
</button> </button>
); );
@ -290,12 +291,12 @@ export default function FlowsPage() {
<div className="space-y-2 border-t border-border pt-4"> <div className="space-y-2 border-t border-border pt-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">
Or start blank {t("startBlank")}
</p> </p>
<Input <Input
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Welcome menu" placeholder={t("placeholderName")}
className="bg-muted" className="bg-muted"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleCreate(); if (e.key === "Enter") handleCreate();
@ -309,11 +310,11 @@ export default function FlowsPage() {
onClick={() => setCreateOpen(false)} onClick={() => setCreateOpen(false)}
disabled={creating} disabled={creating}
> >
Cancel {t("cancel")}
</Button> </Button>
<Button onClick={handleCreate} disabled={!newName.trim() || creating}> <Button onClick={handleCreate} disabled={!newName.trim() || creating}>
{creating && <Loader2 className="h-4 w-4 animate-spin" />} {creating && <Loader2 className="h-4 w-4 animate-spin" />}
Create blank flow {t("createBlank")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -325,9 +326,11 @@ export default function FlowsPage() {
function EmptyState({ function EmptyState({
onCreate, onCreate,
canCreate, canCreate,
t,
}: { }: {
onCreate: () => void; onCreate: () => void;
canCreate: boolean; canCreate: boolean;
t: ReturnType<typeof useTranslations>;
}) { }) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center"> <div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center">
@ -335,12 +338,10 @@ function EmptyState({
<Workflow className="h-6 w-6 text-muted-foreground" /> <Workflow className="h-6 w-6 text-muted-foreground" />
</div> </div>
<h2 className="mt-4 text-base font-medium text-foreground"> <h2 className="mt-4 text-base font-medium text-foreground">
No flows yet {t("emptyTitle")}
</h2> </h2>
<p className="mt-1 max-w-md text-sm text-muted-foreground"> <p className="mt-1 max-w-md text-sm text-muted-foreground">
Build your first conversation — a welcome menu, an order lookup, an FAQ {t("emptyDesc")}
bot. Customers tap buttons; the bot routes them to the right answer (or
the right agent).
</p> </p>
<GatedButton <GatedButton
canAct={canCreate} canAct={canCreate}
@ -349,7 +350,7 @@ function EmptyState({
className="mt-5" className="mt-5"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Create your first flow {t("createFirst")}
</GatedButton> </GatedButton>
</div> </div>
); );
@ -359,12 +360,14 @@ function FlowCard({
flow, flow,
onEdit, onEdit,
onDelete, onDelete,
t,
}: { }: {
flow: FlowRow; flow: FlowRow;
onEdit: () => void; onEdit: () => void;
onDelete: () => void; onDelete: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const triggerSummary = describeTrigger(flow); const triggerSummary = describeTrigger(flow, t);
const StatusIcon = const StatusIcon =
flow.status === "active" flow.status === "active"
? PlayCircle ? PlayCircle
@ -388,7 +391,7 @@ function FlowCard({
)} )}
> >
<StatusIcon className="h-3 w-3" /> <StatusIcon className="h-3 w-3" />
{STATUS_LABELS[flow.status]} {STATUS_LABELS(t)[flow.status]}
</Badge> </Badge>
</div> </div>
@ -399,14 +402,14 @@ function FlowCard({
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground"> <div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" /> <MessageSquare className="h-3 w-3" />
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"} {t("runCount", { count: flow.execution_count })}
</span> </span>
</div> </div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3"> <div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onEdit}> <Button variant="ghost" size="sm" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
Edit {t("edit")}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -415,23 +418,23 @@ function FlowCard({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300" className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete {t("delete")}
</Button> </Button>
</div> </div>
</div> </div>
); );
} }
function describeTrigger(flow: FlowRow): string { function describeTrigger(flow: FlowRow, t: ReturnType<typeof useTranslations>): string {
if (flow.trigger_type === "keyword") { if (flow.trigger_type === "keyword") {
const keywords = Array.isArray(flow.trigger_config.keywords) const keywords = Array.isArray(flow.trigger_config.keywords)
? (flow.trigger_config.keywords as string[]) ? (flow.trigger_config.keywords as string[])
: []; : [];
if (keywords.length === 0) return "Triggers on keyword (none set)"; if (keywords.length === 0) return t("triggerKeywordNone");
return `Triggers on: ${keywords.join(", ")}`; return t("triggerKeyword", { keywords: keywords.join(", ") });
} }
if (flow.trigger_type === "first_inbound_message") { if (flow.trigger_type === "first_inbound_message") {
return "Triggers on a contact's first-ever inbound message"; return t("triggerFirstInbound");
} }
return "Manual trigger"; return t("triggerManual");
} }

View File

@ -2,6 +2,7 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { import {
CONVERSATION_SELECT, CONVERSATION_SELECT,
@ -21,6 +22,7 @@ import { cn } from "@/lib/utils";
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open"; const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
export default function InboxPage() { export default function InboxPage() {
const t = useTranslations("Inbox.page");
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
/** /**
@ -556,7 +558,7 @@ export default function InboxPage() {
<div className="flex shrink-0 items-center justify-center gap-2 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2"> <div className="flex shrink-0 items-center justify-center gap-2 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2">
<WifiOff className="h-4 w-4 text-amber-400" /> <WifiOff className="h-4 w-4 text-amber-400" />
<p className="text-xs text-amber-400"> <p className="text-xs text-amber-400">
WhatsApp® is not connected. Go to Settings to connect your account. {t("whatsappNotConnected")}
</p> </p>
</div> </div>
)} )}

View File

@ -29,6 +29,7 @@ import { toast } from "sonner";
import { useCan } from "@/hooks/use-can"; import { useCan } from "@/hooks/use-can";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { GatedButton } from "@/components/ui/gated-button"; import { GatedButton } from "@/components/ui/gated-button";
import { useTranslations } from "next-intl";
// Pipeline creation is admin-class (settings-tier write under // Pipeline creation is admin-class (settings-tier write under
// the new RLS); deal creation is operational and only requires // the new RLS); deal creation is operational and only requires
@ -45,6 +46,7 @@ const SPEC_DEFAULT_STAGES = [
]; ];
export default function PipelinesPage() { export default function PipelinesPage() {
const t = useTranslations("Pipelines.page");
const supabase = createClient(); const supabase = createClient();
const canEditSettings = useCan("edit-settings"); const canEditSettings = useCan("edit-settings");
const canCreateDeals = useCan("send-messages"); const canCreateDeals = useCan("send-messages");
@ -223,11 +225,11 @@ export default function PipelinesPage() {
.update({ stage_id: newStageId }) .update({ stage_id: newStageId })
.eq("id", dealId); .eq("id", dealId);
if (error) { if (error) {
toast.error("Failed to move deal"); toast.error(t("toastFailedMoveDeal"));
refreshDeals(); refreshDeals();
} }
}, },
[supabase, refreshDeals], [supabase, refreshDeals, t],
); );
const handleAddDeal = useCallback( const handleAddDeal = useCallback(
@ -260,7 +262,7 @@ export default function PipelinesPage() {
} }
// pipelines.account_id is NOT NULL post-017 with no DB default. // pipelines.account_id is NOT NULL post-017 with no DB default.
if (!accountId) { if (!accountId) {
toast.error("Your profile is not linked to an account."); toast.error(t("toastNotLinkedToAccount"));
setCreating(false); setCreating(false);
return; return;
} }
@ -272,7 +274,7 @@ export default function PipelinesPage() {
.single(); .single();
if (error || !pipeline) { if (error || !pipeline) {
toast.error("Failed to create pipeline"); toast.error(t("toastFailedCreatePipeline"));
setCreating(false); setCreating(false);
return; return;
} }
@ -290,7 +292,7 @@ export default function PipelinesPage() {
setSelectedPipelineId(pipeline.id); setSelectedPipelineId(pipeline.id);
await refreshPipelines(); await refreshPipelines();
setCreating(false); setCreating(false);
toast.success("Pipeline created"); toast.success(t("toastPipelineCreated"));
} }
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId); const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
@ -323,7 +325,7 @@ export default function PipelinesPage() {
> >
<GitBranch className="h-4 w-4 text-primary" /> <GitBranch className="h-4 w-4 text-primary" />
<span className="font-semibold"> <span className="font-semibold">
{selectedPipeline?.name ?? "Select Pipeline"} {selectedPipeline?.name ?? t("selectPipeline")}
</span> </span>
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <ChevronDown className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -333,7 +335,7 @@ export default function PipelinesPage() {
> >
{pipelines.length === 0 && ( {pipelines.length === 0 && (
<DropdownMenuItem disabled className="text-muted-foreground"> <DropdownMenuItem disabled className="text-muted-foreground">
No pipelines yet {t("noPipelinesYet")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{pipelines.map((p) => ( {pipelines.map((p) => (
@ -357,7 +359,7 @@ export default function PipelinesPage() {
className="text-popover-foreground" className="text-popover-foreground"
> >
<Settings className="mr-2 h-3.5 w-3.5" /> <Settings className="mr-2 h-3.5 w-3.5" />
Manage Pipelines {t("managePipelines")}
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
@ -373,7 +375,7 @@ export default function PipelinesPage() {
className="border-border bg-card text-foreground hover:bg-muted" className="border-border bg-card text-foreground hover:bg-muted"
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
Add Pipeline {t("addPipeline")}
</GatedButton> </GatedButton>
<GatedButton <GatedButton
canAct={canCreateDeals} canAct={canCreateDeals}
@ -383,7 +385,7 @@ export default function PipelinesPage() {
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
Add Deal {t("addDeal")}
</GatedButton> </GatedButton>
</div> </div>
</div> </div>
@ -393,10 +395,10 @@ export default function PipelinesPage() {
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20"> <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20">
<GitBranch className="h-12 w-12 text-muted-foreground" /> <GitBranch className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium text-foreground"> <h3 className="mt-4 text-lg font-medium text-foreground">
No pipelines yet {t("noPipelinesYet")}
</h3> </h3>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
Create a pipeline to start tracking deals {t("createToStartTracking")}
</p> </p>
<GatedButton <GatedButton
canAct={canEditSettings} canAct={canEditSettings}
@ -405,7 +407,7 @@ export default function PipelinesPage() {
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90" className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
Create Pipeline {t("createPipeline")}
</GatedButton> </GatedButton>
</div> </div>
) : ( ) : (
@ -425,21 +427,21 @@ export default function PipelinesPage() {
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}> <Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
<DialogContent className="sm:max-w-sm bg-popover border-border"> <DialogContent className="sm:max-w-sm bg-popover border-border">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">New Pipeline</DialogTitle> <DialogTitle className="text-popover-foreground">{t("newPipeline")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="py-2"> <div className="py-2">
<Label className="text-muted-foreground">Pipeline Name</Label> <Label className="text-muted-foreground">{t("pipelineName")}</Label>
<Input <Input
value={newPipelineName} value={newPipelineName}
onChange={(e) => setNewPipelineName(e.target.value)} onChange={(e) => setNewPipelineName(e.target.value)}
placeholder="e.g., Enterprise Sales" placeholder={t("pipelineNamePlaceholder")}
className="mt-2 bg-muted border-border text-foreground" className="mt-2 bg-muted border-border text-foreground"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleCreatePipeline(); if (e.key === "Enter") handleCreatePipeline();
}} }}
/> />
<p className="mt-2 text-xs text-muted-foreground"> <p className="mt-2 text-xs text-muted-foreground">
Default stages (New Lead → Won) will be created automatically. {t("defaultStagesDesc")}
</p> </p>
</div> </div>
<DialogFooter className="bg-popover/50 border-border"> <DialogFooter className="bg-popover/50 border-border">
@ -448,14 +450,14 @@ export default function PipelinesPage() {
onClick={() => setNewPipelineOpen(false)} onClick={() => setNewPipelineOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleCreatePipeline} onClick={handleCreatePipeline}
disabled={creating || !newPipelineName.trim()} disabled={creating || !newPipelineName.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
{creating ? "Creating..." : "Create Pipeline"} {creating ? t("creating") : t("createPipelineBtn")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -2,6 +2,7 @@
import { useMemo, type ReactNode } from 'react'; import { useMemo, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
@ -26,6 +27,7 @@ export default function SettingsPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { defaultCurrency } = useAuth(); const { defaultCurrency } = useAuth();
const { mode } = useTheme(); const { mode } = useTheme();
const t = useTranslations('Settings');
// The URL (`?tab=`) is the single source of truth for the active // The URL (`?tab=`) is the single source of truth for the active
// section — deep-linkable, and it keeps the existing links in the // section — deep-linkable, and it keeps the existing links in the
@ -67,11 +69,10 @@ export default function SettingsPage() {
<div> <div>
<div> <div>
<h1 className="text-2xl font-bold tracking-tight text-foreground"> <h1 className="text-2xl font-bold tracking-tight text-foreground">
Settings {t('pageTitle')}
</h1> </h1>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Everything in one place — your account and your workspace. Pick a {t('pageDesc')}
section to manage it.
</p> </p>
</div> </div>

View File

@ -8,6 +8,7 @@ import {
type ReactNode, type ReactNode,
} from "react" } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ArrowLeft, ArrowLeft,
@ -88,17 +89,17 @@ interface StepMeta {
} }
const STEP_META: Record<AutomationStepType, StepMeta> = { const STEP_META: Record<AutomationStepType, StepMeta> = {
send_message: { label: "Send Message", icon: MessageSquare, border: "border-l-primary" }, send_message: { label: "send_message", icon: MessageSquare, border: "border-l-primary" },
send_template: { label: "Send Template", icon: FileText, border: "border-l-primary" }, send_template: { label: "send_template", icon: FileText, border: "border-l-primary" },
add_tag: { label: "Add Tag", icon: Tag, border: "border-l-primary" }, add_tag: { label: "add_tag", icon: Tag, border: "border-l-primary" },
remove_tag: { label: "Remove Tag", icon: TagIcon, border: "border-l-primary" }, remove_tag: { label: "remove_tag", icon: TagIcon, border: "border-l-primary" },
assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" }, assign_conversation: { label: "assign_conversation", icon: UserCheck, border: "border-l-primary" },
update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-primary" }, update_contact_field: { label: "update_contact_field", icon: PencilLine, border: "border-l-primary" },
create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-primary" }, create_deal: { label: "create_deal", icon: Briefcase, border: "border-l-primary" },
wait: { label: "Wait", icon: Hourglass, border: "border-l-border" }, wait: { label: "wait", icon: Hourglass, border: "border-l-border" },
condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" }, condition: { label: "condition", icon: GitBranch, border: "border-l-amber-500" },
send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" }, send_webhook: { label: "send_webhook", icon: Webhook, border: "border-l-primary" },
close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-primary" }, close_conversation: { label: "close_conversation", icon: CircleSlash, border: "border-l-primary" },
} }
const ADDABLE_STEPS: AutomationStepType[] = [ const ADDABLE_STEPS: AutomationStepType[] = [
@ -115,18 +116,14 @@ const ADDABLE_STEPS: AutomationStepType[] = [
"close_conversation", "close_conversation",
] ]
const TRIGGER_OPTIONS: { value: AutomationTriggerType; label: string; hint: string }[] = [ const TRIGGER_OPTIONS: { value: AutomationTriggerType }[] = [
{ value: "new_message_received", label: "New Message Received", hint: "Any incoming message" }, { value: "new_message_received" },
{ { value: "first_inbound_message" },
value: "first_inbound_message", { value: "keyword_match" },
label: "First Message from Contact", { value: "new_contact_created" },
hint: "First time this contact ever messages you (works for manually-added contacts too)", { value: "conversation_assigned" },
}, { value: "tag_added" },
{ value: "keyword_match", label: "Keyword Match", hint: "Message contains specific keyword(s)" }, { value: "time_based" },
{ value: "new_contact_created", label: "New Contact Created", hint: "When a contact is auto-created from an incoming message" },
{ value: "conversation_assigned", label: "Conversation Assigned", hint: "When assigned to an agent" },
{ value: "tag_added", label: "Tag Added", hint: "When a tag is added to a contact" },
{ value: "time_based", label: "Time-Based", hint: "On a recurring schedule" },
] ]
function cid(): string { function cid(): string {
@ -286,15 +283,17 @@ const SELECT_CLASS =
function TagSelect({ function TagSelect({
value, value,
onChange, onChange,
t,
}: { }: {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { tags } = useResources() const { tags } = useResources()
if (tags.length === 0) { if (tags.length === 0) {
return ( return (
<Input <Input
placeholder="Tag id" placeholder={t("tags.placeholder")}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="bg-muted text-foreground" className="bg-muted text-foreground"
@ -314,16 +313,16 @@ function TagSelect({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select a tag…</option> <option value="">{t("tags.select")}</option>
{tags.map((t) => ( {tags.map((tg) => (
<option key={t.id} value={t.id}> <option key={tg.id} value={tg.id}>
{t.name} {tg.name}
</option> </option>
))} ))}
{/* Preserve a saved tag that's since been deleted so editing an {/* Preserve a saved tag that's since been deleted so editing an
existing automation doesn't silently drop it. */} existing automation doesn't silently drop it. */}
{value && !selected && ( {value && !selected && (
<option value={value}>{value} (unknown tag)</option> <option value={value}>{t("tags.unknown", { id: value })}</option>
)} )}
</select> </select>
</div> </div>
@ -337,9 +336,11 @@ function TagSelect({
function ContactFieldSelect({ function ContactFieldSelect({
value, value,
onChange, onChange,
t,
}: { }: {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { customFields } = useResources() const { customFields } = useResources()
const customValue = value.startsWith("custom:") ? value : "" const customValue = value.startsWith("custom:") ? value : ""
@ -351,11 +352,11 @@ function ContactFieldSelect({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="name">Name</option> <option value="name">{t("fields.name")}</option>
<option value="email">Email</option> <option value="email">{t("fields.email")}</option>
<option value="company">Company</option> <option value="company">{t("fields.company")}</option>
{customFields.length > 0 && ( {customFields.length > 0 && (
<optgroup label="Custom fields"> <optgroup label={t("fields.customFields")}>
{customFields.map((f) => ( {customFields.map((f) => (
<option key={f.id} value={`custom:${f.id}`}> <option key={f.id} value={`custom:${f.id}`}>
{f.field_name} {f.field_name}
@ -364,7 +365,7 @@ function ContactFieldSelect({
</optgroup> </optgroup>
)} )}
{customValue && !knownCustom && ( {customValue && !knownCustom && (
<option value={customValue}>{customValue} (unknown field)</option> <option value={customValue}>{t("fields.unknown", { id: customValue })}</option>
)} )}
</select> </select>
) )
@ -375,15 +376,17 @@ function ContactFieldSelect({
function AgentSelect({ function AgentSelect({
value, value,
onChange, onChange,
t,
}: { }: {
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { members } = useResources() const { members } = useResources()
if (members.length === 0) { if (members.length === 0) {
return ( return (
<Input <Input
placeholder="Agent id" placeholder={t("agents.placeholder")}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="bg-muted text-foreground" className="bg-muted text-foreground"
@ -397,14 +400,14 @@ function AgentSelect({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select an agent…</option> <option value="">{t("agents.select")}</option>
{members.map((m) => ( {members.map((m) => (
<option key={m.user_id} value={m.user_id}> <option key={m.user_id} value={m.user_id}>
{m.full_name || m.email || m.user_id} {m.full_name || m.email || m.user_id}
</option> </option>
))} ))}
{value && !selected && ( {value && !selected && (
<option value={value}>{value} (unknown agent)</option> <option value={value}>{t("agents.unknown", { id: value })}</option>
)} )}
</select> </select>
) )
@ -416,17 +419,19 @@ function DealPipelineFields({
pipelineId, pipelineId,
stageId, stageId,
onChange, onChange,
t,
}: { }: {
pipelineId: string pipelineId: string
stageId: string stageId: string
onChange: (patch: { pipeline_id: string; stage_id: string }) => void onChange: (patch: { pipeline_id: string; stage_id: string }) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { pipelines, stages } = useResources() const { pipelines, stages } = useResources()
if (pipelines.length === 0) { if (pipelines.length === 0) {
return ( return (
<> <>
<FieldBlock label="Pipeline id"> <FieldBlock label={t("pipelines.pipelineIdLabel")}>
<Input <Input
value={pipelineId} value={pipelineId}
onChange={(e) => onChange={(e) =>
@ -435,7 +440,7 @@ function DealPipelineFields({
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Stage id"> <FieldBlock label={t("pipelines.stageIdLabel")}>
<Input <Input
value={stageId} value={stageId}
onChange={(e) => onChange={(e) =>
@ -454,7 +459,7 @@ function DealPipelineFields({
return ( return (
<> <>
<FieldBlock label="Pipeline"> <FieldBlock label={t("pipelines.pipelineLabel")}>
<select <select
value={pipelineId} value={pipelineId}
onChange={(e) => { onChange={(e) => {
@ -469,18 +474,18 @@ function DealPipelineFields({
}} }}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select a pipeline…</option> <option value="">{t("pipelines.selectPipeline")}</option>
{pipelines.map((p) => ( {pipelines.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.name} {p.name}
</option> </option>
))} ))}
{pipelineId && !selectedPipeline && ( {pipelineId && !selectedPipeline && (
<option value={pipelineId}>{pipelineId} (unknown pipeline)</option> <option value={pipelineId}>{t("pipelines.unknownPipeline", { id: pipelineId })}</option>
)} )}
</select> </select>
</FieldBlock> </FieldBlock>
<FieldBlock label="Stage"> <FieldBlock label={t("pipelines.stageLabel")}>
<select <select
value={stageId} value={stageId}
onChange={(e) => onChange={(e) =>
@ -490,7 +495,7 @@ function DealPipelineFields({
disabled={!pipelineId || stageOptions.length === 0} disabled={!pipelineId || stageOptions.length === 0}
> >
<option value=""> <option value="">
{pipelineId ? "Select a stage…" : "Select a pipeline first…"} {pipelineId ? t("pipelines.selectStage") : t("pipelines.selectPipelineFirst")}
</option> </option>
{stageOptions.map((s) => ( {stageOptions.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
@ -498,7 +503,7 @@ function DealPipelineFields({
</option> </option>
))} ))}
{stageId && pipelineId && !selectedStage && ( {stageId && pipelineId && !selectedStage && (
<option value={stageId}>{stageId} (unknown stage)</option> <option value={stageId}>{t("pipelines.unknownStage", { id: stageId })}</option>
)} )}
</select> </select>
</FieldBlock> </FieldBlock>
@ -513,17 +518,19 @@ function SendTemplateFields({
templateName, templateName,
language, language,
onChange, onChange,
t,
}: { }: {
templateName: string templateName: string
language: string language: string
onChange: (patch: { template_name: string; language: string }) => void onChange: (patch: { template_name: string; language: string }) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const { templates } = useResources() const { templates } = useResources()
if (templates.length === 0) { if (templates.length === 0) {
return ( return (
<> <>
<FieldBlock label="Template name"> <FieldBlock label={t("templates.templateNameLabel")}>
<Input <Input
value={templateName} value={templateName}
onChange={(e) => onChange={(e) =>
@ -532,7 +539,7 @@ function SendTemplateFields({
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Language"> <FieldBlock label={t("templates.languageLabel")}>
<Input <Input
value={language} value={language}
onChange={(e) => onChange={(e) =>
@ -554,7 +561,7 @@ function SendTemplateFields({
) )
return ( return (
<FieldBlock label="Template"> <FieldBlock label={t("templates.templateLabel")}>
<select <select
value={current} value={current}
onChange={(e) => { onChange={(e) => {
@ -563,18 +570,18 @@ function SendTemplateFields({
}} }}
className={SELECT_CLASS} className={SELECT_CLASS}
> >
<option value="">Select a template…</option> <option value="">{t("templates.select")}</option>
{templates.map((t) => { {templates.map((tmpl) => {
const lang = t.language ?? "en_US" const lang = tmpl.language ?? "en_US"
return ( return (
<option key={t.id} value={toValue(t.name, lang)}> <option key={tmpl.id} value={toValue(tmpl.name, lang)}>
{t.name} ({lang}) {tmpl.name} ({lang})
</option> </option>
) )
})} })}
{current && !hasMatch && ( {current && !hasMatch && (
<option value={current}> <option value={current}>
{templateName} ({language || "unknown"}) — not in approved list {t("templates.unknown", { name: templateName, lang: language || t("templates.unknownLang") })}
</option> </option>
)} )}
</select> </select>
@ -588,6 +595,7 @@ function SendTemplateFields({
export function AutomationBuilder({ initial }: { initial: BuilderInitial }) { export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
const router = useRouter() const router = useRouter()
const t = useTranslations("Automations.builder")
const isEditing = !!initial.id const isEditing = !!initial.id
const [state, setState] = useState<BuilderInitial>(initial) const [state, setState] = useState<BuilderInitial>(initial)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@ -658,11 +666,11 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
description: firstIssue.path ? `at ${firstIssue.path}` : undefined, description: firstIssue.path ? `at ${firstIssue.path}` : undefined,
}) })
} else { } else {
toast.error(body?.error ?? "Save failed") toast.error(body?.error ?? t("toasts.saveFailed"))
} }
return return
} }
toast.success(isEditing ? "Automation saved" : "Automation created") toast.success(isEditing ? t("toasts.saved") : t("toasts.created"))
if (!isEditing && body?.automation?.id) { if (!isEditing && body?.automation?.id) {
router.replace(`/automations/${body.automation.id}/edit`) router.replace(`/automations/${body.automation.id}/edit`)
} }
@ -681,22 +689,22 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
type="button" type="button"
onClick={() => router.push("/automations")} onClick={() => router.push("/automations")}
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back to automations" aria-label={t("backToAutomations")}
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</button> </button>
<input <input
value={state.name} value={state.name}
onChange={(e) => patchTop("name", e.target.value)} onChange={(e) => patchTop("name", e.target.value)}
placeholder="Untitled automation" placeholder={t("untitled")}
className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-foreground placeholder:text-muted-foreground focus:bg-muted focus:outline-none sm:text-base" className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-foreground placeholder:text-muted-foreground focus:bg-muted focus:outline-none sm:text-base"
/> />
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="hidden sm:inline">Active</span> <span className="hidden sm:inline">{t("active")}</span>
<Switch <Switch
checked={state.is_active} checked={state.is_active}
onCheckedChange={(v) => patchTop("is_active", !!v)} onCheckedChange={(v) => patchTop("is_active", !!v)}
aria-label="Active" aria-label={t("activeAria")}
/> />
</div> </div>
<Button <Button
@ -705,7 +713,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null} {saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{isEditing ? "Save" : "Save Draft"} {isEditing ? t("save") : t("saveDraft")}
</Button> </Button>
</header> </header>
@ -717,8 +725,9 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
<TriggerCard <TriggerCard
type={state.trigger_type} type={state.trigger_type}
config={state.trigger_config} config={state.trigger_config}
onTypeChange={(t) => patchTop("trigger_type", t)} onTypeChange={(tVal) => patchTop("trigger_type", tVal)}
onConfigChange={(c) => patchTop("trigger_config", c)} onConfigChange={(c) => patchTop("trigger_config", c)}
t={t}
/> />
<StepList <StepList
steps={state.steps} steps={state.steps}
@ -746,11 +755,13 @@ function TriggerCard({
config, config,
onTypeChange, onTypeChange,
onConfigChange, onConfigChange,
t,
}: { }: {
type: AutomationTriggerType type: AutomationTriggerType
config: Record<string, unknown> config: Record<string, unknown>
onTypeChange: (t: AutomationTriggerType) => void onTypeChange: (t: AutomationTriggerType) => void
onConfigChange: (c: Record<string, unknown>) => void onConfigChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
return ( return (
@ -767,9 +778,9 @@ function TriggerCard({
<Zap className="h-4 w-4" /> <Zap className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[11px] uppercase tracking-wide text-blue-300">Trigger</div> <div className="text-[11px] uppercase tracking-wide text-blue-300">{t("trigger")}</div>
<div className="truncate text-sm font-medium text-foreground"> <div className="truncate text-sm font-medium text-foreground">
{TRIGGER_OPTIONS.find((o) => o.value === type)?.label ?? type} {t(`triggers.${type}.label`)}
</div> </div>
</div> </div>
<ChevronDown <ChevronDown
@ -780,7 +791,7 @@ function TriggerCard({
<div className="space-y-3 border-t border-border px-4 py-3"> <div className="space-y-3 border-t border-border px-4 py-3">
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted-foreground"> <label className="mb-1 block text-xs font-medium text-muted-foreground">
Trigger type {t("triggerType")}
</label> </label>
<select <select
value={type} value={type}
@ -789,18 +800,19 @@ function TriggerCard({
> >
{TRIGGER_OPTIONS.map((o) => ( {TRIGGER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} {t(`triggers.${o.value}.label`)}
</option> </option>
))} ))}
</select> </select>
<p className="mt-1 text-[11px] text-muted-foreground"> <p className="mt-1 text-[11px] text-muted-foreground">
{TRIGGER_OPTIONS.find((o) => o.value === type)?.hint} {t(`triggers.${type}.hint`)}
</p> </p>
</div> </div>
{type === "keyword_match" && ( {type === "keyword_match" && (
<KeywordMatchConfig <KeywordMatchConfig
config={config as unknown as KeywordMatchTriggerConfig} config={config as unknown as KeywordMatchTriggerConfig}
onChange={onConfigChange} onChange={onConfigChange}
t={t}
/> />
)} )}
{type === "tag_added" && ( {type === "tag_added" && (
@ -811,18 +823,27 @@ function TriggerCard({
<TagSelect <TagSelect
value={(config.tag_id as string) ?? ""} value={(config.tag_id as string) ?? ""}
onChange={(v) => onConfigChange({ ...config, tag_id: v })} onChange={(v) => onConfigChange({ ...config, tag_id: v })}
t={t}
/> />
</div> </div>
)} )}
{type === "time_based" && ( {type === "time_based" && (
<Input <div>
placeholder="Cron expression or HH:mm" <label className="mb-1 block text-xs font-medium text-muted-foreground">
value={(config.schedule as string) ?? ""} {t("schedule")}
onChange={(e) => </label>
onConfigChange({ ...config, schedule: e.target.value }) <Input
} placeholder="Cron expression or HH:mm"
className="bg-muted text-foreground" value={(config.schedule as string) ?? ""}
/> onChange={(e) =>
onConfigChange({ ...config, schedule: e.target.value })
}
className="bg-muted text-foreground"
/>
<p className="mt-1 text-[11px] text-muted-foreground">
{t("scheduleHint")}
</p>
</div>
)} )}
</div> </div>
)} )}
@ -834,9 +855,11 @@ function TriggerCard({
function KeywordMatchConfig({ function KeywordMatchConfig({
config, config,
onChange, onChange,
t,
}: { }: {
config: KeywordMatchTriggerConfig config: KeywordMatchTriggerConfig
onChange: (c: Record<string, unknown>) => void onChange: (c: Record<string, unknown>) => void
t: ReturnType<typeof useTranslations>
}) { }) {
const keywords = config?.keywords ?? [] const keywords = config?.keywords ?? []
// Keep a local draft string so the comma and trailing space aren't // Keep a local draft string so the comma and trailing space aren't
@ -872,7 +895,7 @@ function KeywordMatchConfig({
<div className="space-y-2"> <div className="space-y-2">
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted-foreground"> <label className="mb-1 block text-xs font-medium text-muted-foreground">
Keywords (comma-separated) {t("keywords")}
</label> </label>
<Input <Input
value={draft} value={draft}
@ -884,21 +907,21 @@ function KeywordMatchConfig({
commit() commit()
} }
}} }}
placeholder="e.g. pricing, demo request, talk to sales" placeholder={t("keywordsHint")}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs font-medium text-muted-foreground"> <label className="mb-1 block text-xs font-medium text-muted-foreground">
Match type {t("config.matchType")}
</label> </label>
<select <select
value={config?.match_type ?? "contains"} value={config?.match_type ?? "contains"}
onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })} onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:outline-none" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:outline-none"
> >
<option value="contains">Contains</option> <option value="contains">{t("config.matchContains")}</option>
<option value="exact">Exact</option> <option value="exact">{t("config.matchExact")}</option>
</select> </select>
</div> </div>
</div> </div>
@ -972,6 +995,7 @@ function StepRenderer({
parentScope: ParentScope parentScope: ParentScope
parentPath: StepPath parentPath: StepPath
} & Omit<StepListProps, "steps" | "parentPath">) { } & Omit<StepListProps, "steps" | "parentPath">) {
const t = useTranslations("Automations.builder")
const path: StepPath = [ const path: StepPath = [
...parentPath, ...parentPath,
parentScope.kind === "root" parentScope.kind === "root"
@ -1011,7 +1035,7 @@ function StepRenderer({
<div className="text-[11px] uppercase tracking-wide text-muted-foreground"> <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
{isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"} {isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}
</div> </div>
<div className="truncate text-sm font-medium text-foreground">{meta.label}</div> <div className="truncate text-sm font-medium text-foreground">{t(`steps.${meta.label}`)}</div>
<div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div> <div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div>
</div> </div>
<ChevronDown <ChevronDown
@ -1051,7 +1075,7 @@ function StepRenderer({
onClick={() => props.deleteStepAt(path)} onClick={() => props.deleteStepAt(path)}
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete {t("delete", { defaultValue: "Delete" })}
</Button> </Button>
</div> </div>
</div> </div>
@ -1083,6 +1107,7 @@ function ConditionBranches({
step: BuilderStep step: BuilderStep
parentPath: StepPath parentPath: StepPath
} & Omit<StepListProps, "steps" | "parentPath">) { } & Omit<StepListProps, "steps" | "parentPath">) {
const t = useTranslations("Automations.builder")
const yes = step.branches?.yes ?? [] const yes = step.branches?.yes ?? []
const no = step.branches?.no ?? [] const no = step.branches?.no ?? []
// Build the child scope by appending a branch marker. The scope the // Build the child scope by appending a branch marker. The scope the
@ -1101,10 +1126,10 @@ function ConditionBranches({
// cram each branch to ~170px which is too narrow for the nested // cram each branch to ~170px which is too narrow for the nested
// cards. Two-column grid returns on sm+. // cards. Two-column grid returns on sm+.
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<BranchColumn label="Yes" color="text-primary"> <BranchColumn label={t("branches.yes")} color="text-primary">
<StepList {...props} steps={yes} parentPath={yesPath} /> <StepList {...props} steps={yes} parentPath={yesPath} />
</BranchColumn> </BranchColumn>
<BranchColumn label="No" color="text-rose-400"> <BranchColumn label={t("branches.no")} color="text-rose-400">
<StepList {...props} steps={no} parentPath={noPath} /> <StepList {...props} steps={no} parentPath={noPath} />
</BranchColumn> </BranchColumn>
</div> </div>
@ -1129,13 +1154,14 @@ function BranchColumn({
} }
function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) { function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
const t = useTranslations("Automations.builder")
return ( return (
<div className="relative flex flex-col items-center"> <div className="relative flex flex-col items-center">
<div className="h-4 w-[2px] bg-border" aria-hidden /> <div className="h-4 w-[2px] bg-border" aria-hidden />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-muted-foreground transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary" className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-muted-foreground transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary"
aria-label="Add step" aria-label={t("addStep")}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -1143,12 +1169,12 @@ function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
align="start" align="start"
className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover" className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover"
> >
{ADDABLE_STEPS.map((t) => { {ADDABLE_STEPS.map((tp) => {
const Icon = STEP_META[t].icon const Icon = STEP_META[tp].icon
return ( return (
<DropdownMenuItem key={t} onClick={() => onPick(t)}> <DropdownMenuItem key={tp} onClick={() => onPick(tp)}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{STEP_META[t].label} {t(`steps.${STEP_META[tp].label}`)}
</DropdownMenuItem> </DropdownMenuItem>
) )
})} })}
@ -1170,6 +1196,7 @@ function StepEditor({
step: BuilderStep step: BuilderStep
onChange: (s: BuilderStep) => void onChange: (s: BuilderStep) => void
}) { }) {
const t = useTranslations("Automations.builder")
const cfg = step.step_config const cfg = step.step_config
const set = (patch: Record<string, unknown>) => const set = (patch: Record<string, unknown>) =>
onChange({ ...step, step_config: { ...cfg, ...patch } }) onChange({ ...step, step_config: { ...cfg, ...patch } })
@ -1177,11 +1204,11 @@ function StepEditor({
switch (step.step_type) { switch (step.step_type) {
case "send_message": case "send_message":
return ( return (
<FieldBlock label="Message text"> <FieldBlock label={t("config.messageText")}>
<Textarea <Textarea
value={(cfg.text as string) ?? ""} value={(cfg.text as string) ?? ""}
onChange={(e) => set({ text: e.target.value })} onChange={(e) => set({ text: e.target.value })}
placeholder="Hi! Thanks for reaching out…" placeholder={t("config.placeholderMessageText")}
className="min-h-24 bg-muted text-foreground" className="min-h-24 bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
@ -1192,36 +1219,39 @@ function StepEditor({
templateName={(cfg.template_name as string) ?? ""} templateName={(cfg.template_name as string) ?? ""}
language={(cfg.language as string) ?? ""} language={(cfg.language as string) ?? ""}
onChange={(patch) => set(patch)} onChange={(patch) => set(patch)}
t={t}
/> />
) )
case "add_tag": case "add_tag":
case "remove_tag": case "remove_tag":
return ( return (
<FieldBlock label="Tag"> <FieldBlock label={t("config.tagLabel")}>
<TagSelect <TagSelect
value={(cfg.tag_id as string) ?? ""} value={(cfg.tag_id as string) ?? ""}
onChange={(v) => set({ tag_id: v })} onChange={(v) => set({ tag_id: v })}
t={t}
/> />
</FieldBlock> </FieldBlock>
) )
case "assign_conversation": case "assign_conversation":
return ( return (
<> <>
<FieldBlock label="Mode"> <FieldBlock label={t("config.modeLabel")}>
<select <select
value={(cfg.mode as string) ?? "round_robin"} value={(cfg.mode as string) ?? "round_robin"}
onChange={(e) => set({ mode: e.target.value })} onChange={(e) => set({ mode: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
> >
<option value="round_robin">Round-robin</option> <option value="round_robin">{t("config.modes.round_robin")}</option>
<option value="specific">Specific agent</option> <option value="specific">{t("config.modes.specific")}</option>
</select> </select>
</FieldBlock> </FieldBlock>
{cfg.mode === "specific" && ( {cfg.mode === "specific" && (
<FieldBlock label="Agent"> <FieldBlock label={t("config.agentLabel")}>
<AgentSelect <AgentSelect
value={(cfg.agent_id as string) ?? ""} value={(cfg.agent_id as string) ?? ""}
onChange={(v) => set({ agent_id: v })} onChange={(v) => set({ agent_id: v })}
t={t}
/> />
</FieldBlock> </FieldBlock>
)} )}
@ -1230,17 +1260,18 @@ function StepEditor({
case "update_contact_field": case "update_contact_field":
return ( return (
<> <>
<FieldBlock label="Field"> <FieldBlock label={t("config.fieldLabel")}>
<ContactFieldSelect <ContactFieldSelect
value={(cfg.field as string) ?? "name"} value={(cfg.field as string) ?? "name"}
onChange={(v) => set({ field: v })} onChange={(v) => set({ field: v })}
t={t}
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Value"> <FieldBlock label={t("config.valueLabel")}>
<Input <Input
value={(cfg.value as string) ?? ""} value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })} onChange={(e) => set({ value: e.target.value })}
placeholder="Text or {{ vars.x }} / {{ message.text }}" placeholder={t("config.placeholderValue")}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
@ -1253,15 +1284,16 @@ function StepEditor({
pipelineId={(cfg.pipeline_id as string) ?? ""} pipelineId={(cfg.pipeline_id as string) ?? ""}
stageId={(cfg.stage_id as string) ?? ""} stageId={(cfg.stage_id as string) ?? ""}
onChange={(patch) => set(patch)} onChange={(patch) => set(patch)}
t={t}
/> />
<FieldBlock label="Title"> <FieldBlock label={t("config.titleLabel")}>
<Input <Input
value={(cfg.title as string) ?? ""} value={(cfg.title as string) ?? ""}
onChange={(e) => set({ title: e.target.value })} onChange={(e) => set({ title: e.target.value })}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Value"> <FieldBlock label={t("config.valueLabel")}>
<Input <Input
type="number" type="number"
value={(cfg.value as number) ?? 0} value={(cfg.value as number) ?? 0}
@ -1274,7 +1306,7 @@ function StepEditor({
case "wait": case "wait":
return ( return (
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FieldBlock label="Amount"> <FieldBlock label={t("config.amountLabel")}>
<Input <Input
type="number" type="number"
min={1} min={1}
@ -1283,15 +1315,15 @@ function StepEditor({
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Unit"> <FieldBlock label={t("config.unitLabel")}>
<select <select
value={(cfg.unit as string) ?? "hours"} value={(cfg.unit as string) ?? "hours"}
onChange={(e) => set({ unit: e.target.value })} onChange={(e) => set({ unit: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
> >
<option value="minutes">Minutes</option> <option value="minutes">{t("config.units.minutes")}</option>
<option value="hours">Hours</option> <option value="hours">{t("config.units.hours")}</option>
<option value="days">Days</option> <option value="days">{t("config.units.days")}</option>
</select> </select>
</FieldBlock> </FieldBlock>
</div> </div>
@ -1299,27 +1331,27 @@ function StepEditor({
case "condition": case "condition":
return ( return (
<> <>
<FieldBlock label="Subject"> <FieldBlock label={t("config.subjectLabel")}>
<select <select
value={(cfg.subject as string) ?? "tag_presence"} value={(cfg.subject as string) ?? "tag_presence"}
onChange={(e) => set({ subject: e.target.value })} onChange={(e) => set({ subject: e.target.value })}
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground" className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
> >
<option value="tag_presence">Tag presence</option> <option value="tag_presence">{t("config.subjects.tag_presence")}</option>
<option value="contact_field">Contact field</option> <option value="contact_field">{t("config.subjects.contact_field")}</option>
<option value="message_content">Message content</option> <option value="message_content">{t("config.subjects.message_content")}</option>
<option value="time_of_day">Time of day</option> <option value="time_of_day">{t("config.subjects.time_of_day")}</option>
</select> </select>
</FieldBlock> </FieldBlock>
<FieldBlock label="Operand"> <FieldBlock label={t("config.operandLabel")}>
<Input <Input
placeholder={ placeholder={
cfg.subject === "time_of_day" cfg.subject === "time_of_day"
? "HH:mm-HH:mm" ? t("config.placeholderTime")
: cfg.subject === "contact_field" : cfg.subject === "contact_field"
? "name / email / company" ? t("config.placeholderContact")
: cfg.subject === "tag_presence" : cfg.subject === "tag_presence"
? "tag id" ? t("config.placeholderTag")
: "" : ""
} }
value={(cfg.operand as string) ?? ""} value={(cfg.operand as string) ?? ""}
@ -1341,14 +1373,14 @@ function StepEditor({
case "send_webhook": case "send_webhook":
return ( return (
<> <>
<FieldBlock label="URL"> <FieldBlock label={t("config.urlLabel")}>
<Input <Input
value={(cfg.url as string) ?? ""} value={(cfg.url as string) ?? ""}
onChange={(e) => set({ url: e.target.value })} onChange={(e) => set({ url: e.target.value })}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Body template (JSON)"> <FieldBlock label={t("config.bodyTemplateLabel")}>
<Textarea <Textarea
value={(cfg.body_template as string) ?? ""} value={(cfg.body_template as string) ?? ""}
onChange={(e) => set({ body_template: e.target.value })} onChange={(e) => set({ body_template: e.target.value })}
@ -1360,7 +1392,7 @@ function StepEditor({
case "close_conversation": case "close_conversation":
return ( return (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Sets the conversation status to &quot;closed&quot;. No configuration needed. {t("config.closeConversationHint", { defaultValue: "Sets the conversation status to \"closed\". No configuration needed." })}
</p> </p>
) )
default: default:

View File

@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types'; import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Loader2, FileText, ArrowRight } from 'lucide-react'; import { Loader2, FileText, ArrowRight } from 'lucide-react';
import { useTranslations } from 'next-intl';
const categoryColors: Record<string, string> = { const categoryColors: Record<string, string> = {
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20', Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
@ -20,6 +21,7 @@ interface Step1Props {
} }
export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) { export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) {
const t = useTranslations('Broadcasts.wizard');
const [templates, setTemplates] = useState<MessageTemplate[]>([]); const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -40,7 +42,7 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
if (fetchError) throw fetchError; if (fetchError) throw fetchError;
setTemplates(data ?? []); setTemplates(data ?? []);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates'); setError(err instanceof Error ? err.message : t('chooseTemplate.errorLoad'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -68,17 +70,17 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Choose a Template</h2> <h2 className="text-lg font-semibold text-foreground">{t('chooseTemplate.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Select an approved message template for your broadcast. {t('chooseTemplate.subtitle')}
</p> </p>
</div> </div>
{templates.length === 0 ? ( {templates.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50"> <div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50">
<FileText className="mb-2 h-8 w-8 text-muted-foreground" /> <FileText className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No templates available.</p> <p className="text-sm text-muted-foreground">{t('chooseTemplate.noTemplates')}</p>
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p> <p className="mt-1 text-xs text-muted-foreground">{t('chooseTemplate.createFirst')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@ -119,14 +121,14 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
<div className="flex items-center justify-between border-t border-border pt-4"> <div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground"> <Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
Back {t('back')}
</Button> </Button>
<Button <Button
onClick={onNext} onClick={onNext}
disabled={!selectedTemplate} disabled={!selectedTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
Next {t('next')}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</div> </div>

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback, useMemo } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { CustomField, Tag } from '@/types'; import { CustomField, Tag } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -14,6 +14,7 @@ import {
ArrowLeft, ArrowLeft,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv'; type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
type CustomFieldOperator = 'is' | 'is_not' | 'contains'; type CustomFieldOperator = 'is' | 'is_not' | 'contains';
@ -39,50 +40,51 @@ interface Step2Props {
onBack: () => void; onBack: () => void;
} }
const audienceOptions: {
type: AudienceType;
label: string;
description: string;
icon: typeof Users;
}[] = [
{
type: 'all',
label: 'All Contacts',
description: 'Send to every contact in your database',
icon: Users,
},
{
type: 'tags',
label: 'Filter by Tags',
description: 'Target contacts with specific tags',
icon: Tags,
},
{
type: 'custom_field',
label: 'Custom Field',
description: 'Filter by a custom field value',
icon: Filter,
},
{
type: 'csv',
label: 'Upload CSV',
description: 'Upload a list of phone numbers',
icon: Upload,
},
];
const OPERATOR_OPTIONS: { value: CustomFieldOperator; label: string }[] = [
{ value: 'is', label: 'is' },
{ value: 'is_not', label: 'is not' },
{ value: 'contains', label: 'contains' },
];
export function Step2SelectAudience({ export function Step2SelectAudience({
audience, audience,
onUpdate, onUpdate,
onNext, onNext,
onBack, onBack,
}: Step2Props) { }: Step2Props) {
const t = useTranslations('Broadcasts.wizard');
const OPERATOR_OPTIONS = useMemo<{ value: CustomFieldOperator; label: string }[]>(() => [
{ value: 'is', label: t('selectAudience.operatorIs') },
{ value: 'is_not', label: t('selectAudience.operatorIsNot') },
{ value: 'contains', label: t('selectAudience.operatorContains') },
], [t]);
const audienceOptions = useMemo<{
type: AudienceType;
label: string;
description: string;
icon: typeof Users;
}[]>(() => [
{
type: 'all',
label: t('selectAudience.method.all'),
description: t('selectAudience.allDescLoading'),
icon: Users,
},
{
type: 'tags',
label: t('selectAudience.method.tags'),
description: t('selectAudience.tagDesc'),
icon: Tags,
},
{
type: 'custom_field',
label: t('selectAudience.method.customField'),
description: t('selectAudience.customFieldDesc'),
icon: Filter,
},
{
type: 'csv',
label: t('selectAudience.method.csv'),
description: t('selectAudience.csvDesc'),
icon: Upload,
},
], [t]);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [customFields, setCustomFields] = useState<CustomField[]>([]); const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingTags, setLoadingTags] = useState(false); const [loadingTags, setLoadingTags] = useState(false);
@ -249,14 +251,14 @@ export function Step2SelectAudience({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Select Audience</h2> <h2 className="text-lg font-semibold text-foreground">{t('selectAudience.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Choose who will receive this broadcast. {t('selectAudience.subtitle')}
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{audienceOptions.map((option) => { {audienceOptions.map((option: { type: AudienceType; label: string; description: string; icon: typeof Users }) => {
const isSelected = audience.type === option.type; const isSelected = audience.type === option.type;
const Icon = option.icon; const Icon = option.icon;
return ( return (
@ -305,12 +307,12 @@ export function Step2SelectAudience({
{audience.type === 'tags' && ( {audience.type === 'tags' && (
<div className="rounded-xl border border-border bg-card/50 p-4"> <div className="rounded-xl border border-border bg-card/50 p-4">
<p className="mb-3 text-sm font-medium text-foreground">Select Tags</p> <p className="mb-3 text-sm font-medium text-foreground">{t('selectAudience.selectTags')}</p>
{loadingTags ? ( {loadingTags ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
) : tags.length === 0 ? ( ) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No tags found. Create tags in Settings. {t('selectAudience.noTagsFound')}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -341,12 +343,12 @@ export function Step2SelectAudience({
{audience.type === 'custom_field' && ( {audience.type === 'custom_field' && (
<div className="space-y-3 rounded-xl border border-border bg-card/50 p-4"> <div className="space-y-3 rounded-xl border border-border bg-card/50 p-4">
<p className="text-sm font-medium text-foreground">Custom Field Filter</p> <p className="text-sm font-medium text-foreground">{t('selectAudience.method.customField')}</p>
{loadingFields ? ( {loadingFields ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
) : customFields.length === 0 ? ( ) : customFields.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No custom fields defined. Create one in Settings → Custom Fields. {t('selectAudience.errorLoadFields')}
</p> </p>
) : ( ) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_140px_minmax(0,1fr)]">
@ -355,7 +357,7 @@ export function Step2SelectAudience({
onChange={(e) => updateCustomField({ fieldId: e.target.value })} onChange={(e) => updateCustomField({ fieldId: e.target.value })}
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary" className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
> >
<option value="">Select field…</option> <option value="">{t('selectAudience.selectField')}</option>
{customFields.map((f) => ( {customFields.map((f) => (
<option key={f.id} value={f.id}> <option key={f.id} value={f.id}>
{f.field_name} {f.field_name}
@ -371,7 +373,7 @@ export function Step2SelectAudience({
} }
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary" className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
> >
{OPERATOR_OPTIONS.map((op) => ( {OPERATOR_OPTIONS.map((op: { value: CustomFieldOperator; label: string }) => (
<option key={op.value} value={op.value}> <option key={op.value} value={op.value}>
{op.label} {op.label}
</option> </option>
@ -381,7 +383,7 @@ export function Step2SelectAudience({
type="text" type="text"
value={audience.customField?.value ?? ''} value={audience.customField?.value ?? ''}
onChange={(e) => updateCustomField({ value: e.target.value })} onChange={(e) => updateCustomField({ value: e.target.value })}
placeholder="Value" placeholder={t('selectAudience.valuePlaceholder')}
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary" className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/> />
</div> </div>
@ -394,12 +396,11 @@ export function Step2SelectAudience({
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<X className="h-4 w-4 text-red-400" /> <X className="h-4 w-4 text-red-400" />
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
Exclude contacts with these tags {t('selectAudience.excludeTags')}
</p> </p>
<span className="text-xs text-muted-foreground">(optional)</span>
</div> </div>
{tags.length === 0 ? ( {tags.length === 0 ? (
<p className="text-xs text-muted-foreground">No tags available.</p> <p className="text-xs text-muted-foreground">{t('selectAudience.noTagsFound')}</p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tags.map((tag) => { {tags.map((tag) => {
@ -456,14 +457,14 @@ export function Step2SelectAudience({
className="border-border text-muted-foreground" className="border-border text-muted-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back {t('back')}
</Button> </Button>
<Button <Button
onClick={onNext} onClick={onNext}
disabled={!isValid} disabled={!isValid}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
Next {t('next')}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</div> </div>

View File

@ -13,6 +13,7 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react'; import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
type VariableType = 'static' | 'field' | 'custom_field'; type VariableType = 'static' | 'field' | 'custom_field';
@ -49,10 +50,9 @@ function isValidHttpUrl(value: string): boolean {
} }
const contactFields = [ const contactFields = [
{ value: 'name', label: 'Contact Name' }, { value: 'name', labelKey: 'name' },
{ value: 'phone', label: 'Phone Number' }, { value: 'phone', labelKey: 'phone' },
{ value: 'email', label: 'Email Address' }, { value: 'email', labelKey: 'email' },
{ value: 'company', label: 'Company' },
]; ];
const SAMPLE_CONTACT: Contact = { const SAMPLE_CONTACT: Contact = {
@ -76,6 +76,7 @@ export function Step3Personalize({
onNext, onNext,
onBack, onBack,
}: Step3Props) { }: Step3Props) {
const t = useTranslations('Broadcasts.wizard');
const [customFields, setCustomFields] = useState<CustomField[]>([]); const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingFields, setLoadingFields] = useState(true); const [loadingFields, setLoadingFields] = useState(true);
const [firstContact, setFirstContact] = useState<Contact | null>(null); const [firstContact, setFirstContact] = useState<Contact | null>(null);
@ -230,15 +231,14 @@ export function Step3Personalize({
const previewLabel = firstContact const previewLabel = firstContact
? firstContact.name || firstContact.phone ? firstContact.name || firstContact.phone
: 'sample data'; : t('personalize.previewSample');
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Personalize Message</h2> <h2 className="text-lg font-semibold text-foreground">{t('personalize.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Map template variables to contact fields, custom fields, or static {t('personalize.subtitle')}
values.
</p> </p>
</div> </div>
@ -246,30 +246,23 @@ export function Step3Personalize({
<div className="rounded-xl border border-border bg-card/50 p-4"> <div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-primary" /> <ImageIcon className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Header media</p> <p className="text-sm font-medium text-foreground">{t('personalize.headerImage')}</p>
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary"> <span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
{mediaHeaderType} {mediaHeaderType}
</span> </span>
</div> </div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground"> <label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Media URL {t('personalize.imageUrl')}
</label> </label>
<Input <Input
type="url" type="url"
value={headerMediaUrl} value={headerMediaUrl}
onChange={(e) => onHeaderMediaUrlChange(e.target.value)} onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
placeholder={`https://example.com/header.${ placeholder={t('personalize.imageUrlPlaceholder')}
mediaHeaderType === 'image'
? 'jpg'
: mediaHeaderType === 'video'
? 'mp4'
: 'pdf'
}`}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground" className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/> />
<p className="mt-1.5 text-xs text-muted-foreground"> <p className="mt-1.5 text-xs text-muted-foreground">
Public URL of the {mediaHeaderType} sent as the message header. {t('personalize.headerImageDesc')}
Used for every recipient in this broadcast.
</p> </p>
{mediaHeaderType === 'image' && {mediaHeaderType === 'image' &&
headerMediaError === null && headerMediaError === null &&
@ -294,7 +287,7 @@ export function Step3Personalize({
{placeholders.length === 0 && !mediaHeaderType ? ( {placeholders.length === 0 && !mediaHeaderType ? (
<div className="rounded-xl border border-border bg-card/50 p-6 text-center"> <div className="rounded-xl border border-border bg-card/50 p-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
This template has no variables to personalize. {t('personalize.noPreview')}
</p> </p>
</div> </div>
) : placeholders.length === 0 ? null : ( ) : placeholders.length === 0 ? null : (
@ -317,7 +310,7 @@ export function Step3Personalize({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground"> <label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Mapping Type {t('personalize.type')}
</label> </label>
<Select <Select
value={mapping.type} value={mapping.type}
@ -332,10 +325,10 @@ export function Step3Personalize({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-border bg-popover"> <SelectContent className="border-border bg-popover">
<SelectItem value="static">Static Value</SelectItem> <SelectItem value="static">{t('personalize.typeStatic')}</SelectItem>
<SelectItem value="field">Contact Field</SelectItem> <SelectItem value="field">{t('personalize.typeContact')}</SelectItem>
<SelectItem value="custom_field"> <SelectItem value="custom_field">
Custom Field {t('personalize.typeCustom')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -343,7 +336,7 @@ export function Step3Personalize({
<div> <div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground"> <label className="mb-1.5 block text-xs font-medium text-muted-foreground">
{mapping.type === 'static' ? 'Value' : 'Field'} {mapping.type === 'static' ? t('personalize.staticValue') : t('personalize.contactField')}
</label> </label>
{mapping.type === 'static' ? ( {mapping.type === 'static' ? (
<Input <Input
@ -362,12 +355,12 @@ export function Step3Personalize({
} }
> >
<SelectTrigger className="w-full border-border bg-muted text-foreground"> <SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue placeholder="Select field..." /> <SelectValue placeholder={t('personalize.selectContactField')} />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-border bg-popover"> <SelectContent className="border-border bg-popover">
{contactFields.map((field) => ( {contactFields.map((field) => (
<SelectItem key={field.value} value={field.value}> <SelectItem key={field.value} value={field.value}>
{field.label} {t(`personalize.fieldMap.${field.labelKey}`)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -412,7 +405,7 @@ export function Step3Personalize({
<div className="rounded-xl border border-border bg-card/50 p-4"> <div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2"> <div className="mb-3 flex items-center gap-2">
<Eye className="h-4 w-4 text-primary" /> <Eye className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Live Preview</p> <p className="text-sm font-medium text-foreground">{t('personalize.preview')}</p>
<span className="text-xs text-muted-foreground">({previewLabel})</span> <span className="text-xs text-muted-foreground">({previewLabel})</span>
{loadingPreview && ( {loadingPreview && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
@ -444,14 +437,14 @@ export function Step3Personalize({
className="border-border text-muted-foreground" className="border-border text-muted-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back {t('back')}
</Button> </Button>
<Button <Button
onClick={onNext} onClick={onNext}
disabled={unmappedKeys.length > 0 || headerMediaError !== null} disabled={unmappedKeys.length > 0 || headerMediaError !== null}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
Next {t('next')}
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</div> </div>

View File

@ -15,6 +15,7 @@ import {
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react'; import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface AudienceConfig { interface AudienceConfig {
type: string; type: string;
@ -45,6 +46,7 @@ export function Step4ScheduleSend({
isProcessing, isProcessing,
progress, progress,
}: Step4Props) { }: Step4Props) {
const t = useTranslations('Broadcasts.wizard');
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [estimatedReach, setEstimatedReach] = useState<number>(0); const [estimatedReach, setEstimatedReach] = useState<number>(0);
const [loadingReach, setLoadingReach] = useState(true); const [loadingReach, setLoadingReach] = useState(true);
@ -83,43 +85,43 @@ export function Step4ScheduleSend({
const audienceLabel = const audienceLabel =
audience.type === 'all' audience.type === 'all'
? 'All Contacts' ? t('scheduleSend.audienceAll')
: audience.type === 'tags' : audience.type === 'tags'
? `Tags (${audience.tagIds?.length ?? 0} selected)` ? t('scheduleSend.audienceTags')
: audience.type === 'csv' : audience.type === 'csv'
? 'CSV Upload' ? t('scheduleSend.audienceCsv')
: 'Custom'; : t('scheduleSend.audienceField');
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-lg font-semibold text-foreground">Review & Send</h2> <h2 className="text-lg font-semibold text-foreground">{t('scheduleSend.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
Name your broadcast, review the details, and send. {t('scheduleSend.subtitle')}
</p> </p>
</div> </div>
{/* Broadcast Name */} {/* Broadcast Name */}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-foreground">Broadcast Name</label> <label className="mb-1.5 block text-sm font-medium text-foreground">{t('scheduleSend.broadcastName')}</label>
<Input <Input
value={name} value={name}
onChange={(e) => onNameChange(e.target.value)} onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g. Summer Sale Announcement" placeholder={t('scheduleSend.broadcastNamePlaceholder')}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground" className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
{/* Summary Card */} {/* Summary Card */}
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3"> <div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
<p className="text-sm font-medium text-foreground">Summary</p> <p className="text-sm font-medium text-foreground">{t('scheduleSend.summary')}</p>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div> <div>
<p className="text-xs text-muted-foreground">Template</p> <p className="text-xs text-muted-foreground">{t('scheduleSend.template')}</p>
<p className="text-foreground">{template.name}</p> <p className="text-foreground">{template.name}</p>
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground">Audience</p> <p className="text-xs text-muted-foreground">{t('scheduleSend.audience')}</p>
<p className="text-foreground">{audienceLabel}</p> <p className="text-foreground">{audienceLabel}</p>
</div> </div>
<div> <div>
@ -148,7 +150,7 @@ export function Step4ScheduleSend({
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> <Loader2 className="h-4 w-4 animate-spin text-primary" />
<p className="text-sm font-medium text-foreground">Sending broadcast...</p> <p className="text-sm font-medium text-foreground">{t('scheduleSend.sending')}</p>
</div> </div>
<span className="text-xs font-medium text-primary">{progress}%</span> <span className="text-xs font-medium text-primary">{progress}%</span>
</div> </div>
@ -169,7 +171,7 @@ export function Step4ScheduleSend({
className="border-border text-muted-foreground" className="border-border text-muted-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back {t('back')}
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -181,7 +183,7 @@ export function Step4ScheduleSend({
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50" className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
> >
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Save as Draft {t('scheduleSend.saveDraft')}
</Button> </Button>
)} )}
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
} }
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
Send Broadcast {t('scheduleSend.sendNow')}
</DialogTrigger> </DialogTrigger>
<DialogContent className="border-border bg-popover sm:max-w-md"> <DialogContent className="border-border bg-popover sm:max-w-md">
<DialogHeader> <DialogHeader>
@ -214,7 +216,7 @@ export function Step4ScheduleSend({
onClick={() => setShowConfirm(false)} onClick={() => setShowConfirm(false)}
className="border-border text-muted-foreground" className="border-border text-muted-foreground"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={() => { onClick={() => {
@ -224,7 +226,7 @@ export function Step4ScheduleSend({
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
Confirm & Send {t('scheduleSend.sendNow')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -39,6 +39,7 @@ import {
DollarSign, DollarSign,
LayoutTemplate, LayoutTemplate,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ContactDetailViewProps { interface ContactDetailViewProps {
open: boolean; open: boolean;
@ -53,6 +54,7 @@ export function ContactDetailView({
contactId, contactId,
onUpdated, onUpdated,
}: ContactDetailViewProps) { }: ContactDetailViewProps) {
const t = useTranslations('Contacts.detailView');
const supabase = createClient(); const supabase = createClient();
const { accountId, defaultCurrency } = useAuth(); const { accountId, defaultCurrency } = useAuth();
@ -196,7 +198,7 @@ export function ContactDetailView({
async function saveDetails() { async function saveDetails() {
if (!contactId || !editPhone.trim()) { if (!contactId || !editPhone.trim()) {
toast.error('Phone number is required'); toast.error(t('toastPhoneRequired'));
return; return;
} }
@ -213,9 +215,9 @@ export function ContactDetailView({
.eq('id', contactId); .eq('id', contactId);
if (error) { if (error) {
toast.error('Failed to update contact'); toast.error(t('toastUpdateFailed'));
} else { } else {
toast.success('Contact updated'); toast.success(t('toastUpdated'));
fetchContact(); fetchContact();
onUpdated(); onUpdated();
} }
@ -259,7 +261,7 @@ export function ContactDetailView({
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user || !accountId) { if (!user || !accountId) {
toast.error('Not authenticated'); toast.error(t('toastNotAuthenticated'));
setSavingNote(false); setSavingNote(false);
return; return;
} }
@ -272,11 +274,11 @@ export function ContactDetailView({
}); });
if (error) { if (error) {
toast.error('Failed to add note'); toast.error(t('toastNoteAddFailed'));
} else { } else {
setNewNote(''); setNewNote('');
fetchNotes(); fetchNotes();
toast.success('Note added'); toast.success(t('toastNoteAdded'));
} }
setSavingNote(false); setSavingNote(false);
} }
@ -288,10 +290,10 @@ export function ContactDetailView({
.eq('id', noteId); .eq('id', noteId);
if (error) { if (error) {
toast.error('Failed to delete note'); toast.error(t('toastNoteDeleteFailed'));
} else { } else {
setNotes((prev) => prev.filter((n) => n.id !== noteId)); setNotes((prev) => prev.filter((n) => n.id !== noteId));
toast.success('Note deleted'); toast.success(t('toastNoteDeleted'));
} }
} }
@ -321,9 +323,9 @@ export function ContactDetailView({
if (error) throw error; if (error) throw error;
} }
toast.success('Custom fields saved'); toast.success(t('toastCustomFieldsSaved'));
} catch { } catch {
toast.error('Failed to save custom fields'); toast.error(t('toastCustomFieldsFailed'));
} }
setSavingCustom(false); setSavingCustom(false);
} }
@ -357,11 +359,11 @@ export function ContactDetailView({
const payload = await res.json().catch(() => ({})); const payload = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const reason = payload?.error || `HTTP ${res.status}`; const reason = payload?.error || `HTTP ${res.status}`;
toast.error(`Failed to send template: ${reason}`); toast.error(t('toastTemplateFailed', { reason }));
return; return;
} }
toast.success(`Template "${template.name}" sent`); toast.success(t('toastTemplateSent', { name: template.name }));
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : 'network error'; const reason = err instanceof Error ? err.message : 'network error';
toast.error(`Failed to send template: ${reason}`); toast.error(`Failed to send template: ${reason}`);
@ -403,10 +405,10 @@ export function ContactDetailView({
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SheetTitle className="text-popover-foreground truncate"> <SheetTitle className="text-popover-foreground truncate">
{contact.name || 'Unknown'} {contact.name || t('unnamed')}
</SheetTitle> </SheetTitle>
<SheetDescription className="text-muted-foreground text-xs mt-0.5"> <SheetDescription className="text-muted-foreground text-xs mt-0.5">
Contact details {t('contactDetailsDesc')}
</SheetDescription> </SheetDescription>
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<button <button
@ -448,7 +450,7 @@ export function ContactDetailView({
) : ( ) : (
<LayoutTemplate className="size-4" /> <LayoutTemplate className="size-4" />
)} )}
Send template {t('sendTemplateBtn')}
</Button> </Button>
</div> </div>
</SheetHeader> </SheetHeader>
@ -460,31 +462,31 @@ export function ContactDetailView({
value="details" value="details"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Details {t('tabs.details')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="tags" value="tags"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Tags {t('tabs.tags', { fallback: 'Tags' })}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="notes" value="notes"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Notes {t('tabs.notes')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="custom" value="custom"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Custom Fields {t('tabs.custom')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="deals" value="deals"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Deals {t('tabs.deals')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -492,7 +494,7 @@ export function ContactDetailView({
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3"> <TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Name</Label> <Label className="text-muted-foreground text-xs">{t('company', { fallback: 'Name' })}</Label>
<Input <Input
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
@ -501,7 +503,7 @@ export function ContactDetailView({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"> <Label className="text-muted-foreground text-xs">
Phone <span className="text-red-400">*</span> {t('phone')} <span className="text-red-400">*</span>
</Label> </Label>
<Input <Input
value={editPhone} value={editPhone}
@ -510,7 +512,7 @@ export function ContactDetailView({
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Email</Label> <Label className="text-muted-foreground text-xs">{t('email')}</Label>
<Input <Input
value={editEmail} value={editEmail}
onChange={(e) => setEditEmail(e.target.value)} onChange={(e) => setEditEmail(e.target.value)}
@ -518,7 +520,7 @@ export function ContactDetailView({
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Company</Label> <Label className="text-muted-foreground text-xs">{t('company')}</Label>
<Input <Input
value={editCompany} value={editCompany}
onChange={(e) => setEditCompany(e.target.value)} onChange={(e) => setEditCompany(e.target.value)}
@ -536,7 +538,7 @@ export function ContactDetailView({
) : ( ) : (
<Save className="size-3.5" /> <Save className="size-3.5" />
)} )}
Save Changes {t('saveChangesBtn')}
</Button> </Button>
</div> </div>
</TabsContent> </TabsContent>
@ -545,11 +547,11 @@ export function ContactDetailView({
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3"> <TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Click a tag to add or remove it from this contact. {t('tagsTab.clickTagDesc')}
</p> </p>
{allTags.length === 0 ? ( {allTags.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tags available. Create tags in Settings. {t('tagsTab.noTagsAvailable')}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -586,7 +588,7 @@ export function ContactDetailView({
<Textarea <Textarea
value={newNote} value={newNote}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewNote(e.target.value)}
placeholder="Write a note..." placeholder={t('notesTab.placeholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none" className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none"
/> />
<Button <Button
@ -600,7 +602,7 @@ export function ContactDetailView({
) : ( ) : (
<Plus className="size-3.5" /> <Plus className="size-3.5" />
)} )}
Add Note {t('notesTab.save')}
</Button> </Button>
</div> </div>
@ -611,7 +613,7 @@ export function ContactDetailView({
</div> </div>
) : notes.length === 0 ? ( ) : notes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No notes yet. {t('notesTab.noNotes')}
</p> </p>
) : ( ) : (
notes.map((note) => ( notes.map((note) => (
@ -653,7 +655,7 @@ export function ContactDetailView({
</div> </div>
) : customFields.length === 0 ? ( ) : customFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No custom fields defined. Create them in Settings. {t('noCustomFields')}
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -670,7 +672,7 @@ export function ContactDetailView({
[field.id]: e.target.value, [field.id]: e.target.value,
})) }))
} }
placeholder={`Enter ${field.field_name}...`} placeholder={t('enterCustomField', { name: field.field_name })}
className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground" className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground"
/> />
</div> </div>
@ -686,7 +688,7 @@ export function ContactDetailView({
) : ( ) : (
<Save className="size-3.5" /> <Save className="size-3.5" />
)} )}
Save Custom Fields {t('saveCustomFieldsBtn')}
</Button> </Button>
</div> </div>
)} )}
@ -699,7 +701,7 @@ export function ContactDetailView({
<Loader2 className="size-5 animate-spin text-primary" /> <Loader2 className="size-5 animate-spin text-primary" />
</div> </div>
) : deals.length === 0 ? ( ) : deals.length === 0 ? (
<p className="text-xs text-muted-foreground">No deals yet</p> <p className="text-xs text-muted-foreground">{t('dealsTab.noDeals')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{deals.map((deal) => ( {deals.map((deal) => (

View File

@ -24,6 +24,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2, AlertTriangle } from 'lucide-react'; import { Loader2, AlertTriangle } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ContactFormProps { interface ContactFormProps {
open: boolean; open: boolean;
@ -44,6 +45,7 @@ export function ContactForm({
onSaved, onSaved,
onViewExisting, onViewExisting,
}: ContactFormProps) { }: ContactFormProps) {
const t = useTranslations('Contacts.form');
const supabase = createClient(); const supabase = createClient();
const { accountId } = useAuth(); const { accountId } = useAuth();
const isEdit = !!contact; const isEdit = !!contact;
@ -123,14 +125,14 @@ export function ContactForm({
e.preventDefault(); e.preventDefault();
if (!phone.trim()) { if (!phone.trim()) {
toast.error('Phone number is required'); toast.error(t('phoneRequired'));
return; return;
} }
// Hard-block an exact duplicate on create (the DB unique index is // Hard-block an exact duplicate on create (the DB unique index is
// the real backstop; this avoids a round-trip + a raw error toast). // the real backstop; this avoids a round-trip + a raw error toast).
if (!isEdit && dupMatch?.exact) { if (!isEdit && dupMatch?.exact) {
toast.error('A contact with this phone number already exists'); toast.error(t('toastConflict'));
return; return;
} }
@ -194,7 +196,7 @@ export function ContactForm({
} }
} }
toast.success(isEdit ? 'Contact updated' : 'Contact created'); toast.success(isEdit ? t('toastSuccessEdit') : t('toastSuccessAdd'));
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
} catch (err: unknown) { } catch (err: unknown) {
@ -203,7 +205,7 @@ export function ContactForm({
// normalizes equal). Surface it as the friendly duplicate notice // normalizes equal). Surface it as the friendly duplicate notice
// and, for new contacts, point the user at the existing record. // and, for new contacts, point the user at the existing record.
if (isUniqueViolation(err)) { if (isUniqueViolation(err)) {
toast.error('A contact with this phone number already exists'); toast.error(t('toastConflict'));
if (!isEdit && accountId) { if (!isEdit && accountId) {
const existing = await findExistingContact( const existing = await findExistingContact(
supabase, supabase,
@ -214,7 +216,7 @@ export function ContactForm({
} }
return; return;
} }
const message = err instanceof Error ? err.message : 'Failed to save contact'; const message = err instanceof Error ? err.message : t('toastError');
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@ -226,32 +228,32 @@ export function ContactForm({
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md"> <DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
{isEdit ? 'Edit Contact' : 'Add Contact'} {isEdit ? t('editTitle') : t('addTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
{isEdit {isEdit
? 'Update the contact details below.' ? t('editDesc')
: 'Fill in the details to create a new contact.'} : t('addDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-name" className="text-muted-foreground"> <Label htmlFor="cf-name" className="text-muted-foreground">
Name {t('nameLabel')}
</Label> </Label>
<Input <Input
id="cf-name" id="cf-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="John Doe" placeholder={t('namePlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-phone" className="text-muted-foreground"> <Label htmlFor="cf-phone" className="text-muted-foreground">
Phone <span className="text-red-400">*</span> {t('phoneLabel')} <span className="text-red-400">*</span>
</Label> </Label>
<Input <Input
id="cf-phone" id="cf-phone"
@ -261,7 +263,7 @@ export function ContactForm({
if (dupMatch) setDupMatch(null); if (dupMatch) setDupMatch(null);
}} }}
onBlur={checkDuplicate} onBlur={checkDuplicate}
placeholder="+1 234 567 8900" placeholder={t('phonePlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
{dupMatch ? ( {dupMatch ? (
@ -276,8 +278,8 @@ export function ContactForm({
<div className="space-y-1"> <div className="space-y-1">
<p> <p>
{dupMatch.exact {dupMatch.exact
? 'A contact with this phone number already exists.' ? t('dupExact')
: 'A contact with a very similar number already exists.'} : t('dupSimilar')}
</p> </p>
{onViewExisting && ( {onViewExisting && (
<button <button
@ -285,55 +287,55 @@ export function ContactForm({
onClick={() => onViewExisting(dupMatch.contact.id)} onClick={() => onViewExisting(dupMatch.contact.id)}
className="font-medium underline underline-offset-2 hover:no-underline" className="font-medium underline underline-offset-2 hover:no-underline"
> >
View {dupMatch.contact.name || dupMatch.contact.phone} {t('viewExisting', { name: dupMatch.contact.name || dupMatch.contact.phone })}
</button> </button>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Include country code, e.g. +1 for US {t('phoneHint')}
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-email" className="text-muted-foreground"> <Label htmlFor="cf-email" className="text-muted-foreground">
Email {t('emailLabel')}
</Label> </Label>
<Input <Input
id="cf-email" id="cf-email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com" placeholder={t('emailPlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-company" className="text-muted-foreground"> <Label htmlFor="cf-company" className="text-muted-foreground">
Company {t('companyLabel')}
</Label> </Label>
<Input <Input
id="cf-company" id="cf-company"
value={company} value={company}
onChange={(e) => setCompany(e.target.value)} onChange={(e) => setCompany(e.target.value)}
placeholder="Acme Inc." placeholder={t('companyPlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Tags</Label> <Label className="text-muted-foreground">{t('tagsLabel')}</Label>
{loadingTags ? ( {loadingTags ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm"> <div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
Loading tags... {t('loadingTags')}
</div> </div>
) : tags.length === 0 ? ( ) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No tags available. Create tags in Settings. {t('noTagsAvailable')}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -370,7 +372,7 @@ export function ContactForm({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@ -378,7 +380,7 @@ export function ContactForm({
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
{saving && <Loader2 className="size-4 animate-spin" />} {saving && <Loader2 className="size-4 animate-spin" />}
{isEdit ? 'Update' : 'Create'} {isEdit ? t('update') : t('create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@ -15,6 +15,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Loader2, Plus, Trash2 } from 'lucide-react'; import { Loader2, Plus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface CustomFieldsManagerProps { interface CustomFieldsManagerProps {
open: boolean; open: boolean;
@ -31,15 +32,14 @@ export function CustomFieldsManager({
open, open,
onOpenChange, onOpenChange,
}: CustomFieldsManagerProps) { }: CustomFieldsManagerProps) {
const t = useTranslations('Contacts.customFields');
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md"> <DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Custom fields</DialogTitle> <DialogTitle className="text-popover-foreground">{t('title')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Define extra contact fields (e.g. ZIP code, lead source). They {t('desc')}
appear on every contact and in the “Update Contact Field” automation
action.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<CustomFieldsPanel /> <CustomFieldsPanel />
@ -55,6 +55,7 @@ export function CustomFieldsManager({
* `custom_fields` RLS also rejects non-admin writes as defense in depth. * `custom_fields` RLS also rejects non-admin writes as defense in depth.
*/ */
export function CustomFieldsPanel() { export function CustomFieldsPanel() {
const t = useTranslations('Contacts.customFields');
const supabase = createClient(); const supabase = createClient();
const { user, accountId } = useAuth(); const { user, accountId } = useAuth();
@ -97,11 +98,11 @@ export function CustomFieldsPanel() {
const name = newName.trim(); const name = newName.trim();
if (!name) return; if (!name) return;
if (!accountId || !user) { if (!accountId || !user) {
toast.error('Your profile is not linked to an account.'); toast.error(t('toastNoAccount'));
return; return;
} }
if (isDuplicate(name)) { if (isDuplicate(name)) {
toast.error(`A field named "${name}" already exists.`); toast.error(t('toastDuplicate', { name }));
return; return;
} }
@ -115,10 +116,10 @@ export function CustomFieldsPanel() {
setCreating(false); setCreating(false);
if (error) { if (error) {
toast.error('Could not create field. You may not have permission.'); toast.error(t('toastCreateFailed'));
return; return;
} }
toast.success(`Created "${name}".`); toast.success(t('toastCreated', { name }));
setNewName(''); setNewName('');
await fetchFields(); await fetchFields();
} }
@ -132,7 +133,7 @@ export function CustomFieldsPanel() {
const name = nextName.trim(); const name = nextName.trim();
if (!name || name === field.field_name) return true; if (!name || name === field.field_name) return true;
if (isDuplicate(name, field.id)) { if (isDuplicate(name, field.id)) {
toast.error(`A field named "${name}" already exists.`); toast.error(t('toastDuplicate', { name }));
return false; return false;
} }
setBusyId(field.id); setBusyId(field.id);
@ -142,7 +143,7 @@ export function CustomFieldsPanel() {
.eq('id', field.id); .eq('id', field.id);
setBusyId(null); setBusyId(null);
if (error) { if (error) {
toast.error('Could not rename field.'); toast.error(t('toastRenameFailed'));
return false; return false;
} }
await fetchFields(); await fetchFields();
@ -152,7 +153,7 @@ export function CustomFieldsPanel() {
async function handleDelete(field: CustomField) { async function handleDelete(field: CustomField) {
if ( if (
!window.confirm( !window.confirm(
`Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.` t('deleteConfirm', { name: field.field_name })
) )
) { ) {
return; return;
@ -164,10 +165,10 @@ export function CustomFieldsPanel() {
.eq('id', field.id); .eq('id', field.id);
setBusyId(null); setBusyId(null);
if (error) { if (error) {
toast.error('Could not delete field.'); toast.error(t('toastDeleteFailed'));
return; return;
} }
toast.success(`Deleted "${field.field_name}".`); toast.success(t('toastDeleted', { name: field.field_name }));
await fetchFields(); await fetchFields();
} }
@ -184,7 +185,7 @@ export function CustomFieldsPanel() {
void handleCreate(); void handleCreate();
} }
}} }}
placeholder="New field name…" placeholder={t('fieldName')}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
<Button <Button
@ -197,7 +198,7 @@ export function CustomFieldsPanel() {
) : ( ) : (
<Plus className="size-4" /> <Plus className="size-4" />
)} )}
Add {t('addField')}
</Button> </Button>
</div> </div>
@ -206,11 +207,11 @@ export function CustomFieldsPanel() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Loading… {t('loading')}
</div> </div>
) : fields.length === 0 ? ( ) : fields.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">
No custom fields yet. {t('empty')}
</p> </p>
) : ( ) : (
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
@ -243,6 +244,7 @@ function FieldRow({
onRename: (field: CustomField, name: string) => Promise<boolean>; onRename: (field: CustomField, name: string) => Promise<boolean>;
onDelete: (field: CustomField) => void; onDelete: (field: CustomField) => void;
}) { }) {
const t = useTranslations('Contacts.customFields');
const [name, setName] = useState(field.field_name); const [name, setName] = useState(field.field_name);
async function commit() { async function commit() {
@ -264,7 +266,7 @@ function FieldRow({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Enter') e.currentTarget.blur();
}} }}
aria-label={`Rename ${field.field_name}`} aria-label={t('renameAria', { name: field.field_name })}
className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border" className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border"
/> />
<Button <Button
@ -272,7 +274,7 @@ function FieldRow({
size="icon-sm" size="icon-sm"
disabled={busy} disabled={busy}
onClick={() => onDelete(field)} onClick={() => onDelete(field)}
title="Delete field" title={t('deleteTitle')}
className="shrink-0 text-muted-foreground hover:text-red-400" className="shrink-0 text-muted-foreground hover:text-red-400"
> >
{busy ? ( {busy ? (

View File

@ -37,6 +37,7 @@ import {
AlertTriangle, AlertTriangle,
Tag, Tag,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
const DEFAULT_TAG_COLOR = '#3b82f6'; const DEFAULT_TAG_COLOR = '#3b82f6';
const PREVIEW_LIMIT = 5; const PREVIEW_LIMIT = 5;
@ -79,6 +80,8 @@ function ImportPreviewTags({
tagNames: string[]; tagNames: string[];
tagColorByKey: Map<string, string>; tagColorByKey: Map<string, string>;
}) { }) {
const t = useTranslations('Contacts.importModal');
if (tagNames.length === 0) { if (tagNames.length === 0) {
return <span className="text-muted-foreground">—</span>; return <span className="text-muted-foreground">—</span>;
} }
@ -98,7 +101,7 @@ function ImportPreviewTags({
color, color,
border: `1px solid ${color}${isKnown ? '55' : '30'}`, border: `1px solid ${color}${isKnown ? '55' : '30'}`,
}} }}
title={isKnown ? name : `${name} (will be created on import)`} title={isKnown ? name : t('willBeCreated', { name })}
> >
<span <span
className="size-1.5 shrink-0 rounded-full" className="size-1.5 shrink-0 rounded-full"
@ -123,6 +126,7 @@ export function ImportModal({
onOpenChange, onOpenChange,
onImported, onImported,
}: ImportModalProps) { }: ImportModalProps) {
const t = useTranslations('Contacts.importModal');
const supabase = createClient(); const supabase = createClient();
const { accountId, canEditSettings } = useAuth(); const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -172,9 +176,7 @@ export function ImportModal({
} = parseContactCsv(text); } = parseContactCsv(text);
if (rows.length === 0) { if (rows.length === 0) {
toast.error( toast.error(t('toastNoValidRows'));
'No valid rows found. Ensure CSV has a "phone" column header.'
);
setParsedRows([]); setParsedRows([]);
setHasTagsColumn(false); setHasTagsColumn(false);
setHasCompanyColumn(false); setHasCompanyColumn(false);
@ -336,39 +338,31 @@ export function ImportModal({
tagIdByKey tagIdByKey
); );
} catch { } catch {
toast.warning('Contacts imported, but some tag assignments failed.'); toast.warning(t('toastTagsWarning'));
} }
setResult({ imported, skipped, failed, tagsAssigned }); setResult({ imported, skipped, failed, tagsAssigned });
if (imported > 0) { if (imported > 0) {
toast.success( toast.success(t('toastImported', { count: imported }));
`${imported} contact${imported !== 1 ? 's' : ''} imported`
);
onImported(); onImported();
} }
if (tagsAssigned > 0) { if (tagsAssigned > 0) {
toast.success( toast.success(t('toastTagsAssigned', { count: tagsAssigned }));
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
);
} }
if (skippedNames.length > 0) { if (skippedNames.length > 0) {
const sample = skippedNames.slice(0, 3).join(', '); const sample = skippedNames.slice(0, 3).join(', ');
const more = const more =
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : ''; skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
toast.info( toast.info(t('toastTagsSkipped', { sample, more }));
`Unknown tags skipped (create them in Settings first): ${sample}${more}`
);
} }
if (skipped > 0) { if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); toast.info(t('toastSkipped', { count: skipped }));
} }
if (failed > 0) { if (failed > 0) {
toast.error( toast.error(t('toastFailed', { count: failed }));
`${failed} contact${failed !== 1 ? 's' : ''} failed to import`
);
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed'; const message = err instanceof Error ? err.message : t('toastError');
toast.error(message); toast.error(message);
} finally { } finally {
setImporting(false); setImporting(false);
@ -402,31 +396,19 @@ export function ImportModal({
<div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5"> <div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5">
<DialogHeader className="gap-1.5"> <DialogHeader className="gap-1.5">
<DialogTitle className="text-lg text-popover-foreground"> <DialogTitle className="text-lg text-popover-foreground">
Import Contacts {t('title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="leading-relaxed text-muted-foreground"> <DialogDescription className="leading-relaxed text-muted-foreground"
Upload a CSV with a required{' '} dangerouslySetInnerHTML={{
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground"> __html: t.markup('desc', {
phone phoneCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
</code>{' '} nameCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
column. Optional:{' '} emailCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground"> companyCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
name tagsCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
</code> })
,{' '} }}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground"> />
email
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
company
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
tags
</code>{' '}
(comma-separated; quote multi-tag cells).
</DialogDescription>
</DialogHeader> </DialogHeader>
<div <div
@ -456,8 +438,7 @@ export function ImportModal({
{truncateFilename(file.name)} {truncateFilename(file.name)}
</p> </p>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"> <span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '} {t('rowsReady', { count: parsedRows.length })}
ready
</span> </span>
</> </>
) : ( ) : (
@ -466,10 +447,10 @@ export function ImportModal({
<Upload className="size-5 text-muted-foreground group-hover:text-foreground" /> <Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Click to choose a CSV file {t('uploadDropzone')}
</p> </p>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
.csv up to your browser limit {t('uploadHint')}
</p> </p>
</> </>
)} )}
@ -489,15 +470,13 @@ export function ImportModal({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase"> <p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
Preview · first {preview.length} {t('preview', { count: preview.length })}
</p> </p>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{tagStats.rowsWithTags > 0 && ( {tagStats.rowsWithTags > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground"> <span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground">
<Tag className="text-primary/80 size-3" /> <Tag className="text-primary/80 size-3" />
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} {t('previewTags', { tags: tagStats.unique, contacts: tagStats.rowsWithTags })}
{tagStats.rowsWithTags} contact
{tagStats.rowsWithTags !== 1 ? 's' : ''}
</span> </span>
)} )}
</div> </div>
@ -509,22 +488,22 @@ export function ImportModal({
<thead> <thead>
<tr className="border-b border-border bg-background/60"> <tr className="border-b border-border bg-background/60">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Phone {t('columns.phone')}
</th> </th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Name {t('columns.name')}
</th> </th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Email {t('columns.email')}
</th> </th>
{previewHasCompany && ( {previewHasCompany && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Company {t('columns.company')}
</th> </th>
)} )}
{previewHasTags && ( {previewHasTags && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Tags {t('columns.tags')}
</th> </th>
)} )}
</tr> </tr>
@ -579,8 +558,7 @@ export function ImportModal({
{parsedRows.length > PREVIEW_LIMIT && ( {parsedRows.length > PREVIEW_LIMIT && (
<p className="text-center text-[11px] text-muted-foreground"> <p className="text-center text-[11px] text-muted-foreground">
+ {parsedRows.length - PREVIEW_LIMIT} more row {t('moreRows', { count: parsedRows.length - PREVIEW_LIMIT })}
{parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown
</p> </p>
)} )}
</div> </div>
@ -588,31 +566,30 @@ export function ImportModal({
{result && ( {result && (
<div className="rounded-xl border border-border bg-background/50 p-4"> <div className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-medium text-popover-foreground">Import complete</p> <p className="text-sm font-medium text-popover-foreground">{t('importComplete')}</p>
<div className="mt-3 flex flex-wrap gap-3"> <div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && ( {result.imported > 0 && (
<div className="text-primary flex items-center gap-1.5 text-sm"> <div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4 shrink-0" /> <CheckCircle className="size-4 shrink-0" />
{result.imported} imported {t('resultImported', { count: result.imported })}
</div> </div>
)} )}
{result.tagsAssigned > 0 && ( {result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-sm text-cyan-400"> <div className="flex items-center gap-1.5 text-sm text-cyan-400">
<CheckCircle className="size-4 shrink-0" /> <CheckCircle className="size-4 shrink-0" />
{result.tagsAssigned} tag {t('resultTags', { count: result.tagsAssigned })}
{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div> </div>
)} )}
{result.skipped > 0 && ( {result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-sm text-amber-400"> <div className="flex items-center gap-1.5 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" /> <AlertTriangle className="size-4 shrink-0" />
{result.skipped} skipped {t('resultSkipped', { count: result.skipped })}
</div> </div>
)} )}
{result.failed > 0 && ( {result.failed > 0 && (
<div className="flex items-center gap-1.5 text-sm text-red-400"> <div className="flex items-center gap-1.5 text-sm text-red-400">
<XCircle className="size-4 shrink-0" /> <XCircle className="size-4 shrink-0" />
{result.failed} failed {t('resultFailed', { count: result.failed })}
</div> </div>
)} )}
</div> </div>
@ -627,7 +604,7 @@ export function ImportModal({
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
{result ? 'Close' : 'Cancel'} {result ? t('close') : t('cancel')}
</Button> </Button>
{!result && ( {!result && (
<Button <Button
@ -637,8 +614,7 @@ export function ImportModal({
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
{importing && <Loader2 className="size-4 animate-spin" />} {importing && <Loader2 className="size-4 animate-spin" />}
Import {parsedRows.length > 0 ? parsedRows.length : ''} contact {parsedRows.length > 0 ? t('importBtn', { count: parsedRows.length }) : t('importBtn', { count: 0 })}
{parsedRows.length !== 1 ? 's' : ''}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>

View File

@ -38,7 +38,10 @@ const KIND_THEME: Record<ActivityKind, KindTheme> = {
automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' }, automation: { icon: Zap, badge: 'bg-rose-500/10 text-rose-400' },
} }
import { useTranslations } from 'next-intl'
export function ActivityFeed({ items, loading }: ActivityFeedProps) { export function ActivityFeed({ items, loading }: ActivityFeedProps) {
const t = useTranslations('Dashboard.activityFeed')
// Start at 5 — a quick scan of the most recent events without // Start at 5 — a quick scan of the most recent events without
// dominating vertical real estate. User expands explicitly via the // dominating vertical real estate. User expands explicitly via the
// footer control when they want deeper history. // footer control when they want deeper history.
@ -56,12 +59,12 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
return ( return (
<section className="rounded-xl border border-border bg-card"> <section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4"> <header className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Recent Activity</h2> <h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
<Link <Link
href="/inbox" href="/inbox"
className="text-xs font-medium text-primary hover:text-primary/80" className="text-xs font-medium text-primary hover:text-primary/80"
> >
View all → {t('viewAll')}
</Link> </Link>
</header> </header>
@ -75,8 +78,8 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
<div className="p-5"> <div className="p-5">
<EmptyState <EmptyState
icon={Inbox} icon={Inbox}
title="No activity yet" title={t('noActivity')}
hint="Activity from messages, deals, broadcasts, and automations will appear here." hint={t('noActivityHint')}
/> />
</div> </div>
) : ( ) : (
@ -103,7 +106,7 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
{it.text} {it.text}
</span> </span>
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums"> <span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
{relativeTime(it.at)} {relativeTime(it.at, t)}
</span> </span>
</div> </div>
) )
@ -122,11 +125,10 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
</ul> </ul>
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs"> <footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
<span className="text-muted-foreground tabular-nums"> <span className="text-muted-foreground tabular-nums">
Showing {visible.length} of {totalLoaded} {t('showingOf', { visible: visible.length, totalLoaded, plus: totalLoaded === 50 ? '+' : '' })}
{totalLoaded === 50 ? '+' : ''}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="mr-1 text-muted-foreground">Show</span> <span className="mr-1 text-muted-foreground">{t('show')}</span>
{PAGE_SIZES.map((size, i) => { {PAGE_SIZES.map((size, i) => {
const disabled = !isSizeUseful(size, i) const disabled = !isSizeUseful(size, i)
return ( return (
@ -155,13 +157,13 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
) )
} }
function relativeTime(iso: string): string { function relativeTime(iso: string, t: ReturnType<typeof useTranslations>): string {
const then = new Date(iso).getTime() const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '' if (Number.isNaN(then)) return ''
const diffSec = Math.round((Date.now() - then) / 1000) const diffSec = Math.round((Date.now() - then) / 1000)
if (diffSec < 60) return `${Math.max(1, diffSec)}s ago` if (diffSec < 60) return t('timeS', { sec: Math.max(1, diffSec) })
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago` if (diffSec < 3600) return t('timeM', { min: Math.floor(diffSec / 60) })
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago` if (diffSec < 86400) return t('timeH', { hr: Math.floor(diffSec / 3600) })
if (diffSec < 2_592_000) return `${Math.floor(diffSec / 86400)}d ago` if (diffSec < 2_592_000) return t('timeD', { day: Math.floor(diffSec / 86400) })
return new Date(iso).toLocaleDateString() return new Date(iso).toLocaleDateString()
} }

View File

@ -27,7 +27,10 @@ const VB_W = 760
const VB_H = 240 const VB_H = 240
const PADDING = { top: 16, right: 16, bottom: 28, left: 40 } const PADDING = { top: 16, right: 16, bottom: 28, left: 40 }
import { useTranslations } from 'next-intl'
export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) { export function ConversationsChart({ series, loading, range, onRangeChange }: ConversationsChartProps) {
const t = useTranslations('Dashboard.conversationsChart')
const data = series[range] const data = series[range]
// Memoise the max so per-day hover math doesn't recompute it. // Memoise the max so per-day hover math doesn't recompute it.
@ -49,8 +52,8 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
<section className="flex h-full flex-col rounded-xl border border-border bg-card"> <section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4"> <header className="flex items-center justify-between border-b border-border px-5 py-4">
<div> <div>
<h2 className="text-sm font-semibold text-foreground">Conversations Over Time</h2> <h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</p> <p className="mt-0.5 text-xs text-muted-foreground">{t('description')}</p>
</div> </div>
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1"> <div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
{[7, 30, 90].map((r) => ( {[7, 30, 90].map((r) => (
@ -65,7 +68,7 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
: 'text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:text-foreground',
)} )}
> >
{r} days {t('days', { count: r })}
</button> </button>
))} ))}
</div> </div>
@ -77,17 +80,17 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? ( ) : data.every((p) => p.incoming === 0 && p.outgoing === 0) ? (
<EmptyState <EmptyState
icon={MessageSquare} icon={MessageSquare}
title="No message activity in this range" title={t('noActivity')}
hint="Send or receive messages to start populating this chart." hint={t('noActivityHint')}
/> />
) : ( ) : (
<LineSvg data={data} maxY={maxY} ticks={niceTicks} /> <LineSvg data={data} maxY={maxY} ticks={niceTicks} t={t} />
)} )}
</div> </div>
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground"> <footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
<LegendDot color="#3b82f6" label="Incoming" /> <LegendDot color="#3b82f6" label={t('incoming')} />
<LegendDot color="#7c3aed" label="Outgoing" /> <LegendDot color="#7c3aed" label={t('outgoing')} />
</footer> </footer>
</section> </section>
) )
@ -101,10 +104,12 @@ function LineSvg({
data, data,
maxY, maxY,
ticks, ticks,
t
}: { }: {
data: ConversationsSeriesPoint[] data: ConversationsSeriesPoint[]
maxY: number maxY: number
ticks: number[] ticks: number[]
t: ReturnType<typeof useTranslations>
}) { }) {
// Hover state: both the snapped index AND the tooltip's pixel // Hover state: both the snapped index AND the tooltip's pixel
// offset inside the wrapper div. They're stored together so the // offset inside the wrapper div. They're stored together so the
@ -195,7 +200,7 @@ function LineSvg({
viewBox={`0 0 ${VB_W} ${VB_H}`} viewBox={`0 0 ${VB_W} ${VB_H}`}
className="h-[240px] w-full" className="h-[240px] w-full"
role="img" role="img"
aria-label="Conversations per day" aria-label={t('ariaLabel')}
> >
{/* Y-axis gridlines + labels */} {/* Y-axis gridlines + labels */}
{ticks.map((t) => { {ticks.map((t) => {
@ -287,11 +292,11 @@ function LineSvg({
<div className="mt-1 flex flex-col gap-0.5"> <div className="mt-1 flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-blue-300"> <span className="flex items-center gap-1.5 text-blue-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" /> <span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
{hovered.incoming} incoming {t('tooltipIncoming', { count: hovered.incoming })}
</span> </span>
<span className="flex items-center gap-1.5 text-primary"> <span className="flex items-center gap-1.5 text-primary">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" /> <span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
{hovered.outgoing} outgoing {t('tooltipOutgoing', { count: hovered.outgoing })}
</span> </span>
</div> </div>
</div> </div>

View File

@ -2,6 +2,8 @@ import { BarChart3 } from 'lucide-react'
import type { ComponentType } from 'react' import type { ComponentType } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
/** /**
* Shared empty-state panel for charts that can't render meaningfully * Shared empty-state panel for charts that can't render meaningfully
* without a minimum amount of data. Kept minimal and uniform so the * without a minimum amount of data. Kept minimal and uniform so the
@ -9,7 +11,7 @@ import { cn } from '@/lib/utils'
* different widget. * different widget.
*/ */
export function EmptyState({ export function EmptyState({
title = 'Not enough data yet', title,
hint, hint,
icon: Icon = BarChart3, icon: Icon = BarChart3,
className, className,
@ -19,6 +21,9 @@ export function EmptyState({
icon?: ComponentType<{ className?: string }> icon?: ComponentType<{ className?: string }>
className?: string className?: string
}) { }) {
const t = useTranslations('Dashboard.emptyState')
const defaultTitle = t('title')
return ( return (
<div <div
className={cn( className={cn(
@ -29,7 +34,7 @@ export function EmptyState({
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</div> </div>
<p className="text-sm font-medium text-muted-foreground">{title}</p> <p className="text-sm font-medium text-muted-foreground">{title || defaultTitle}</p>
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>} {hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
</div> </div>
) )

View File

@ -13,13 +13,16 @@ interface PipelineDonutProps {
currency: string currency: string
} }
import { useTranslations } from 'next-intl'
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) { export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
const t = useTranslations('Dashboard.pipelineDonut')
return ( return (
<section className="flex h-full flex-col rounded-xl border border-border bg-card"> <section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="border-b border-border px-5 py-4"> <header className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2> <h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Open deals by stage {t('description')}
</p> </p>
</header> </header>
@ -29,8 +32,8 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
) : data.stages.length === 0 ? ( ) : data.stages.length === 0 ? (
<EmptyState <EmptyState
icon={GitBranch} icon={GitBranch}
title="No open deals yet" title={t('noOpenDeals')}
hint="Create deals in Pipelines to see stage breakdowns here." hint={t('noOpenDealsHint')}
/> />
) : ( ) : (
<> <>
@ -45,7 +48,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
/> />
<span className="flex-1 truncate text-muted-foreground">{s.name}</span> <span className="flex-1 truncate text-muted-foreground">{s.name}</span>
<span className="text-muted-foreground tabular-nums"> <span className="text-muted-foreground tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'} {t('dealCount', { count: s.dealCount })}
</span> </span>
<span className="w-20 text-right text-muted-foreground tabular-nums"> <span className="w-20 text-right text-muted-foreground tabular-nums">
{formatCurrencyShort(s.totalValue, currency)} {formatCurrencyShort(s.totalValue, currency)}
@ -67,6 +70,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
// them for a cleaner look. // them for a cleaner look.
// ------------------------------------------------------------ // ------------------------------------------------------------
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) { function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
const t = useTranslations('Dashboard.pipelineDonut')
const size = 200 const size = 200
const r = 80 const r = 80
const ringWidth = 18 const ringWidth = 18
@ -96,7 +100,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
return ( return (
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage"> <svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label={t('ariaLabel')}>
{/* background ring */} {/* background ring */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} /> <circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
{segments.map((seg) => ( {segments.map((seg) => (
@ -116,7 +120,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
textAnchor="middle" textAnchor="middle"
className="fill-muted-foreground text-[11px]" className="fill-muted-foreground text-[11px]"
> >
Total {t('total')}
</text> </text>
<text <text
x={cx} x={cx}

View File

@ -4,25 +4,29 @@ import Link from 'next/link'
import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react' import { UserPlus, Briefcase, Radio, Zap } from 'lucide-react'
import type { ComponentType } from 'react' import type { ComponentType } from 'react'
import { useTranslations } from 'next-intl'
// Quick-action shortcuts. Each navigates to the page that owns the // Quick-action shortcuts. Each navigates to the page that owns the
// relevant "create" flow. We deliberately don't try to auto-open any // relevant "create" flow. We deliberately don't try to auto-open any
// modal on the target page — that'd require touching those pages, // modal on the target page — that'd require touching those pages,
// which is out of scope here. // which is out of scope here.
interface Action { interface Action {
label: string labelKey: string
href: string href: string
icon: ComponentType<{ className?: string }> icon: ComponentType<{ className?: string }>
tint: string tint: string
} }
const ACTIONS: Action[] = [ const ACTIONS: Action[] = [
{ label: 'New Contact', href: '/contacts', icon: UserPlus, tint: 'text-primary' }, { labelKey: 'newContact', href: '/contacts', icon: UserPlus, tint: 'text-primary' },
{ label: 'New Deal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' }, { labelKey: 'newDeal', href: '/pipelines', icon: Briefcase, tint: 'text-blue-400' },
{ label: 'New Broadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' }, { labelKey: 'newBroadcast', href: '/broadcasts/new', icon: Radio, tint: 'text-amber-400' },
{ label: 'New Automation', href: '/automations/new', icon: Zap, tint: 'text-primary' }, { labelKey: 'newAutomation', href: '/automations/new', icon: Zap, tint: 'text-primary' },
] ]
export function QuickActions() { export function QuickActions() {
const t = useTranslations('Dashboard.quickActions')
return ( return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{ACTIONS.map((a) => { {ACTIONS.map((a) => {
@ -36,7 +40,7 @@ export function QuickActions() {
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}> <div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
<span className="text-sm font-medium text-foreground">{a.label}</span> <span className="text-sm font-medium text-foreground">{t(a.labelKey as string)}</span>
</Link> </Link>
) )
})} })}

View File

@ -19,6 +19,8 @@ interface ResponseTimeChartProps {
thresholdMinutes?: number thresholdMinutes?: number
} }
import { useTranslations } from 'next-intl'
// Single category, single colour — the data is "average minutes // Single category, single colour — the data is "average minutes
// per weekday". Tremor expects categories as the second tuple in // per weekday". Tremor expects categories as the second tuple in
// the row object, so we shape the buckets into // the row object, so we shape the buckets into
@ -30,6 +32,7 @@ export function ResponseTimeChart({
loading, loading,
thresholdMinutes = 5, thresholdMinutes = 5,
}: ResponseTimeChartProps) { }: ResponseTimeChartProps) {
const t = useTranslations('Dashboard.responseTimeChart')
const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false
// Map buckets → Tremor rows. Null `avgMinutes` (no samples) // Map buckets → Tremor rows. Null `avgMinutes` (no samples)
@ -48,29 +51,28 @@ export function ResponseTimeChart({
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4"> <header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div> <div>
<h2 className="text-sm font-semibold text-foreground"> <h2 className="text-sm font-semibold text-foreground">
Average First Response Time {t('title')}
</h2> </h2>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Minutes to reply to a customer&apos;s first unreplied message, by {t('description')}
weekday
</p> </p>
</div> </div>
<div className="flex items-center gap-3 text-right text-xs"> <div className="flex items-center gap-3 text-right text-xs">
{thresholdMinutes > 0 && ( {thresholdMinutes > 0 && (
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums"> <span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums">
target {thresholdMinutes}m {t('target', { minutes: thresholdMinutes })}
</span> </span>
)} )}
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && ( {data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div> <div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
This week:{' '} {t('thisWeek')}{' '}
<span className="font-medium text-foreground tabular-nums"> <span className="font-medium text-foreground tabular-nums">
{fmt(data.thisWeekAvg)} {fmt(data.thisWeekAvg)}
</span> </span>
</div> </div>
<div className="text-muted-foreground"> <div className="text-muted-foreground">
Last week:{' '} {t('lastWeek')}{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span> <span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div> </div>
</div> </div>
@ -84,8 +86,8 @@ export function ResponseTimeChart({
) : !hasData ? ( ) : !hasData ? (
<EmptyState <EmptyState
icon={Clock} icon={Clock}
title="No replies recorded yet" title={t('noReplies')}
hint="This chart fills in as you reply to customer messages." hint={t('noRepliesHint')}
/> />
) : ( ) : (
<BarChart <BarChart

View File

@ -18,6 +18,7 @@
*/ */
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { import {
CircleAlert, CircleAlert,
Plus, Plus,
@ -73,6 +74,7 @@ import { useFlowEditor, type BuilderState } from './flow-editor-state';
// ============================================================ // ============================================================
export function FlowBuilder() { export function FlowBuilder() {
const t = useTranslations('Flows.builder');
const { const {
state, state,
setState, setState,
@ -158,23 +160,22 @@ export function FlowBuilder() {
state={state} state={state}
setState={setState} setState={setState}
triggerIssues={issues.filter((i) => i.scope === 'trigger')} triggerIssues={issues.filter((i) => i.scope === 'trigger')}
t={t}
/> />
<EntryPicker state={state} setState={setState} /> <EntryPicker state={state} setState={setState} t={t} />
<section className="flex flex-col gap-3"> <section className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-foreground text-sm font-semibold"> <h2 className="text-foreground text-sm font-semibold">
Nodes ({state.nodes.length}) {t('nodesTitle', { count: state.nodes.length })}
</h2> </h2>
<AddNodeButton onAdd={addNode} /> <AddNodeButton onAdd={addNode} t={t} />
</div> </div>
{state.nodes.length === 0 ? ( {state.nodes.length === 0 ? (
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm"> <div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
Add a <strong>Start</strong> node, then a{' '} {t.rich('nodesEmpty', { strong: (chunks) => <strong>{chunks}</strong> })}
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
— that&apos;s the welcome-menu shape from the brief.
</div> </div>
) : ( ) : (
state.nodes.map((node) => ( state.nodes.map((node) => (
@ -196,6 +197,7 @@ export function FlowBuilder() {
onSetEntry={() => onSetEntry={() =>
setState((s) => ({ ...s, entry_node_id: node.node_key })) setState((s) => ({ ...s, entry_node_id: node.node_key }))
} }
t={t}
/> />
)) ))
)} )}
@ -221,9 +223,11 @@ export function FlowBuilder() {
function KeywordsInput({ function KeywordsInput({
keywords, keywords,
onChange, onChange,
t,
}: { }: {
keywords: string[]; keywords: string[];
onChange: (keywords: string[]) => void; onChange: (keywords: string[]) => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const [draft, setDraft] = useState(keywords.join(', ')); const [draft, setDraft] = useState(keywords.join(', '));
@ -247,7 +251,7 @@ function KeywordsInput({
commit(); commit();
} }
}} }}
placeholder="support, help, hi" placeholder={t('keywordsPlaceholder')}
className="bg-muted" className="bg-muted"
/> />
); );
@ -261,18 +265,20 @@ function TriggerPanel({
state, state,
setState, setState,
triggerIssues, triggerIssues,
t,
}: { }: {
state: BuilderState; state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>; setState: React.Dispatch<React.SetStateAction<BuilderState>>;
triggerIssues: ValidationIssue[]; triggerIssues: ValidationIssue[];
t: ReturnType<typeof useTranslations>;
}) { }) {
return ( return (
<section className="border-border bg-card rounded-lg border p-4"> <section className="border-border bg-card rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2> <h2 className="text-foreground mb-3 text-sm font-semibold">{t('triggerTitle')}</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
When… {t('whenLabel')}
</label> </label>
<Select <Select
value={state.trigger_type} value={state.trigger_type}
@ -290,13 +296,13 @@ function TriggerPanel({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="keyword"> <SelectItem value="keyword">
A message contains a keyword {t('triggerKeywordTitle')}
</SelectItem> </SelectItem>
<SelectItem value="first_inbound_message"> <SelectItem value="first_inbound_message">
Customer&apos;s first ever inbound message {t('triggerFirstInboundTitle')}
</SelectItem> </SelectItem>
<SelectItem value="manual"> <SelectItem value="manual">
Manual only (no auto-trigger) {t('triggerManualTitle')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -304,7 +310,7 @@ function TriggerPanel({
{state.trigger_type === 'keyword' && ( {state.trigger_type === 'keyword' && (
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
Keywords (comma-separated) {t('keywordsLabel')}
</label> </label>
<KeywordsInput <KeywordsInput
keywords={ keywords={
@ -318,6 +324,7 @@ function TriggerPanel({
trigger_config: { ...s.trigger_config, keywords }, trigger_config: { ...s.trigger_config, keywords },
})) }))
} }
t={t}
/> />
</div> </div>
)} )}
@ -340,20 +347,22 @@ function TriggerPanel({
function EntryPicker({ function EntryPicker({
state, state,
setState, setState,
t,
}: { }: {
state: BuilderState; state: BuilderState;
setState: React.Dispatch<React.SetStateAction<BuilderState>>; setState: React.Dispatch<React.SetStateAction<BuilderState>>;
t: ReturnType<typeof useTranslations>;
}) { }) {
if (state.nodes.length === 0) return null; if (state.nodes.length === 0) return null;
return ( return (
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3"> <section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
<CornerDownRight className="text-primary h-4 w-4 shrink-0" /> <CornerDownRight className="text-primary h-4 w-4 shrink-0" />
<span className="text-muted-foreground text-xs">Entry node:</span> <span className="text-muted-foreground text-xs">{t('entryNodeTitle')}</span>
<NodeKeySelect <NodeKeySelect
value={state.entry_node_id} value={state.entry_node_id}
nodes={state.nodes} nodes={state.nodes}
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))} onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
placeholder="Pick the first node…" placeholder={t('entryNodePlaceholder')}
className="max-w-xs flex-1" className="max-w-xs flex-1"
/> />
</section> </section>
@ -377,6 +386,7 @@ function NodeCard({
onUpdateConfig, onUpdateConfig,
onRemove, onRemove,
onSetEntry, onSetEntry,
t,
}: { }: {
node: BuilderNode; node: BuilderNode;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
@ -390,11 +400,13 @@ function NodeCard({
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
onRemove: () => void; onRemove: () => void;
onSetEntry: () => void; onSetEntry: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const meta = NODE_META[node.node_type]; const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type); const c = nodeColors(node.node_type);
const hasError = issues.some((i) => i.severity === 'error'); const hasError = issues.some((i) => i.severity === 'error');
const preview = summarizeNode(node); const tSummary = useTranslations('Flows.summary');
const preview = summarizeNode(node, tSummary);
return ( return (
<div <div
ref={cardRef} ref={cardRef}
@ -425,7 +437,7 @@ function NodeCard({
className="truncate text-[11px] font-semibold tracking-wider uppercase" className="truncate text-[11px] font-semibold tracking-wider uppercase"
style={{ color: c.text }} style={{ color: c.text }}
> >
{meta.label} {t(`nodes.${node.node_type}.label`)}
</span> </span>
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]"> <code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
{node.node_key} {node.node_key}
@ -435,7 +447,7 @@ function NodeCard({
variant="outline" variant="outline"
className="border-primary/40 bg-primary/10 text-primary text-[10px]" className="border-primary/40 bg-primary/10 text-primary text-[10px]"
> >
Entry {t('badgeEntry')}
</Badge> </Badge>
)} )}
</div> </div>
@ -461,12 +473,13 @@ function NodeCard({
allNodes={allNodes} allNodes={allNodes}
onUpdate={onUpdate} onUpdate={onUpdate}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
t={t}
/> />
<div className="border-border mt-4 flex items-center justify-between border-t pt-3"> <div className="border-border mt-4 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isEntry && ( {!isEntry && (
<Button variant="ghost" size="sm" onClick={onSetEntry}> <Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry {t('setAsEntry')}
</Button> </Button>
)} )}
</div> </div>
@ -477,7 +490,7 @@ function NodeCard({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300" className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Remove node {t('removeNode')}
</Button> </Button>
</div> </div>
{issues.length > 0 && ( {issues.length > 0 && (
@ -504,11 +517,13 @@ function NodeConfigWithAdvanced({
allNodes, allNodes,
onUpdate, onUpdate,
onUpdateConfig, onUpdateConfig,
t,
}: { }: {
node: BuilderNode; node: BuilderNode;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
onUpdate: (patch: Partial<BuilderNode>) => void; onUpdate: (patch: Partial<BuilderNode>) => void;
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const hasReplyIds = const hasReplyIds =
@ -532,13 +547,13 @@ function NodeConfigWithAdvanced({
) : ( ) : (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
)} )}
{showAdvanced ? 'Hide' : 'Show'} advanced {showAdvanced ? t('hideAdvanced') : t('showAdvanced')}
</button> </button>
{showAdvanced && ( {showAdvanced && (
<div className="mt-3 flex flex-col gap-3"> <div className="mt-3 flex flex-col gap-3">
<div> <div>
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
Node key (internal identifier — keep stable for analytics) {t('nodeKeyLabel')}
</label> </label>
<Input <Input
value={node.node_key} value={node.node_key}
@ -550,9 +565,7 @@ function NodeConfigWithAdvanced({
</div> </div>
{hasReplyIds && ( {hasReplyIds && (
<p className="text-muted-foreground text-[10px]"> <p className="text-muted-foreground text-[10px]">
Reply IDs for each option are shown inline above. They&apos;re {t('replyIdsHint')}
returned by WhatsApp when a customer taps; you usually
don&apos;t need to touch them.
</p> </p>
)} )}
</div> </div>
@ -566,7 +579,7 @@ function NodeConfigWithAdvanced({
// Add-node menu // Add-node menu
// ============================================================ // ============================================================
function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) { function AddNodeButton({ onAdd, t }: { onAdd: (type: NodeType) => void; t: ReturnType<typeof useTranslations> }) {
const types: NodeType[] = [ const types: NodeType[] = [
'start', 'start',
'send_buttons', 'send_buttons',
@ -583,10 +596,10 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors" className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
aria-label="Add node" aria-label={t('addNode')}
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add node {t('addNode')}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-border bg-popover"> <DropdownMenuContent align="end" className="border-border bg-popover">
{groupNodeTypesByCategory(types).map((group, i) => ( {groupNodeTypesByCategory(types).map((group, i) => (
@ -598,14 +611,14 @@ function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
{i > 0 && <DropdownMenuSeparator />} {i > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase"> <DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{group.label} {t(`categories.${group.id}`)}
</DropdownMenuLabel> </DropdownMenuLabel>
{group.types.map((t) => { {group.types.map((t_type) => {
const meta = NODE_META[t]; const meta = NODE_META[t_type];
return ( return (
<DropdownMenuItem key={t} onClick={() => onAdd(t)}> <DropdownMenuItem key={t_type} onClick={() => onAdd(t_type)}>
<meta.icon className={cn('h-3.5 w-3.5', meta.color)} /> <meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
{meta.label} {t(`nodes.${t_type}.label`)}
</DropdownMenuItem> </DropdownMenuItem>
); );
})} })}

View File

@ -59,6 +59,8 @@ import {
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { Plus, Trash2 } from 'lucide-react'; import { Plus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
@ -132,10 +134,12 @@ function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
} }
function FlowNodeCard({ data, selected }: NodeProps) { function FlowNodeCard({ data, selected }: NodeProps) {
const t = useTranslations('Flows.builder');
const { node, isEntry, isFlashed } = data as NodeData; const { node, isEntry, isFlashed } = data as NodeData;
const meta = NODE_META[node.node_type]; const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type); const c = nodeColors(node.node_type);
const summary = summarizeNode(node); const tSummary = useTranslations('Flows.summary');
const summary = summarizeNode(node, tSummary);
const slots = outgoingSlots(node); const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they // Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts // don't need an incoming Handle. Every other node type accepts
@ -191,11 +195,11 @@ function FlowNodeCard({ data, selected }: NodeProps) {
className="truncate text-[10.5px] font-semibold tracking-wider uppercase" className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
style={{ color: c.text }} style={{ color: c.text }}
> >
{meta.label} {t(`nodes.${node.node_type}.label`)}
</span> </span>
{isEntry && ( {isEntry && (
<span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase"> <span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
Entry {t('badgeEntry')}
</span> </span>
)} )}
</div> </div>
@ -270,6 +274,7 @@ export function FlowCanvas() {
} }
function FlowCanvasInner() { function FlowCanvasInner() {
const t = useTranslations('Flows.builder');
const { const {
state, state,
setState, setState,
@ -508,8 +513,8 @@ function FlowCanvasInner() {
if (rfNodes.length === 0) { if (rfNodes.length === 0) {
return ( return (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm"> <div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>No nodes yet.</p> <p>{t('noNodesYet')}</p>
<CanvasAddNodeButton /> <CanvasAddNodeButton t={t} />
</div> </div>
); );
} }
@ -565,7 +570,7 @@ function FlowCanvasInner() {
className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]" className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
/> />
<Panel position="top-left" className="!top-4 !left-4"> <Panel position="top-left" className="!top-4 !left-4">
<CanvasAddNodeButton /> <CanvasAddNodeButton t={t} />
</Panel> </Panel>
</ReactFlow> </ReactFlow>
</div> </div>
@ -578,6 +583,7 @@ function FlowCanvasInner() {
onUpdateConfig={onSelectedUpdateConfig} onUpdateConfig={onSelectedUpdateConfig}
onDelete={handleDeleteSelected} onDelete={handleDeleteSelected}
onSetEntry={handleSetEntry} onSetEntry={handleSetEntry}
t={t}
/> />
</> </>
); );
@ -597,6 +603,7 @@ function NodeEditSheet({
onUpdateConfig, onUpdateConfig,
onDelete, onDelete,
onSetEntry, onSetEntry,
t,
}: { }: {
node: BuilderNode | null; node: BuilderNode | null;
isEntry: boolean; isEntry: boolean;
@ -605,6 +612,7 @@ function NodeEditSheet({
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
onDelete: () => void; onDelete: () => void;
onSetEntry: () => void; onSetEntry: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
// Sheet is controlled — opens when a node is selected, closes via // Sheet is controlled — opens when a node is selected, closes via
// Esc / overlay / close button (all delegated to onClose). // Esc / overlay / close button (all delegated to onClose).
@ -628,15 +636,15 @@ function NodeEditSheet({
<NodeIconChip type={node.node_type} size={36} iconSize={18} /> <NodeIconChip type={node.node_type} size={36} iconSize={18} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase"> <SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
<span style={{ color: c.text }}>{meta.label}</span> <span style={{ color: c.text }}>{t(`nodes.${node.node_type}.label`)}</span>
{isEntry && ( {isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase"> <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
Entry {t('badgeEntry')}
</span> </span>
)} )}
</SheetTitle> </SheetTitle>
<SheetDescription className="text-muted-foreground mt-0.5 text-xs"> <SheetDescription className="text-muted-foreground mt-0.5 text-xs">
{meta.blurb} {t(`nodes.${node.node_type}.blurb`)}
</SheetDescription> </SheetDescription>
</div> </div>
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]"> <code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
@ -656,7 +664,7 @@ function NodeEditSheet({
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between"> <SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
{!isEntry ? ( {!isEntry ? (
<Button variant="ghost" size="sm" onClick={onSetEntry}> <Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry {t('setAsEntry')}
</Button> </Button>
) : ( ) : (
<span /> <span />
@ -668,7 +676,7 @@ function NodeEditSheet({
className="text-red-400 hover:bg-red-500/10 hover:text-red-300" className="text-red-400 hover:bg-red-500/10 hover:text-red-300"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete node {t('deleteNode')}
</Button> </Button>
</SheetFooter> </SheetFooter>
</SheetContent> </SheetContent>
@ -696,7 +704,7 @@ const ADD_NODE_TYPES: NodeType[] = [
'end', 'end',
]; ];
function CanvasAddNodeButton() { function CanvasAddNodeButton({ t }: { t: ReturnType<typeof useTranslations> }) {
const reactFlow = useReactFlow(); const reactFlow = useReactFlow();
const { addNode, updateNodePosition } = useFlowEditor(); const { addNode, updateNodePosition } = useFlowEditor();
@ -728,10 +736,10 @@ function CanvasAddNodeButton() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors" className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
aria-label="Add node" aria-label={t('addNode')}
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add node {t('addNode')}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
@ -746,28 +754,28 @@ function CanvasAddNodeButton() {
{i > 0 && <DropdownMenuSeparator />} {i > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase"> <DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
{group.label} {t(`categories.${group.id}`)}
</DropdownMenuLabel> </DropdownMenuLabel>
{group.types.map((t) => { {group.types.map((t_type) => {
const meta = NODE_META[t]; const meta = NODE_META[t_type];
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={t} key={t_type}
onClick={() => handleAdd(t)} onClick={() => handleAdd(t_type)}
className="gap-3 py-2" className="gap-3 py-2"
> >
<NodeIconChip <NodeIconChip
type={t} type={t_type}
size={28} size={28}
iconSize={16} iconSize={16}
className="rounded-md" className="rounded-md"
/> />
<span className="flex flex-col"> <span className="flex flex-col">
<span className="text-popover-foreground text-[13px] font-semibold"> <span className="text-popover-foreground text-[13px] font-semibold">
{meta.label} {t(`nodes.${t_type}.label`)}
</span> </span>
<span className="text-muted-foreground text-[11.5px]"> <span className="text-muted-foreground text-[11.5px]">
{meta.blurb} {t(`nodes.${t_type}.blurb`)}
</span> </span>
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -35,6 +35,7 @@ import { ValidationPanel } from "./validation-panel";
import { NODE_META, nodeColors, type NodeType } from "./shared"; import { NODE_META, nodeColors, type NodeType } from "./shared";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
import { useTranslations } from "next-intl";
/** /**
* Below this viewport width we force list view and hide the toggle. * Below this viewport width we force list view and hide the toggle.
@ -59,6 +60,8 @@ interface Props {
} }
export function FlowEditorShell({ initialFlow, initialNodes }: Props) { export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
const t = useTranslations("Flows.builder");
// Read the persisted choice in the useState initializer. Safe even // Read the persisted choice in the useState initializer. Safe even
// though this is a client component because the parent page only // though this is a client component because the parent page only
// mounts us AFTER a client-side fetch resolves — there's no SSR // mounts us AFTER a client-side fetch resolves — there's no SSR
@ -110,26 +113,26 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
active={effectiveView === "canvas"} active={effectiveView === "canvas"}
onClick={() => choose("canvas")} onClick={() => choose("canvas")}
icon={<GitFork className="h-3.5 w-3.5" />} icon={<GitFork className="h-3.5 w-3.5" />}
label="Canvas" label={t("canvasView")}
/> />
<SegButton <SegButton
active={effectiveView === "list"} active={effectiveView === "list"}
onClick={() => choose("list")} onClick={() => choose("list")}
icon={<List className="h-3.5 w-3.5" />} icon={<List className="h-3.5 w-3.5" />}
label="List" label={t("listView")}
/> />
</div> </div>
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex"> <div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
{LEGEND_TYPES.map((t) => ( {LEGEND_TYPES.map((t_type) => (
<span <span
key={t} key={t_type}
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground" className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
> >
<span <span
className="h-2.5 w-2.5 rounded-full" className="h-2.5 w-2.5 rounded-full"
style={{ background: nodeColors(t).solid }} style={{ background: nodeColors(t_type).solid }}
/> />
{NODE_META[t].label} {t(`nodes.${t_type}.label`)}
</span> </span>
))} ))}
</div> </div>

View File

@ -49,6 +49,7 @@ import {
validateFlowForActivation, validateFlowForActivation,
type ValidationIssue, type ValidationIssue,
} from "@/lib/flows/validate"; } from "@/lib/flows/validate";
import { useTranslations } from "next-intl";
import { unlinkNodeReferences } from "@/lib/flows/edges"; import { unlinkNodeReferences } from "@/lib/flows/edges";
import type { FlowNodeRow, FlowRow } from "@/lib/flows/types"; import type { FlowNodeRow, FlowRow } from "@/lib/flows/types";
import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared"; import { NODE_META, slugify, type BuilderNode, type NodeType } from "./shared";
@ -237,6 +238,7 @@ export function FlowEditorProvider({
children, children,
}: ProviderProps) { }: ProviderProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations("Flows.editorState");
const [state, setStateRaw] = useState<BuilderState>(() => ({ const [state, setStateRaw] = useState<BuilderState>(() => ({
name: initialFlow.name, name: initialFlow.name,
@ -347,7 +349,7 @@ export function FlowEditorProvider({
throw new Error(json.error ?? `Save failed: ${res.status}`); throw new Error(json.error ?? `Save failed: ${res.status}`);
} }
setDirty(false); setDirty(false);
toast.success("Saved."); toast.success(t("saved"));
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Save failed"; const msg = err instanceof Error ? err.message : "Save failed";
toast.error(msg); toast.error(msg);
@ -360,7 +362,7 @@ export function FlowEditorProvider({
const setStatus = useCallback( const setStatus = useCallback(
async (next: BuilderState["status"]) => { async (next: BuilderState["status"]) => {
if (next === "active" && !canActivate) { if (next === "active" && !canActivate) {
toast.error("Fix the issues below before activating."); toast.error(t("fixIssues"));
return; return;
} }
setActivating(true); setActivating(true);
@ -383,10 +385,10 @@ export function FlowEditorProvider({
setStateRaw((s) => ({ ...s, status: next })); setStateRaw((s) => ({ ...s, status: next }));
toast.success( toast.success(
next === "active" next === "active"
? "Flow activated." ? t("statusActivated")
: next === "archived" : next === "archived"
? "Archived." ? t("statusArchived")
: "Saved as draft.", : t("statusDraft")
); );
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : "Status update failed"; const msg = err instanceof Error ? err.message : "Status update failed";

View File

@ -28,6 +28,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { NODE_META, type BuilderNode } from "../shared"; import { NODE_META, type BuilderNode } from "../shared";
export function TextRow({ export function TextRow({
@ -83,7 +84,7 @@ export function NextNodeRow({
nodes={allNodes} nodes={allNodes}
excludeKey={currentKey} excludeKey={currentKey}
onChange={(v) => onChange(v ?? "")} onChange={(v) => onChange(v ?? "")}
placeholder="Pick a next node…" placeholder={useTranslations("Flows.builder.form")("pickNextNode")}
/> />
</div> </div>
); );
@ -104,6 +105,7 @@ export function NodeKeySelect({
placeholder?: string; placeholder?: string;
className?: string; className?: string;
}) { }) {
const t = useTranslations("Flows.builder.form");
const options = nodes.filter((n) => n.node_key !== excludeKey); const options = nodes.filter((n) => n.node_key !== excludeKey);
return ( return (
<Select <Select
@ -114,7 +116,7 @@ export function NodeKeySelect({
<SelectValue placeholder={placeholder ?? "—"} /> <SelectValue placeholder={placeholder ?? "—"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__">— None —</SelectItem> <SelectItem value="__none__">{t("none")}</SelectItem>
{options.map((n) => { {options.map((n) => {
const Icon = NODE_META[n.node_type].icon; const Icon = NODE_META[n.node_type].icon;
return ( return (

View File

@ -34,6 +34,7 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -62,6 +63,7 @@ export function NodeConfigForm({
showAdvanced, showAdvanced,
onUpdateConfig, onUpdateConfig,
}: NodeConfigFormProps) { }: NodeConfigFormProps) {
const t = useTranslations("Flows.builder.form");
const cfg = node.config; const cfg = node.config;
switch (node.node_type) { switch (node.node_type) {
case "start": case "start":
@ -71,7 +73,7 @@ export function NodeConfigForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={node.node_key} currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })} onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="Advances to" label={t("advancesTo")}
/> />
); );
@ -79,7 +81,7 @@ export function NodeConfigForm({
return ( return (
<> <>
<TextRow <TextRow
label="Text sent to the customer" label={t("textToCustomer")}
value={(cfg as { text?: string }).text ?? ""} value={(cfg as { text?: string }).text ?? ""}
onChange={(v) => onUpdateConfig({ text: v })} onChange={(v) => onUpdateConfig({ text: v })}
/> />
@ -88,7 +90,7 @@ export function NodeConfigForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={node.node_key} currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })} onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="Advances to" label={t("advancesTo")}
/> />
</> </>
); );
@ -101,6 +103,7 @@ export function NodeConfigForm({
currentKey={node.node_key} currentKey={node.node_key}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
showAdvanced={showAdvanced} showAdvanced={showAdvanced}
t={t}
/> />
); );
@ -112,6 +115,7 @@ export function NodeConfigForm({
currentKey={node.node_key} currentKey={node.node_key}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
showAdvanced={showAdvanced} showAdvanced={showAdvanced}
t={t}
/> />
); );
@ -122,6 +126,7 @@ export function NodeConfigForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={node.node_key} currentKey={node.node_key}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
t={t}
/> />
); );
@ -129,14 +134,14 @@ export function NodeConfigForm({
return ( return (
<> <>
<TextRow <TextRow
label="Prompt sent to the customer" label={t("promptToCustomer")}
value={(cfg as { prompt_text?: string }).prompt_text ?? ""} value={(cfg as { prompt_text?: string }).prompt_text ?? ""}
onChange={(v) => onUpdateConfig({ prompt_text: v })} onChange={(v) => onUpdateConfig({ prompt_text: v })}
rows={2} rows={2}
/> />
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground"> <label className="mb-1 block text-xs text-muted-foreground">
Variable key (stored in flow_runs.vars; alphanumeric + underscore) {t("varKeyLabel")}
</label> </label>
<Input <Input
value={(cfg as { var_key?: string }).var_key ?? ""} value={(cfg as { var_key?: string }).var_key ?? ""}
@ -145,11 +150,11 @@ export function NodeConfigForm({
var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""), var_key: e.target.value.replace(/[^a-zA-Z0-9_]/g, ""),
}) })
} }
placeholder="e.g. name, email, company" placeholder={t("varKeyPlaceholder")}
className="bg-muted font-mono text-xs" className="bg-muted font-mono text-xs"
/> />
<p className="mt-1 text-[10px] text-muted-foreground"> <p className="mt-1 text-[10px] text-muted-foreground">
Interpolate in downstream prompts and handoff notes with{" "} {t("varKeyHelp")}{" "}
<code className="rounded bg-muted px-1"> <code className="rounded bg-muted px-1">
{"{{vars."} {"{{vars."}
{(cfg as { var_key?: string }).var_key || "name"} {(cfg as { var_key?: string }).var_key || "name"}
@ -163,7 +168,7 @@ export function NodeConfigForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={node.node_key} currentKey={node.node_key}
onChange={(v) => onUpdateConfig({ next_node_key: v })} onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="After capturing, advance to" label={t("advanceAfterCapture")}
/> />
</> </>
); );
@ -175,6 +180,7 @@ export function NodeConfigForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={node.node_key} currentKey={node.node_key}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
t={t}
/> />
); );
@ -185,13 +191,14 @@ export function NodeConfigForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={node.node_key} currentKey={node.node_key}
onUpdateConfig={onUpdateConfig} onUpdateConfig={onUpdateConfig}
t={t}
/> />
); );
case "handoff": case "handoff":
return ( return (
<TextRow <TextRow
label="Internal note (for the agent picking up)" label={t("internalNote")}
value={(cfg as { note?: string }).note ?? ""} value={(cfg as { note?: string }).note ?? ""}
onChange={(v) => onUpdateConfig({ note: v })} onChange={(v) => onUpdateConfig({ note: v })}
rows={2} rows={2}
@ -201,8 +208,7 @@ export function NodeConfigForm({
case "end": case "end":
return ( return (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Terminal node. When the runner reaches this node the run is marked {t("endNodeHelp")}
complete. No config needed.
</p> </p>
); );
} }
@ -224,12 +230,14 @@ function SendButtonsForm({
currentKey, currentKey,
onUpdateConfig, onUpdateConfig,
showAdvanced, showAdvanced,
t,
}: { }: {
cfg: SendButtonsCfg; cfg: SendButtonsCfg;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
currentKey: string; currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
showAdvanced: boolean; showAdvanced: boolean;
t: ReturnType<typeof useTranslations>;
}) { }) {
const buttons = cfg.buttons ?? []; const buttons = cfg.buttons ?? [];
const updateButton = ( const updateButton = (
@ -257,20 +265,20 @@ function SendButtonsForm({
return ( return (
<> <>
<TextRow <TextRow
label="Body text" label={t("bodyText")}
value={cfg.text ?? ""} value={cfg.text ?? ""}
onChange={(v) => onUpdateConfig({ text: v })} onChange={(v) => onUpdateConfig({ text: v })}
rows={3} rows={3}
/> />
<TextRow <TextRow
label="Footer (optional, 60 chars)" label={t("footerText")}
value={cfg.footer_text ?? ""} value={cfg.footer_text ?? ""}
onChange={(v) => onUpdateConfig({ footer_text: v })} onChange={(v) => onUpdateConfig({ footer_text: v })}
/> />
<div> <div>
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<label className="text-xs text-muted-foreground"> <label className="text-xs text-muted-foreground">
Buttons (1–3) — each one routes to a different next node {t("buttonsHelp")}
</label> </label>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@ -299,7 +307,7 @@ function SendButtonsForm({
<Input <Input
value={b.title} value={b.title}
onChange={(e) => updateButton(i, { title: e.target.value })} onChange={(e) => updateButton(i, { title: e.target.value })}
placeholder="Visible title (≤20 chars)" placeholder={t("optionTitlePlaceholder")}
className="bg-muted" className="bg-muted"
maxLength={20} maxLength={20}
/> />
@ -308,7 +316,7 @@ function SendButtonsForm({
nodes={allNodes} nodes={allNodes}
excludeKey={currentKey} excludeKey={currentKey}
onChange={(v) => updateButton(i, { next_node_key: v ?? "" })} onChange={(v) => updateButton(i, { next_node_key: v ?? "" })}
placeholder="Next node…" placeholder={t("nextNodePlaceholder")}
/> />
<Button <Button
variant="ghost" variant="ghost"
@ -329,7 +337,7 @@ function SendButtonsForm({
className="mt-2" className="mt-2"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add button {t("addButton")}
</Button> </Button>
)} )}
</div> </div>
@ -362,12 +370,14 @@ function SendListForm({
currentKey, currentKey,
onUpdateConfig, onUpdateConfig,
showAdvanced, showAdvanced,
t,
}: { }: {
cfg: SendListCfg; cfg: SendListCfg;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
currentKey: string; currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
showAdvanced: boolean; showAdvanced: boolean;
t: ReturnType<typeof useTranslations>;
}) { }) {
const sections = cfg.sections ?? []; const sections = cfg.sections ?? [];
const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0); const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0);
@ -453,12 +463,12 @@ function SendListForm({
/> />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<TextRow <TextRow
label="Tap-to-expand button label (≤20 chars)" label={t("buttonLabel")}
value={cfg.button_label ?? ""} value={cfg.button_label ?? ""}
onChange={(v) => onUpdateConfig({ button_label: v })} onChange={(v) => onUpdateConfig({ button_label: v })}
/> />
<TextRow <TextRow
label="Footer (optional, 60 chars)" label={t("footerText")}
value={cfg.footer_text ?? ""} value={cfg.footer_text ?? ""}
onChange={(v) => onUpdateConfig({ footer_text: v })} onChange={(v) => onUpdateConfig({ footer_text: v })}
/> />
@ -466,7 +476,7 @@ function SendListForm({
<div className="mt-2"> <div className="mt-2">
<label className="mb-2 block text-xs text-muted-foreground"> <label className="mb-2 block text-xs text-muted-foreground">
Rows (1–10 total across all sections) {t("rowsHelp")}
</label> </label>
{sections.map((section, sIdx) => ( {sections.map((section, sIdx) => (
<div <div
@ -479,7 +489,7 @@ function SendListForm({
onChange={(e) => onChange={(e) =>
updateSection(sIdx, { title: e.target.value }) updateSection(sIdx, { title: e.target.value })
} }
placeholder={`Section ${sIdx + 1} title (optional)`} placeholder={t("sectionTitlePlaceholder", { count: sIdx + 1 })}
className="bg-muted text-xs" className="bg-muted text-xs"
/> />
{sections.length > 1 && ( {sections.length > 1 && (
@ -524,7 +534,7 @@ function SendListForm({
onChange={(e) => onChange={(e) =>
updateRow(sIdx, rIdx, { title: e.target.value }) updateRow(sIdx, rIdx, { title: e.target.value })
} }
placeholder="Row title (≤24)" placeholder={t("rowTitlePlaceholder")}
className="bg-muted" className="bg-muted"
maxLength={24} maxLength={24}
/> />
@ -535,7 +545,7 @@ function SendListForm({
onChange={(v) => onChange={(v) =>
updateRow(sIdx, rIdx, { next_node_key: v ?? "" }) updateRow(sIdx, rIdx, { next_node_key: v ?? "" })
} }
placeholder="Next node…" placeholder={t("nextNodePlaceholder")}
/> />
<Button <Button
variant="ghost" variant="ghost"
@ -555,7 +565,7 @@ function SendListForm({
className="mt-1" className="mt-1"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add row {t("addRow")}
</Button> </Button>
)} )}
</div> </div>
@ -566,7 +576,7 @@ function SendListForm({
{sections.length < 10 && ( {sections.length < 10 && (
<Button variant="outline" size="sm" onClick={addSection}> <Button variant="outline" size="sm" onClick={addSection}>
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add section {t("addSection")}
</Button> </Button>
)} )}
</div> </div>
@ -598,11 +608,13 @@ function ConditionForm({
allNodes, allNodes,
currentKey, currentKey,
onUpdateConfig, onUpdateConfig,
t,
}: { }: {
cfg: ConditionCfg; cfg: ConditionCfg;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
currentKey: string; currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const tags = useUserTags(); const tags = useUserTags();
@ -614,7 +626,7 @@ function ConditionForm({
<> <>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">If</label> <label className="mb-1 block text-xs text-muted-foreground">{t("ifLabel")}</label>
<Select <Select
value={subject} value={subject}
onValueChange={(v) => onValueChange={(v) =>
@ -625,19 +637,19 @@ function ConditionForm({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="var">Captured variable</SelectItem> <SelectItem value="var">{t("capturedVariable")}</SelectItem>
<SelectItem value="tag">Contact has tag</SelectItem> <SelectItem value="tag">{t("contactHasTag")}</SelectItem>
<SelectItem value="contact_field">Contact field</SelectItem> <SelectItem value="contact_field">{t("contactField")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="mb-1 block text-xs text-muted-foreground"> <label className="mb-1 block text-xs text-muted-foreground">
{subject === "var" {subject === "var"
? "var name" ? t("varName")
: subject === "tag" : subject === "tag"
? "Tag" ? t("tagLabel")
: "Field"} : t("fieldLabel")}
</label> </label>
{subject === "tag" && tags.length > 0 ? ( {subject === "tag" && tags.length > 0 ? (
<Select <Select
@ -661,7 +673,7 @@ function ConditionForm({
onValueChange={(v) => onUpdateConfig({ subject_key: v })} onValueChange={(v) => onUpdateConfig({ subject_key: v })}
> >
<SelectTrigger className="bg-muted"> <SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a field…" /> <SelectValue placeholder={t("pickField")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="name">name</SelectItem> <SelectItem value="name">name</SelectItem>
@ -676,7 +688,7 @@ function ConditionForm({
onChange={(e) => onChange={(e) =>
onUpdateConfig({ subject_key: e.target.value }) onUpdateConfig({ subject_key: e.target.value })
} }
placeholder={subject === "var" ? "e.g. email" : "tag UUID"} placeholder={subject === "var" ? t("varKeyPlaceholder") : t("tagUuidPlaceholder")}
className="bg-muted font-mono text-xs" className="bg-muted font-mono text-xs"
/> />
)} )}
@ -690,7 +702,7 @@ function ConditionForm({
)} )}
> >
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">Operator</label> <label className="mb-1 block text-xs text-muted-foreground">{t("operatorLabel")}</label>
<Select <Select
value={operator} value={operator}
onValueChange={(v) => onValueChange={(v) =>
@ -701,16 +713,16 @@ function ConditionForm({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="present">is present</SelectItem> <SelectItem value="present">{t("isPresent")}</SelectItem>
<SelectItem value="absent">is absent</SelectItem> <SelectItem value="absent">{t("isAbsent")}</SelectItem>
<SelectItem value="equals">equals</SelectItem> <SelectItem value="equals">{t("equals")}</SelectItem>
<SelectItem value="contains">contains</SelectItem> <SelectItem value="contains">{t("contains")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{showValue && ( {showValue && (
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">Value</label> <label className="mb-1 block text-xs text-muted-foreground">{t("valueLabel")}</label>
<Input <Input
value={cfg.value ?? ""} value={cfg.value ?? ""}
onChange={(e) => onUpdateConfig({ value: e.target.value })} onChange={(e) => onUpdateConfig({ value: e.target.value })}
@ -726,14 +738,14 @@ function ConditionForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={currentKey} currentKey={currentKey}
onChange={(v) => onUpdateConfig({ true_next: v })} onChange={(v) => onUpdateConfig({ true_next: v })}
label="If true → advance to" label={t("ifTrueAdvance")}
/> />
<NextNodeRow <NextNodeRow
value={cfg.false_next ?? ""} value={cfg.false_next ?? ""}
allNodes={allNodes} allNodes={allNodes}
currentKey={currentKey} currentKey={currentKey}
onChange={(v) => onUpdateConfig({ false_next: v })} onChange={(v) => onUpdateConfig({ false_next: v })}
label="If false → advance to" label={t("ifFalseAdvance")}
/> />
</div> </div>
</> </>
@ -755,11 +767,13 @@ function SetTagForm({
allNodes, allNodes,
currentKey, currentKey,
onUpdateConfig, onUpdateConfig,
t,
}: { }: {
cfg: SetTagCfg; cfg: SetTagCfg;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
currentKey: string; currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const tags = useUserTags(); const tags = useUserTags();
@ -767,7 +781,7 @@ function SetTagForm({
<> <>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">Action</label> <label className="mb-1 block text-xs text-muted-foreground">{t("actionLabel")}</label>
<Select <Select
value={cfg.mode ?? "add"} value={cfg.mode ?? "add"}
onValueChange={(v) => onValueChange={(v) =>
@ -778,13 +792,13 @@ function SetTagForm({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="add">Add tag</SelectItem> <SelectItem value="add">{t("addTag")}</SelectItem>
<SelectItem value="remove">Remove tag</SelectItem> <SelectItem value="remove">{t("removeTag")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">Tag</label> <label className="mb-1 block text-xs text-muted-foreground">{t("tagLabel")}</label>
{tags.length > 0 ? ( {tags.length > 0 ? (
<Select <Select
value={cfg.tag_id ?? ""} value={cfg.tag_id ?? ""}
@ -805,7 +819,7 @@ function SetTagForm({
<Input <Input
value={cfg.tag_id ?? ""} value={cfg.tag_id ?? ""}
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })} onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
placeholder="Tag UUID" placeholder={t("tagUuidPlaceholder")}
className="bg-muted font-mono text-xs" className="bg-muted font-mono text-xs"
/> />
)} )}
@ -816,7 +830,7 @@ function SetTagForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={currentKey} currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })} onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="Then advance to" label={t("thenAdvanceTo")}
/> />
</> </>
); );
@ -878,11 +892,13 @@ function SendMediaForm({
allNodes, allNodes,
currentKey, currentKey,
onUpdateConfig, onUpdateConfig,
t,
}: { }: {
cfg: SendMediaCfg; cfg: SendMediaCfg;
allNodes: BuilderNode[]; allNodes: BuilderNode[];
currentKey: string; currentKey: string;
onUpdateConfig: (patch: Record<string, unknown>) => void; onUpdateConfig: (patch: Record<string, unknown>) => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@ -930,7 +946,7 @@ function SendMediaForm({
return ( return (
<> <>
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">Media type</label> <label className="mb-1 block text-xs text-muted-foreground">{t("mediaTypeLabel")}</label>
<Select <Select
value={mediaType} value={mediaType}
onValueChange={(v) => { onValueChange={(v) => {
@ -948,17 +964,17 @@ function SendMediaForm({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="image">Image (PNG, JPEG, WebP)</SelectItem> <SelectItem value="image">{t("imageLabel")}</SelectItem>
<SelectItem value="video">Video (MP4, 3GP)</SelectItem> <SelectItem value="video">{t("videoLabel")}</SelectItem>
<SelectItem value="document"> <SelectItem value="document">
Document (PDF, Word, Excel, PowerPoint, TXT) {t("documentLabel")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground">File</label> <label className="mb-1 block text-xs text-muted-foreground">{t("fileLabel")}</label>
{cfg.media_url ? ( {cfg.media_url ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs"> <div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs">
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" /> <Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
@ -975,7 +991,7 @@ function SendMediaForm({
type="button" type="button"
onClick={handleClear} onClick={handleClear}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Remove file" aria-label={t("removeFile")}
disabled={uploading} disabled={uploading}
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -991,12 +1007,12 @@ function SendMediaForm({
{uploading ? ( {uploading ? (
<> <>
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3.5 w-3.5 animate-spin" />
Uploading… {t("uploading")}
</> </>
) : ( ) : (
<> <>
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
Click to upload (max 16 MB) {t("clickToUpload")}
</> </>
)} )}
</button> </button>
@ -1016,7 +1032,7 @@ function SendMediaForm({
</div> </div>
<TextRow <TextRow
label="Caption (optional, shown under the media)" label={t("captionLabel")}
value={cfg.caption ?? ""} value={cfg.caption ?? ""}
onChange={(v) => onUpdateConfig({ caption: v })} onChange={(v) => onUpdateConfig({ caption: v })}
rows={2} rows={2}
@ -1025,12 +1041,12 @@ function SendMediaForm({
{isDocument && ( {isDocument && (
<div> <div>
<label className="mb-1 block text-xs text-muted-foreground"> <label className="mb-1 block text-xs text-muted-foreground">
Filename shown to the customer (documents only) {t("filenameLabel")}
</label> </label>
<Input <Input
value={cfg.filename ?? ""} value={cfg.filename ?? ""}
onChange={(e) => onUpdateConfig({ filename: e.target.value })} onChange={(e) => onUpdateConfig({ filename: e.target.value })}
placeholder="invoice.pdf" placeholder={t("filenamePlaceholder")}
className="bg-muted text-xs" className="bg-muted text-xs"
/> />
</div> </div>
@ -1041,7 +1057,7 @@ function SendMediaForm({
allNodes={allNodes} allNodes={allNodes}
currentKey={currentKey} currentKey={currentKey}
onChange={(v) => onUpdateConfig({ next_node_key: v })} onChange={(v) => onUpdateConfig({ next_node_key: v })}
label="After sending, advance to" label={t("advanceAfterSending")}
/> />
</> </>
); );

View File

@ -305,7 +305,10 @@ export function truncate(s: string, max = 80): string {
return clean.slice(0, max - 1) + '…'; return clean.slice(0, max - 1) + '…';
} }
export function summarizeNode(node: BuilderNode): string | null { export function summarizeNode(
node: BuilderNode,
t?: (key: string, values?: Record<string, string | number>) => string
): string | null {
const cfg = node.config; const cfg = node.config;
switch (node.node_type) { switch (node.node_type) {
case 'start': case 'start':
@ -342,11 +345,13 @@ export function summarizeNode(node: BuilderNode): string | null {
}, 0); }, 0);
if (text.length > 0) { if (text.length > 0) {
return rowCount > 0 return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? '' : 's'}` ? `${truncate(text, 50)} · ${t ? t('options', { count: rowCount }) : `${rowCount} option${rowCount === 1 ? '' : 's'}`}`
: truncate(text); : truncate(text);
} }
return rowCount > 0 return rowCount > 0
? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}` ? t
? t('optionsAcrossSections', { rowCount, sectionCount: sections.length })
: `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
: null; : null;
} }
case 'send_media': { case 'send_media': {
@ -356,9 +361,9 @@ export function summarizeNode(node: BuilderNode): string | null {
const url = typeof cfg.media_url === 'string' ? cfg.media_url : ''; const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === 'string' ? cfg.caption : ''; const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1) ? t ? t(mediaType) || (mediaType.charAt(0).toUpperCase() + mediaType.slice(1)) : mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: 'Media'; : t ? t('media') : 'Media';
if (!url) return `${label} (no file uploaded)`; if (!url) return t ? t('noFile', { label }) : `${label} (no file uploaded)`;
const name = filename || url.split('/').pop() || 'file'; const name = filename || url.split('/').pop() || 'file';
return caption return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}` ? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
@ -386,17 +391,17 @@ export function summarizeNode(node: BuilderNode): string | null {
: 'var'; : 'var';
const subjectStr = const subjectStr =
subject === 'tag' subject === 'tag'
? `has tag ${truncate(subjectKey, 24)}` ? t ? t('hasTag', { tag: truncate(subjectKey, 24) }) : `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`; : `${subject}.${subjectKey}`;
const op = const op =
cfg.operator === 'equals' cfg.operator === 'equals'
? '==' ? '=='
: cfg.operator === 'contains' : cfg.operator === 'contains'
? 'contains' ? t ? t('opContains') : 'contains'
: cfg.operator === 'present' : cfg.operator === 'present'
? 'exists' ? t ? t('opExists') : 'exists'
: cfg.operator === 'absent' : cfg.operator === 'absent'
? 'missing' ? t ? t('opMissing') : 'missing'
: ''; : '';
const value = typeof cfg.value === 'string' ? cfg.value : ''; const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr = const valStr =
@ -406,14 +411,14 @@ export function summarizeNode(node: BuilderNode): string | null {
return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`; return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
} }
case 'set_tag': { case 'set_tag': {
const mode = cfg.mode === 'remove' ? 'Remove' : 'Add'; const mode = cfg.mode === 'remove' ? (t ? t('modeRemove') : 'Remove') : (t ? t('modeAdd') : 'Add');
const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : ''; const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
// No tag name available without an async lookup here; show a // No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between // short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance. // multiple set_tag nodes at a glance.
return tagId return tagId
? `${mode} tag ${tagId.slice(0, 8)}…` ? t ? t('tagPicked', { mode, tag: tagId.slice(0, 8) }) : `${mode} tag ${tagId.slice(0, 8)}…`
: `${mode} tag (none picked)`; : t ? t('tagNone', { mode }) : `${mode} tag (none picked)`;
} }
case 'handoff': { case 'handoff': {
const note = typeof cfg.note === 'string' ? cfg.note : ''; const note = typeof cfg.note === 'string' ? cfg.note : '';

View File

@ -18,12 +18,14 @@
*/ */
import { CircleAlert, CircleCheck } from "lucide-react"; import { CircleAlert, CircleCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ValidationIssue } from "@/lib/flows/validate"; import type { ValidationIssue } from "@/lib/flows/validate";
import { useFlowEditor } from "./flow-editor-state"; import { useFlowEditor } from "./flow-editor-state";
export function ValidationPanel() { export function ValidationPanel() {
const { issues, requestFlash } = useFlowEditor(); const { issues, requestFlash } = useFlowEditor();
const t = useTranslations("Flows.validation");
if (issues.length === 0) { if (issues.length === 0) {
// Slate-950 base + emerald accents so the panel stays readable when // Slate-950 base + emerald accents so the panel stays readable when
@ -32,7 +34,7 @@ export function ValidationPanel() {
return ( return (
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-background p-3 text-sm font-medium text-emerald-300"> <div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-background p-3 text-sm font-medium text-emerald-300">
<CircleCheck className="h-4 w-4 shrink-0" /> <CircleCheck className="h-4 w-4 shrink-0" />
No issues. Ready to activate. {t("noIssues")}
</div> </div>
); );
} }
@ -51,12 +53,11 @@ export function ValidationPanel() {
) : ( ) : (
<CircleAlert className="h-4 w-4 text-amber-400" /> <CircleAlert className="h-4 w-4 text-amber-400" />
)} )}
{errors.length} error{errors.length === 1 ? "" : "s"},{" "} {t("summary", { errorCount: errors.length, warningCount: warnings.length })}
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{issues.map((i, ix) => ( {issues.map((i, ix) => (
<IssueLine key={ix} issue={i} onJump={requestFlash} /> <IssueLine key={ix} issue={i} onJump={requestFlash} t={t} />
))} ))}
</div> </div>
</div> </div>
@ -72,9 +73,11 @@ export function ValidationPanel() {
export function IssueLine({ export function IssueLine({
issue, issue,
onJump, onJump,
t,
}: { }: {
issue: ValidationIssue; issue: ValidationIssue;
onJump?: (key: string) => void; onJump?: (key: string) => void;
t?: ReturnType<typeof useTranslations>;
}) { }) {
const tone = const tone =
issue.severity === "error" ? "text-red-300" : "text-amber-300"; issue.severity === "error" ? "text-red-300" : "text-amber-300";
@ -106,7 +109,7 @@ export function IssueLine({
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/60", "flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/60",
tone, tone,
)} )}
aria-label={`Jump to node ${issue.node_key}`} aria-label={t ? t("jumpToNode", { key: issue.node_key! }) : `Jump to node ${issue.node_key}`}
> >
{body} {body}
</button> </button>

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react"; import { Sparkles, Hand, Undo2, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
// ------------------------------------------------------------ // ------------------------------------------------------------
@ -77,6 +78,7 @@ export function AiThreadBanner({
currentUserId, currentUserId,
onChange, onChange,
}: AiThreadBannerProps) { }: AiThreadBannerProps) {
const t = useTranslations("Inbox.aiBanner");
const { accountId } = useAuth(); const { accountId } = useAuth();
const [autoReplyOn, setAutoReplyOn] = useState<boolean | null>(null); const [autoReplyOn, setAutoReplyOn] = useState<boolean | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -107,7 +109,7 @@ export function AiThreadBanner({
}); });
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
toast.error(j?.error ?? "Couldn't update the AI assistant."); toast.error(j?.error ?? t("updateError"));
return; return;
} }
setPaused(paused); setPaused(paused);
@ -122,14 +124,14 @@ export function AiThreadBanner({
: {} : {}
: { assigned_agent_id: null }), : { assigned_agent_id: null }),
}); });
toast.success(paused ? "You've taken over this chat." : "AI resumed."); toast.success(paused ? t("tookOver") : t("resumed"));
} catch { } catch {
toast.error("Couldn't reach the server."); toast.error(t("networkError"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
}, },
[conversationId, currentUserId, onChange], [conversationId, currentUserId, onChange, t],
); );
// Account has no auto-reply → nothing to show. (Still loading → nothing.) // Account has no auto-reply → nothing to show. (Still loading → nothing.)
@ -140,7 +142,7 @@ export function AiThreadBanner({
return ( return (
<Banner tone="muted"> <Banner tone="muted">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium text-foreground">AI assistant is paused here</p> <p className="font-medium text-foreground">{t("pausedTitle")}</p>
{handoffSummary && ( {handoffSummary && (
<p className="truncate text-muted-foreground" title={handoffSummary}> <p className="truncate text-muted-foreground" title={handoffSummary}>
{handoffSummary} {handoffSummary}
@ -148,7 +150,7 @@ export function AiThreadBanner({
)} )}
</div> </div>
<BannerButton onClick={() => toggle(false)} busy={busy} icon={Undo2}> <BannerButton onClick={() => toggle(false)} busy={busy} icon={Undo2}>
Resume AI {t("resume")}
</BannerButton> </BannerButton>
</Banner> </Banner>
); );
@ -163,11 +165,11 @@ export function AiThreadBanner({
<div className="flex min-w-0 flex-1 items-center gap-1.5"> <div className="flex min-w-0 flex-1 items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 flex-shrink-0 text-primary" /> <Sparkles className="h-3.5 w-3.5 flex-shrink-0 text-primary" />
<span className="truncate font-medium text-foreground"> <span className="truncate font-medium text-foreground">
AI assistant is replying automatically {t("activeText")}
</span> </span>
</div> </div>
<BannerButton onClick={() => toggle(true)} busy={busy} icon={Hand}> <BannerButton onClick={() => toggle(true)} busy={busy} icon={Hand}>
Take over {t("takeOver")}
</BannerButton> </BannerButton>
</Banner> </Banner>
); );

View File

@ -19,12 +19,16 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTranslations } from "next-intl";
interface ContactSidebarProps { interface ContactSidebarProps {
contact: Contact | null; contact: Contact | null;
} }
export function ContactSidebar({ contact }: ContactSidebarProps) { export function ContactSidebar({ contact }: ContactSidebarProps) {
const tSidebar = useTranslations("Inbox.sidebar");
const tThread = useTranslations("Inbox.messageThread");
const { accountId } = useAuth(); const { accountId } = useAuth();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [deals, setDeals] = useState<Deal[]>([]); const [deals, setDeals] = useState<Deal[]>([]);
@ -118,7 +122,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
if (!contact) { if (!contact) {
return ( return (
<div className="flex h-full w-70 items-center justify-center border-l border-border bg-card"> <div className="flex h-full w-70 items-center justify-center border-l border-border bg-card">
<p className="text-sm text-muted-foreground">Select a conversation</p> <p className="text-sm text-muted-foreground">{tThread("selectConversation")}</p>
</div> </div>
); );
} }
@ -181,11 +185,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div> <div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<TagIcon className="h-3 w-3" /> <TagIcon className="h-3 w-3" />
Tags {tSidebar("tags")}
</div> </div>
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{tags.length === 0 ? ( {tags.length === 0 ? (
<p className="px-1 text-xs text-muted-foreground">No tags</p> <p className="px-1 text-xs text-muted-foreground">{tSidebar("noTags")}</p>
) : ( ) : (
tags.map((tag) => ( tags.map((tag) => (
<span <span
@ -210,11 +214,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div> <div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<DollarSign className="h-3 w-3" /> <DollarSign className="h-3 w-3" />
Active Deals {tSidebar("deals")}
</div> </div>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{deals.length === 0 ? ( {deals.length === 0 ? (
<p className="px-1 text-xs text-muted-foreground">No deals</p> <p className="px-1 text-xs text-muted-foreground">{tSidebar("noDeals")}</p>
) : ( ) : (
deals.map((deal) => ( deals.map((deal) => (
<div <div
@ -254,14 +258,14 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div> <div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<StickyNote className="h-3 w-3" /> <StickyNote className="h-3 w-3" />
Notes {tSidebar("notes")}
</div> </div>
<div className="mt-2"> <div className="mt-2">
<div className="flex gap-2"> <div className="flex gap-2">
<textarea <textarea
value={newNote} value={newNote}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewNote(e.target.value)}
placeholder="Add a note..." placeholder={tSidebar("addNotePlaceholder")}
rows={2} rows={2}
className="flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-xs text-foreground placeholder-muted-foreground outline-none focus:border-primary/50" className="flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-xs text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
/> />

View File

@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
import type { Conversation, ConversationStatus, Tag } from "@/types"; import type { Conversation, ConversationStatus, Tag } from "@/types";
import { Search, ChevronDown, X } from "lucide-react"; import { Search, ChevronDown, X } from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
DropdownMenu, DropdownMenu,
@ -41,15 +42,9 @@ const STATUS_COLORS: Record<ConversationStatus, string> = {
closed: "bg-muted-foreground", closed: "bg-muted-foreground",
}; };
type InboxFilter = ConversationStatus | "all" | "unread";
const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = [
{ label: "All", value: "all" }, type InboxFilter = ConversationStatus | "all" | "unread";
{ label: "Unread", value: "unread" },
{ label: "Open", value: "open" },
{ label: "Pending", value: "pending" },
{ label: "Closed", value: "closed" },
];
export function ConversationList({ export function ConversationList({
activeConversationId, activeConversationId,
@ -58,6 +53,16 @@ export function ConversationList({
onConversationsLoaded, onConversationsLoaded,
resyncToken = 0, resyncToken = 0,
}: ConversationListProps) { }: ConversationListProps) {
const t = useTranslations("Inbox.conversationList");
const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = useMemo(() => [
{ label: t("filterAll"), value: "all" },
{ label: t("filterUnread"), value: "unread" },
{ label: t("filterOpen"), value: "open" },
{ label: t("filterPending"), value: "pending" },
{ label: t("filterClosed"), value: "closed" },
], [t]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filter, setFilter] = useState<InboxFilter>("all"); const [filter, setFilter] = useState<InboxFilter>("all");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -226,7 +231,7 @@ export function ConversationList({
<Input <Input
value={search} value={search}
onChange={handleSearchChange} onChange={handleSearchChange}
placeholder="Search conversations..." placeholder={t("searchPlaceholder")}
className="border-border bg-muted pl-9 text-sm text-foreground placeholder-muted-foreground focus:border-primary/50" className="border-border bg-muted pl-9 text-sm text-foreground placeholder-muted-foreground focus:border-primary/50"
/> />
</div> </div>
@ -234,7 +239,7 @@ export function ConversationList({
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted"> <DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
{activeFilter?.label ?? "All"} {activeFilter?.label ?? t("filterAll")}
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -268,7 +273,7 @@ export function ConversationList({
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
)} )}
> >
Tags {t("tags")}
{selectedTagIds.length > 0 && ( {selectedTagIds.length > 0 && (
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground"> <span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-bold text-primary-foreground">
{selectedTagIds.length} {selectedTagIds.length}
@ -310,7 +315,7 @@ export function ConversationList({
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
)} )}
> >
<span className="truncate">{selectedCompany ?? "Company"}</span> <span className="truncate">{selectedCompany ?? t("company")}</span>
<ChevronDown className="h-3 w-3 shrink-0" /> <ChevronDown className="h-3 w-3 shrink-0" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -326,7 +331,7 @@ export function ConversationList({
: "text-popover-foreground" : "text-popover-foreground"
)} )}
> >
All companies {t("allCompanies")}
</DropdownMenuItem> </DropdownMenuItem>
{companies.map((co) => ( {companies.map((co) => (
<DropdownMenuItem <DropdownMenuItem
@ -361,7 +366,7 @@ export function ConversationList({
className="h-1.5 w-1.5 shrink-0 rounded-full" className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: tag?.color ?? "var(--muted-foreground)" }} style={{ backgroundColor: tag?.color ?? "var(--muted-foreground)" }}
/> />
<span className="max-w-24 truncate">{tag?.name ?? "Tag"}</span> <span className="max-w-24 truncate">{tag?.name ?? t("tags")}</span>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
); );
@ -379,7 +384,7 @@ export function ConversationList({
onClick={clearContactFilters} onClick={clearContactFilters}
className="px-1 text-[11px] text-muted-foreground hover:text-foreground" className="px-1 text-[11px] text-muted-foreground hover:text-foreground"
> >
Clear all {t("clearAll")}
</button> </button>
</div> </div>
)} )}
@ -398,7 +403,7 @@ export function ConversationList({
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center"> <div className="px-4 py-12 text-center">
<p className="text-sm text-muted-foreground">No conversations found</p> <p className="text-sm text-muted-foreground">{t("noConversations")}</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
@ -408,6 +413,7 @@ export function ConversationList({
conversation={conv} conversation={conv}
isActive={conv.id === activeConversationId} isActive={conv.id === activeConversationId}
onSelect={handleSelect} onSelect={handleSelect}
t={t}
/> />
))} ))}
</div> </div>
@ -421,15 +427,17 @@ interface ConversationItemProps {
conversation: Conversation; conversation: Conversation;
isActive: boolean; isActive: boolean;
onSelect: (conversation: Conversation) => void; onSelect: (conversation: Conversation) => void;
t: ReturnType<typeof useTranslations>;
} }
function ConversationItem({ function ConversationItem({
conversation, conversation,
isActive, isActive,
onSelect, onSelect,
t,
}: ConversationItemProps) { }: ConversationItemProps) {
const contact = conversation.contact; const contact = conversation.contact;
const displayName = contact?.name || contact?.phone || "Unknown"; const displayName = contact?.name || contact?.phone || t("unknown");
const initials = displayName.charAt(0).toUpperCase(); const initials = displayName.charAt(0).toUpperCase();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
@ -473,7 +481,7 @@ function ConversationItem({
</div> </div>
<div className="mt-0.5 flex items-center justify-between gap-2"> <div className="mt-0.5 flex items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{conversation.last_message_text || "No messages yet"} {conversation.last_message_text || t("noMessagesYet")}
</p> </p>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{conversation.unread_count > 0 && ( {conversation.unread_count > 0 && (

View File

@ -10,6 +10,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { Message } from "@/types"; import type { Message } from "@/types";
import { useTranslations } from "next-intl";
// WhatsApp's own quick-reaction bar starts with these six. Picking the same // WhatsApp's own quick-reaction bar starts with these six. Picking the same
// set keeps the affordance familiar without pulling in a 300KB emoji library. // set keeps the affordance familiar without pulling in a 300KB emoji library.
@ -33,6 +34,8 @@ export function MessageActions({
onReact, onReact,
children, children,
}: MessageActionsProps) { }: MessageActionsProps) {
const t = useTranslations("Inbox.actions");
// Touch devices have no hover. Long-press fires `contextmenu`; we capture // Touch devices have no hover. Long-press fires `contextmenu`; we capture
// it, suppress the native menu, and pin the toolbar open until the user // it, suppress the native menu, and pin the toolbar open until the user
// interacts elsewhere. // interacts elsewhere.
@ -50,14 +53,14 @@ export function MessageActions({
const handleCopy = async () => { const handleCopy = async () => {
const text = message.content_text ?? ""; const text = message.content_text ?? "";
if (!text) { if (!text) {
toast.error("Nothing to copy"); toast.error(t("nothingToCopy"));
return; return;
} }
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
toast.success("Copied"); toast.success(t("copied"));
} catch { } catch {
toast.error("Copy failed"); toast.error(t("copyFailed"));
} }
setTouchOpen(false); setTouchOpen(false);
}; };
@ -104,7 +107,7 @@ export function MessageActions({
<Popover open={pickerOpen} onOpenChange={setPickerOpen}> <Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger <PopoverTrigger
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground" className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="React" aria-label={t("react")}
> >
<SmilePlus className="h-3.5 w-3.5" /> <SmilePlus className="h-3.5 w-3.5" />
</PopoverTrigger> </PopoverTrigger>
@ -118,7 +121,7 @@ export function MessageActions({
type="button" type="button"
onClick={() => handlePickEmoji(e)} onClick={() => handlePickEmoji(e)}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-muted" className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-muted"
aria-label={`React with ${e}`} aria-label={t("reactWith", { emoji: e })}
> >
{e} {e}
</button> </button>
@ -129,7 +132,7 @@ export function MessageActions({
type="button" type="button"
onClick={handleReply} onClick={handleReply}
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground" className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Reply" aria-label={t("reply")}
> >
<CornerUpLeft className="h-3.5 w-3.5" /> <CornerUpLeft className="h-3.5 w-3.5" />
</button> </button>
@ -137,7 +140,7 @@ export function MessageActions({
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground" className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy" aria-label={t("copyText")}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>

View File

@ -18,6 +18,7 @@ import {
import { format } from "date-fns"; import { format } from "date-fns";
import { ReplyQuote } from "./reply-quote"; import { ReplyQuote } from "./reply-quote";
import { MessageReactions } from "./message-reactions"; import { MessageReactions } from "./message-reactions";
import { useTranslations } from "next-intl";
interface MessageBubbleProps { interface MessageBubbleProps {
message: Message; message: Message;
@ -45,11 +46,11 @@ function StatusIcon({ status }: { status: Message["status"] }) {
} }
} }
function MediaUnavailable({ label }: { label: string }) { function MediaUnavailable({ label, t }: { label: string, t: ReturnType<typeof useTranslations> }) {
return ( return (
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" /> <ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{label} unavailable</span> <span>{t("unavailable", { label })}</span>
</div> </div>
); );
} }
@ -117,7 +118,7 @@ function MediaImage({ url, alt }: { url: string; alt: string }) {
); );
} }
function MessageContent({ message }: { message: Message }) { function MessageContent({ message, t }: { message: Message, t: ReturnType<typeof useTranslations> }) {
switch (message.content_type) { switch (message.content_type) {
case "text": case "text":
return ( return (
@ -132,7 +133,7 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? ( {message.media_url ? (
<MediaImage url={message.media_url} alt="Shared image" /> <MediaImage url={message.media_url} alt="Shared image" />
) : ( ) : (
<MediaUnavailable label="Image" /> <MediaUnavailable label={t("photo")} t={t} />
)} )}
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -152,7 +153,7 @@ function MessageContent({ message }: { message: Message }) {
className="max-h-64 max-w-60 rounded-lg" className="max-h-64 max-w-60 rounded-lg"
/> />
) : ( ) : (
<MediaUnavailable label="Video" /> <MediaUnavailable label={t("video")} t={t} />
)} )}
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -168,14 +169,14 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? ( {message.media_url ? (
<audio src={message.media_url} controls className="max-w-60" /> <audio src={message.media_url} controls className="max-w-60" />
) : ( ) : (
<MediaUnavailable label="Audio" /> <MediaUnavailable label={t("audio")} t={t} />
)} )}
</div> </div>
); );
case "document": case "document":
if (!message.media_url) { if (!message.media_url) {
return <MediaUnavailable label={message.content_text || "Document"} />; return <MediaUnavailable label={message.content_text || t("document")} t={t} />;
} }
return ( return (
<a <a
@ -186,7 +187,7 @@ function MessageContent({ message }: { message: Message }) {
> >
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" /> <FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate"> <span className="truncate">
{message.content_text || "Document"} {message.content_text || t("document")}
</span> </span>
</a> </a>
); );
@ -196,7 +197,7 @@ function MessageContent({ message }: { message: Message }) {
<div> <div>
<span className="mb-1 inline-flex items-center gap-1 rounded bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary"> <span className="mb-1 inline-flex items-center gap-1 rounded bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary">
<LayoutTemplate className="h-3 w-3" /> <LayoutTemplate className="h-3 w-3" />
Template {t("template")}
</span> </span>
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -210,7 +211,7 @@ function MessageContent({ message }: { message: Message }) {
return ( return (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 shrink-0 text-muted-foreground" /> <MapPin className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{message.content_text || "Location shared"}</span> <span>{message.content_text || t("locationShared")}</span>
</div> </div>
); );
@ -224,10 +225,10 @@ function MessageContent({ message }: { message: Message }) {
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<CornerDownLeft className="h-3 w-3" /> <CornerDownLeft className="h-3 w-3" />
Button reply {t("buttonReply")}
</span> </span>
<p className="whitespace-pre-wrap break-words text-sm"> <p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Interactive reply]"} {message.content_text || t("interactiveReply")}
</p> </p>
</div> </div>
); );
@ -236,7 +237,7 @@ function MessageContent({ message }: { message: Message }) {
default: default:
return ( return (
<p className="whitespace-pre-wrap break-words text-sm"> <p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Unsupported message type]"} {message.content_text || t("unsupported")}
</p> </p>
); );
} }
@ -249,6 +250,8 @@ export function MessageBubble({
currentUserId, currentUserId,
onToggleReaction, onToggleReaction,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const t = useTranslations("Inbox.bubble");
const isAgent = message.sender_type === "agent" || message.sender_type === "bot"; const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
const time = format(new Date(message.created_at), "HH:mm"); const time = format(new Date(message.created_at), "HH:mm");
@ -276,7 +279,7 @@ export function MessageBubble({
onPrimary={isAgent} onPrimary={isAgent}
/> />
)} )}
<MessageContent message={message} /> <MessageContent message={message} t={t} />
<div <div
className={cn( className={cn(
"mt-1 flex items-center gap-1", "mt-1 flex items-center gap-1",
@ -290,10 +293,10 @@ export function MessageBubble({
{message.ai_generated && ( {message.ai_generated && (
<span <span
className="inline-flex items-center gap-0.5 rounded-full bg-primary-foreground/20 px-1.5 py-px text-[9px] font-semibold uppercase leading-none tracking-wide text-primary-foreground" className="inline-flex items-center gap-0.5 rounded-full bg-primary-foreground/20 px-1.5 py-px text-[9px] font-semibold uppercase leading-none tracking-wide text-primary-foreground"
title="Sent automatically by the AI assistant" title={t("aiBadgeTitle")}
> >
<Sparkles className="h-2.5 w-2.5" /> <Sparkles className="h-2.5 w-2.5" />
AI {t("aiBadge")}
</span> </span>
)} )}
<span <span

View File

@ -37,6 +37,7 @@ import {
MEDIA_MAX_BYTES_BY_KIND, MEDIA_MAX_BYTES_BY_KIND,
} from "@/lib/storage/upload-media"; } from "@/lib/storage/upload-media";
import { ReplyQuote } from "./reply-quote"; import { ReplyQuote } from "./reply-quote";
import { useTranslations } from "next-intl";
/** Media content types an agent can send from the composer. */ /** Media content types an agent can send from the composer. */
export type ComposerMediaKind = "image" | "video" | "document" | "audio"; export type ComposerMediaKind = "image" | "video" | "document" | "audio";
@ -121,6 +122,8 @@ export function MessageComposer({
replyTo, replyTo,
onClearReply, onClearReply,
}: MessageComposerProps) { }: MessageComposerProps) {
const t = useTranslations("Inbox.composer");
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [drafting, setDrafting] = useState(false); const [drafting, setDrafting] = useState(false);
@ -437,7 +440,7 @@ export function MessageComposer({
{sessionExpired && ( {sessionExpired && (
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2"> <div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
<p className="text-xs text-amber-400"> <p className="text-xs text-amber-400">
24-hour session expired. Use a template to re-engage. {t("sessionExpiredHint")}
</p> </p>
<Button <Button
variant="ghost" variant="ghost"
@ -446,7 +449,7 @@ export function MessageComposer({
onClick={onOpenTemplates} onClick={onOpenTemplates}
> >
<LayoutTemplate className="mr-1 h-3 w-3" /> <LayoutTemplate className="mr-1 h-3 w-3" />
Templates {t("templates")}
</Button> </Button>
</div> </div>
)} )}
@ -491,27 +494,27 @@ export function MessageComposer({
onCaptionChange={setCaption} onCaptionChange={setCaption}
onDiscard={discardDraft} onDiscard={discardDraft}
onSend={sendDraft} onSend={sendDraft}
t={t}
/> />
) : recording ? ( ) : recording ? (
// Recording bar — replaces the composer while the mic is live. // Recording bar — replaces the composer while the mic is live.
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5"> <div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5">
<span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" /> <span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" />
<span className="flex-1 text-sm text-foreground"> <span className="flex-1 text-sm text-foreground">
Recording… {formatDuration(recordSeconds)} /{" "} {t("recording", { current: formatDuration(recordSeconds), max: formatDuration(MAX_RECORDING_SECONDS) })}
{formatDuration(MAX_RECORDING_SECONDS)}
</span> </span>
<button <button
type="button" type="button"
onClick={cancelRecording} onClick={cancelRecording}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground" className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
> >
Cancel {t("cancel")}
</button> </button>
<Button <Button
size="sm" size="sm"
onClick={stopRecording} onClick={stopRecording}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90" className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90"
title="Stop and attach" title={t("stopAndAttach")}
> >
<Square className="h-4 w-4" /> <Square className="h-4 w-4" />
</Button> </Button>
@ -524,10 +527,10 @@ export function MessageComposer({
disabled={inputsDisabled || busy} disabled={inputsDisabled || busy}
title={ title={
readOnly readOnly
? "Read-only — your role can't send messages" ? t("readOnlyTitle")
: inputsDisabled : inputsDisabled
? undefined ? undefined
: "Attach media" : t("attachMedia")
} }
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
> >
@ -540,19 +543,19 @@ export function MessageComposer({
<DropdownMenuContent align="start" className="border-border bg-popover"> <DropdownMenuContent align="start" className="border-border bg-popover">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}> <DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
<ImageIcon className="mr-2 h-4 w-4" /> <ImageIcon className="mr-2 h-4 w-4" />
Photo {t("photo")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}> <DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
<Video className="mr-2 h-4 w-4" /> <Video className="mr-2 h-4 w-4" />
Video {t("video")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}> <DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" /> <FileText className="mr-2 h-4 w-4" />
Document {t("document")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => void startRecording()}> <DropdownMenuItem onClick={() => void startRecording()}>
<Mic className="mr-2 h-4 w-4" /> <Mic className="mr-2 h-4 w-4" />
Voice note {t("voiceNote")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -562,7 +565,7 @@ export function MessageComposer({
size="sm" size="sm"
canAct={!readOnly} canAct={!readOnly}
gateReason="send messages" gateReason="send messages"
title={readOnly ? undefined : "Send template"} title={readOnly ? undefined : t("sendTemplate")}
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground" className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenTemplates} onClick={onOpenTemplates}
> >
@ -575,7 +578,7 @@ export function MessageComposer({
canAct={!readOnly} canAct={!readOnly}
gateReason="send messages" gateReason="send messages"
disabled={drafting} disabled={drafting}
title={readOnly ? undefined : "Draft a reply with AI"} title={readOnly ? undefined : t("draftWithAI")}
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-primary" className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-primary"
onClick={handleDraft} onClick={handleDraft}
> >
@ -593,17 +596,17 @@ export function MessageComposer({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={
readOnly readOnly
? "Read-only — viewers can browse but not reply" ? t("readOnlyPlaceholder")
: sessionExpired : sessionExpired
? "Session expired - use a template" ? t("sessionExpiredPlaceholder")
: "Type a message... (Shift+Enter for new line)" : t("typeMessagePlaceholder")
} }
disabled={sessionExpired || readOnly} disabled={sessionExpired || readOnly}
rows={1} rows={1}
// Textarea keeps its own inline title — the GatedButton // Textarea keeps its own inline title — the GatedButton
// wrapping pattern doesn't apply to non-button inputs. // wrapping pattern doesn't apply to non-button inputs.
// The placeholder text also surfaces the read-only state. // The placeholder text also surfaces the read-only state.
title={readOnly ? "Read-only — your role can't send messages" : undefined} title={readOnly ? t("readOnlyTitle") : undefined}
className={cn( className={cn(
"flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50", "flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50",
(sessionExpired || readOnly) && "cursor-not-allowed opacity-50" (sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
@ -628,7 +631,7 @@ export function MessageComposer({
under the textarea left edge. */} under the textarea left edge. */}
{!draft && !recording && ( {!draft && !recording && (
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground"> <p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
Tap the ✨ to draft a reply with AI — you can edit it before sending {t("draftHint")}
</p> </p>
)} )}
</div> </div>
@ -648,6 +651,7 @@ function MediaDraftPreview({
onCaptionChange, onCaptionChange,
onDiscard, onDiscard,
onSend, onSend,
t,
}: { }: {
draft: MediaDraft; draft: MediaDraft;
busy: boolean; busy: boolean;
@ -655,6 +659,7 @@ function MediaDraftPreview({
onCaptionChange: (caption: string) => void; onCaptionChange: (caption: string) => void;
onDiscard: () => void; onDiscard: () => void;
onSend: () => void; onSend: () => void;
t: ReturnType<typeof useTranslations>;
}) { }) {
return ( return (
<div className="rounded-xl border border-border bg-muted/40 p-3"> <div className="rounded-xl border border-border bg-muted/40 p-3">
@ -684,7 +689,7 @@ function MediaDraftPreview({
<button <button
type="button" type="button"
onClick={onDiscard} onClick={onDiscard}
aria-label="Remove attachment" aria-label={t("removeAttachment")}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
@ -703,7 +708,7 @@ function MediaDraftPreview({
onSend(); onSend();
} }
}} }}
placeholder="Add a caption…" placeholder={t("addCaption")}
className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50" className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50"
/> />
)} )}

View File

@ -28,6 +28,7 @@ import {
PanelRightClose, PanelRightClose,
} from "lucide-react"; } from "lucide-react";
import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { format, isToday, isYesterday, differenceInHours } from "date-fns";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
DropdownMenu, DropdownMenu,
@ -109,10 +110,10 @@ interface MessageThreadProps {
onToggleContactPanel?: () => void; onToggleContactPanel?: () => void;
} }
function formatDateSeparator(dateStr: string): string { function formatDateSeparator(dateStr: string, t: ReturnType<typeof useTranslations>): string {
const date = new Date(dateStr); const date = new Date(dateStr);
if (isToday(date)) return "Today"; if (isToday(date)) return t("today");
if (isYesterday(date)) return "Yesterday"; if (isYesterday(date)) return t("yesterday");
return format(date, "MMMM d, yyyy"); return format(date, "MMMM d, yyyy");
} }
@ -166,6 +167,10 @@ export function MessageThread({
contactPanelOpen, contactPanelOpen,
onToggleContactPanel, onToggleContactPanel,
}: MessageThreadProps) { }: MessageThreadProps) {
const t = useTranslations("Inbox.messageThread");
const tTimer = useTranslations("Inbox.sessionTimer");
const tQuote = useTranslations("Inbox.replyQuote");
const { user } = useAuth(); const { user } = useAuth();
const { getPresence, getRow, now } = usePresence(); const { getPresence, getRow, now } = usePresence();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -235,17 +240,17 @@ export function MessageThread({
const expired = hoursSince >= 24; const expired = hoursSince >= 24;
if (expired) { if (expired) {
return { expired: true, remaining: "Expired" }; return { expired: true, remaining: tTimer("expired") };
} }
const hoursLeft = 24 - hoursSince; const hoursLeft = 24 - hoursSince;
const remaining = const remaining =
hoursLeft >= 1 hoursLeft >= 1
? `${Math.floor(hoursLeft)}h remaining` ? tTimer("xhRemaining", { hours: Math.floor(hoursLeft) })
: `${Math.floor(hoursLeft * 60)}m remaining`; : tTimer("xmRemaining", { minutes: Math.floor(hoursLeft * 60) });
return { expired, remaining }; return { expired, remaining };
}, [messages]); }, [messages, tTimer]);
// Store latest callback in a ref so fetchMessages doesn't need to // Store latest callback in a ref so fetchMessages doesn't need to
// depend on `onMessagesLoaded` — otherwise parent re-renders cause // depend on `onMessagesLoaded` — otherwise parent re-renders cause
@ -688,7 +693,7 @@ export function MessageThread({
setReplyTo({ setReplyTo({
id: msg.id, id: msg.id,
authorLabel: authorLabelFor(msg), authorLabel: authorLabelFor(msg),
preview: buildReplyPreview(msg), preview: buildReplyPreview(msg, tQuote),
}); });
}, },
[authorLabelFor], [authorLabelFor],
@ -789,10 +794,10 @@ export function MessageThread({
<MessageSquare className="h-8 w-8 text-muted-foreground" /> <MessageSquare className="h-8 w-8 text-muted-foreground" />
</div> </div>
<h3 className="mt-4 text-sm font-medium text-muted-foreground"> <h3 className="mt-4 text-sm font-medium text-muted-foreground">
Select a conversation {t("selectConversation")}
</h3> </h3>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Choose a conversation from the left to start messaging {t("selectConversationHint")}
</p> </p>
</div> </div>
); );
@ -806,8 +811,8 @@ export function MessageThread({
const assignedAgentId = conversation.assigned_agent_id ?? null; const assignedAgentId = conversation.assigned_agent_id ?? null;
const currentAssignee = profiles.find((p) => p.user_id === assignedAgentId); const currentAssignee = profiles.find((p) => p.user_id === assignedAgentId);
const assignLabel = assignedAgentId const assignLabel = assignedAgentId
? (currentAssignee?.full_name ?? "Assigned") ? (currentAssignee?.full_name ?? t("assigned"))
: "Assign"; : t("assign");
return ( return (
// `min-w-0` is load-bearing: the page already puts min-w-0 on the // `min-w-0` is load-bearing: the page already puts min-w-0 on the
@ -829,7 +834,7 @@ export function MessageThread({
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
aria-label="Back to conversations" aria-label={t("backToConversations")}
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden" className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
> >
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
@ -867,10 +872,10 @@ export function MessageThread({
type="button" type="button"
onClick={onToggleContactPanel} onClick={onToggleContactPanel}
aria-label={ aria-label={
contactPanelOpen ? "Hide contact panel" : "Show contact panel" contactPanelOpen ? t("hideContactPanel") : t("showContactPanel")
} }
title={contactPanelOpen ? t("hideContact") : t("showContact")}
aria-pressed={contactPanelOpen} aria-pressed={contactPanelOpen}
title={contactPanelOpen ? "Hide contact" : "Show contact"}
className={cn( className={cn(
"hidden h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground lg:inline-flex", "hidden h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground lg:inline-flex",
contactPanelOpen ? "text-primary" : "text-muted-foreground", contactPanelOpen ? "text-primary" : "text-muted-foreground",
@ -894,8 +899,8 @@ export function MessageThread({
type="button" type="button"
onClick={handleRefreshClick} onClick={handleRefreshClick}
disabled={isRefreshing} disabled={isRefreshing}
aria-label="Refresh conversation" aria-label={t("refreshConversation")}
title="Refresh" title={t("refresh")}
className={cn( className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-60", "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-60",
)} )}
@ -912,7 +917,7 @@ export function MessageThread({
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted", "inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
currentStatus?.color ?? "text-muted-foreground" currentStatus?.color ?? "text-muted-foreground"
)}> )}>
{currentStatus?.label ?? "Status"} {currentStatus ? t(`status${currentStatus.label}`) : t("status")}
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -925,7 +930,7 @@ export function MessageThread({
onClick={() => handleStatusChange(opt.value)} onClick={() => handleStatusChange(opt.value)}
className={cn("text-sm", opt.color)} className={cn("text-sm", opt.color)}
> >
{opt.label} {t(`status${opt.label}`)}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@ -949,7 +954,7 @@ export function MessageThread({
> >
{profiles.length === 0 ? ( {profiles.length === 0 ? (
<DropdownMenuItem disabled className="text-sm text-muted-foreground"> <DropdownMenuItem disabled className="text-sm text-muted-foreground">
No teammates available {t("noTeammates")}
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
profiles.map((p) => { profiles.map((p) => {
@ -975,7 +980,7 @@ export function MessageThread({
/> />
<span className="flex-1"> <span className="flex-1">
{p.full_name} {p.full_name}
{p.user_id === user?.id ? " (me)" : ""} {p.user_id === user?.id ? t("me") : ""}
</span> </span>
{isSelected && <Check className="ml-2 h-3 w-3" />} {isSelected && <Check className="ml-2 h-3 w-3" />}
</DropdownMenuItem> </DropdownMenuItem>
@ -989,7 +994,7 @@ export function MessageThread({
onClick={() => handleAssignChange(null)} onClick={() => handleAssignChange(null)}
className="text-sm text-muted-foreground" className="text-sm text-muted-foreground"
> >
Unassign {t("unassign")}
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
@ -1006,9 +1011,9 @@ export function MessageThread({
</div> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No messages yet</p> <p className="text-sm text-muted-foreground">{t("noMessagesYet")}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Send a template to start the conversation {t("sendTemplateHint")}
</p> </p>
</div> </div>
) : ( ) : (
@ -1018,7 +1023,7 @@ export function MessageThread({
{/* Date separator */} {/* Date separator */}
<div className="mb-4 flex items-center justify-center"> <div className="mb-4 flex items-center justify-center">
<span className="rounded-full bg-muted px-3 py-1 text-[10px] font-medium text-muted-foreground"> <span className="rounded-full bg-muted px-3 py-1 text-[10px] font-medium text-muted-foreground">
{formatDateSeparator(group.date)} {formatDateSeparator(group.date, t)}
</span> </span>
</div> </div>
{/* Messages */} {/* Messages */}
@ -1029,8 +1034,11 @@ export function MessageThread({
: null; : null;
const reply = parent const reply = parent
? { ? {
authorLabel: authorLabelFor(parent), authorLabel:
preview: buildReplyPreview(parent), parent.sender_type === "agent" || parent.sender_type === "bot"
? t("me")
: contact?.name || contact?.phone || "Unknown",
preview: buildReplyPreview(parent, tQuote),
} }
: null; : null;
const msgReactions = reactionsByMessageId.get(msg.id); const msgReactions = reactionsByMessageId.get(msg.id);

View File

@ -3,6 +3,7 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Message } from "@/types"; import type { Message } from "@/types";
import { useTranslations } from "next-intl";
interface ReplyQuoteProps { interface ReplyQuoteProps {
/** Sender label of the quoted message: "You" for our own messages, /** Sender label of the quoted message: "You" for our own messages,
@ -26,6 +27,7 @@ export function ReplyQuote({
onDismiss, onDismiss,
onPrimary = false, onPrimary = false,
}: ReplyQuoteProps) { }: ReplyQuoteProps) {
const t = useTranslations("Inbox.replyQuote");
const isChip = !!onDismiss; const isChip = !!onDismiss;
return ( return (
<div <div
@ -63,7 +65,7 @@ export function ReplyQuote({
<button <button
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
aria-label="Cancel reply" aria-label={t("cancelReply")}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -74,22 +76,22 @@ export function ReplyQuote({
} }
/** Build the one-line preview text shown inside a reply quote. */ /** Build the one-line preview text shown inside a reply quote. */
export function buildReplyPreview(message: Message): string { export function buildReplyPreview(message: Message, t: ReturnType<typeof useTranslations>): string {
if (message.content_text) return message.content_text; if (message.content_text) return message.content_text;
switch (message.content_type) { switch (message.content_type) {
case "image": case "image":
return "[Image]"; return t("photo");
case "video": case "video":
return "[Video]"; return t("video");
case "audio": case "audio":
return "[Audio]"; return t("audio");
case "document": case "document":
return "[Document]"; return t("document");
case "location": case "location":
return "[Location]"; return t("location");
case "template": case "template":
return "[Template]"; return t("template");
default: default:
return "[Message]"; return t("message");
} }
} }

View File

@ -22,6 +22,7 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { extractVariableIndices } from "@/lib/whatsapp/template-validators"; import { extractVariableIndices } from "@/lib/whatsapp/template-validators";
import { useTranslations } from "next-intl";
export interface TemplateSendValues { export interface TemplateSendValues {
body: string[]; body: string[];
@ -78,6 +79,8 @@ export function TemplatePicker({
onOpenChange, onOpenChange,
onSelect, onSelect,
}: TemplatePickerProps) { }: TemplatePickerProps) {
const t = useTranslations("Inbox.templatePicker");
const [templates, setTemplates] = useState<MessageTemplate[]>([]); const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<MessageTemplate | null>(null); const [selected, setSelected] = useState<MessageTemplate | null>(null);
@ -190,12 +193,12 @@ export function TemplatePicker({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground"> <DialogTitle className="flex items-center gap-2 text-popover-foreground">
<LayoutTemplate className="h-4 w-4 text-primary" /> <LayoutTemplate className="h-4 w-4 text-primary" />
{selected ? selected.name : "Send template"} {selected ? selected.name : t("sendTemplate")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
{selected {selected
? "Fill in the placeholders to render this template. Meta requires every variable to be set." ? t("fillPlaceholders")
: "Pick an approved WhatsApp template to send to this contact."} : t("pickTemplate")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -207,10 +210,9 @@ export function TemplatePicker({
</div> </div>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<div className="rounded-md border border-border bg-background/50 p-6 text-center"> <div className="rounded-md border border-border bg-background/50 p-6 text-center">
<p className="text-sm text-popover-foreground">No approved templates</p> <p className="text-sm text-popover-foreground">{t("noApprovedTemplates")}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Approve a template in Meta WhatsApp Manager, then sync it {t("noApprovedTemplatesHint")}
from Settings → Templates.
</p> </p>
</div> </div>
) : ( ) : (
@ -249,7 +251,7 @@ export function TemplatePicker({
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border border-border bg-background/50 p-3"> <div className="rounded-md border border-border bg-background/50 p-3">
<p className="mb-1 text-xs text-muted-foreground">Preview</p> <p className="mb-1 text-xs text-muted-foreground">{t("preview")}</p>
<p className="whitespace-pre-wrap text-sm text-popover-foreground"> <p className="whitespace-pre-wrap text-sm text-popover-foreground">
{renderBodyPreview(selected.body_text, params)} {renderBodyPreview(selected.body_text, params)}
</p> </p>
@ -267,7 +269,7 @@ export function TemplatePicker({
<Input <Input
value={headerText} value={headerText}
onChange={(e) => setHeaderText(e.target.value)} onChange={(e) => setHeaderText(e.target.value)}
placeholder="Value for the header variable" placeholder={t("headerValuePlaceholder")}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground" className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
@ -282,7 +284,7 @@ export function TemplatePicker({
next[i] = e.target.value; next[i] = e.target.value;
setParams(next); setParams(next);
}} }}
placeholder={`Value for {{${v}}}`} placeholder={t("bodyValuePlaceholder", { val: `{{${v}}}` })}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground" className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
@ -300,11 +302,11 @@ export function TemplatePicker({
[slot.index]: e.target.value, [slot.index]: e.target.value,
})) }))
} }
placeholder="URL suffix value" placeholder={t("urlSuffixValuePlaceholder")}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground" className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/> />
<p className="text-[10px] text-muted-foreground break-all"> <p className="text-[10px] text-muted-foreground break-all">
Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")} {t("finalUrl", { url: slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}") })}
</p> </p>
</div> </div>
))} ))}
@ -320,14 +322,14 @@ export function TemplatePicker({
className="border-border text-popover-foreground hover:bg-muted" className="border-border text-popover-foreground hover:bg-muted"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back {t("back")}
</Button> </Button>
<Button <Button
disabled={!canConfirm} disabled={!canConfirm}
onClick={confirm} onClick={confirm}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
Send template {t("send")}
</Button> </Button>
</> </>
) : ( ) : (
@ -336,7 +338,7 @@ export function TemplatePicker({
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
className="border-border text-popover-foreground hover:bg-muted" className="border-border text-popover-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>

View File

@ -19,22 +19,22 @@ import {
import { ModeToggle } from "@/components/layout/mode-toggle"; import { ModeToggle } from "@/components/layout/mode-toggle";
const pageTitles: Record<string, string> = { const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard", "/dashboard": "dashboard",
"/inbox": "Inbox", "/inbox": "inbox",
"/notifications": "Notifications", "/notifications": "notifications",
"/contacts": "Contacts", "/contacts": "contacts",
"/pipelines": "Pipelines", "/pipelines": "pipelines",
"/broadcasts": "Broadcasts", "/broadcasts": "broadcasts",
"/automations": "Automations", "/automations": "automations",
"/settings": "Settings", "/settings": "settings",
}; };
function getPageTitle(pathname: string): string { function getPageTitleKey(pathname: string): string {
if (pageTitles[pathname]) return pageTitles[pathname]; if (pageTitles[pathname]) return pageTitles[pathname];
const match = Object.entries(pageTitles).find(([path]) => const match = Object.entries(pageTitles).find(([path]) =>
pathname.startsWith(path), pathname.startsWith(path),
); );
return match ? match[1] : "Dashboard"; return match ? match[1] : "dashboard";
} }
interface HeaderProps { interface HeaderProps {
@ -43,10 +43,13 @@ interface HeaderProps {
onOpenSidebar?: () => void; onOpenSidebar?: () => void;
} }
import { useTranslations } from "next-intl";
export function Header({ onOpenSidebar }: HeaderProps) { export function Header({ onOpenSidebar }: HeaderProps) {
const t = useTranslations("Header");
const pathname = usePathname(); const pathname = usePathname();
const { profile, signOut } = useAuth(); const { profile, signOut } = useAuth();
const title = getPageTitle(pathname); const titleKey = getPageTitleKey(pathname);
const initial = const initial =
profile?.full_name?.charAt(0)?.toUpperCase() ?? profile?.full_name?.charAt(0)?.toUpperCase() ??
@ -60,13 +63,13 @@ export function Header({ onOpenSidebar }: HeaderProps) {
<button <button
type="button" type="button"
onClick={onOpenSidebar} onClick={onOpenSidebar}
aria-label="Open menu" aria-label={t("openMenu")}
className="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden" className="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
> >
<Menu className="h-5 w-5" /> <Menu className="h-5 w-5" />
</button> </button>
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg"> <h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
{title} {t(titleKey as string)}
</h1> </h1>
</div> </div>
@ -76,13 +79,13 @@ export function Header({ onOpenSidebar }: HeaderProps) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-muted/70 focus:bg-muted/70 focus:outline-none data-popup-open:bg-muted/70 sm:gap-3 sm:pl-1 sm:pr-3" className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-muted/70 focus:bg-muted/70 focus:outline-none data-popup-open:bg-muted/70 sm:gap-3 sm:pl-1 sm:pr-3"
aria-label="Open account menu" aria-label={t("openAccountMenu")}
> >
<Avatar className="size-8"> <Avatar className="size-8">
{profile?.avatar_url ? ( {profile?.avatar_url ? (
<AvatarImage <AvatarImage
src={profile.avatar_url} src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"} alt={profile.full_name ?? t("defaultAvatar")}
/> />
) : null} ) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary"> <AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
@ -90,7 +93,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="hidden text-sm font-medium text-foreground sm:inline"> <span className="hidden text-sm font-medium text-foreground sm:inline">
{profile?.full_name ?? "User"} {profile?.full_name ?? t("defaultUser")}
</span> </span>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -100,7 +103,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
> >
<div className="px-2 py-1.5"> <div className="px-2 py-1.5">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"} {profile?.full_name ?? t("defaultUser")}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""} {profile?.email ?? ""}
@ -116,7 +119,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
} }
> >
<User className="size-4" /> <User className="size-4" />
Profile {t("menuProfile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
render={ render={
@ -127,7 +130,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
} }
> >
<SettingsIcon className="size-4" /> <SettingsIcon className="size-4" />
Settings {t("menuSettings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
@ -135,7 +138,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground" className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
Sign out {t("menuSignOut")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -5,6 +5,8 @@ import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme"; import { useTheme } from "@/hooks/use-theme";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
/** /**
* Light/dark mode toggle — a single icon button that flips the app * Light/dark mode toggle — a single icon button that flips the app
* between the two modes. Sun shows in light mode (click → go dark), * between the two modes. Sun shows in light mode (click → go dark),
@ -14,14 +16,17 @@ import { cn } from "@/lib/utils";
* 40×40 hit target to match the header's other touch controls. * 40×40 hit target to match the header's other touch controls.
*/ */
export function ModeToggle({ className }: { className?: string }) { export function ModeToggle({ className }: { className?: string }) {
const t = useTranslations("ModeToggle");
const { mode, toggleMode } = useTheme(); const { mode, toggleMode } = useTheme();
const goingTo = mode === "dark" ? "light" : "dark"; const goingTo = mode === "dark" ? "light" : "dark";
const switchLabel = t("switchMode", { mode: goingTo });
return ( return (
<button <button
type="button" type="button"
onClick={toggleMode} onClick={toggleMode}
aria-label={`Switch to ${goingTo} mode`} aria-label={switchLabel}
title={`Switch to ${goingTo} mode`} title={switchLabel}
className={cn( className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", "flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className, className,

View File

@ -34,32 +34,32 @@ import type { AccountRole } from "@/lib/auth/roles";
// wants to recolour "agent" rows, this is the one diff. // wants to recolour "agent" rows, this is the one diff.
const ROLE_CHIP: Record< const ROLE_CHIP: Record<
AccountRole, AccountRole,
{ icon: typeof Crown; label: string; className: string } { icon: typeof Crown; labelKey: string; className: string }
> = { > = {
owner: { owner: {
icon: Crown, icon: Crown,
label: "Owner", labelKey: "roleOwner",
// Amber: scarce, immutable, "the boss" — gets visual emphasis. // Amber: scarce, immutable, "the boss" — gets visual emphasis.
className: className:
"border-amber-500/40 bg-amber-500/10 text-amber-300", "border-amber-500/40 bg-amber-500/10 text-amber-300",
}, },
admin: { admin: {
icon: Shield, icon: Shield,
label: "Admin", labelKey: "roleAdmin",
// Primary-tinted: significant but not as scarce as owner. // Primary-tinted: significant but not as scarce as owner.
className: className:
"border-primary/40 bg-primary/10 text-primary", "border-primary/40 bg-primary/10 text-primary",
}, },
agent: { agent: {
icon: UserCog, icon: UserCog,
label: "Agent", labelKey: "roleAgent",
// Neutral slate: the operational default. // Neutral slate: the operational default.
className: className:
"border-border bg-muted text-foreground", "border-border bg-muted text-foreground",
}, },
viewer: { viewer: {
icon: User, icon: User,
label: "Viewer", labelKey: "roleViewer",
// Muted slate: read-only role; visually quieter than agent. // Muted slate: read-only role; visually quieter than agent.
className: className:
"border-border bg-card text-muted-foreground", "border-border bg-card text-muted-foreground",
@ -80,7 +80,7 @@ import {
interface NavItem { interface NavItem {
href: string; href: string;
label: string; labelKey: string;
icon: typeof LayoutDashboard; icon: typeof LayoutDashboard;
/** /**
* When true, the nav row renders a small "Beta" chip after the label. * When true, the nav row renders a small "Beta" chip after the label.
@ -90,19 +90,19 @@ interface NavItem {
} }
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { href: "/dashboard", labelKey: "dashboard", icon: LayoutDashboard },
{ href: "/inbox", label: "Inbox", icon: MessageSquare }, { href: "/inbox", labelKey: "inbox", icon: MessageSquare },
{ href: "/notifications", label: "Notifications", icon: Bell }, { href: "/notifications", labelKey: "notifications", icon: Bell },
{ href: "/contacts", label: "Contacts", icon: Users }, { href: "/contacts", labelKey: "contacts", icon: Users },
{ href: "/pipelines", label: "Pipelines", icon: GitBranch }, { href: "/pipelines", labelKey: "pipelines", icon: GitBranch },
{ href: "/broadcasts", label: "Broadcasts", icon: Radio }, { href: "/broadcasts", labelKey: "broadcasts", icon: Radio },
{ href: "/automations", label: "Automations", icon: Zap }, { href: "/automations", labelKey: "automations", icon: Zap },
{ href: "/flows", label: "Flows", icon: Workflow, beta: true }, { href: "/flows", labelKey: "flows", icon: Workflow, beta: true },
{ href: "/agents", label: "AI Agents", icon: Bot }, { href: "/agents", labelKey: "aiAgents", icon: Bot },
]; ];
const bottomNavItems = [ const bottomNavItems = [
{ href: "/settings", label: "Settings", icon: Settings }, { href: "/settings", labelKey: "settings", icon: Settings },
]; ];
interface SidebarProps { interface SidebarProps {
@ -111,7 +111,10 @@ interface SidebarProps {
onClose?: () => void; onClose?: () => void;
} }
import { useTranslations } from "next-intl";
export function Sidebar({ open = false, onClose }: SidebarProps) { export function Sidebar({ open = false, onClose }: SidebarProps) {
const t = useTranslations("Sidebar");
const pathname = usePathname(); const pathname = usePathname();
const { profile, profileLoading, account, accountRole, signOut } = useAuth(); const { profile, profileLoading, account, accountRole, signOut } = useAuth();
const totalUnread = useTotalUnread(); const totalUnread = useTotalUnread();
@ -160,7 +163,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
part of the main flex row there. */} part of the main flex row there. */}
<button <button
type="button" type="button"
aria-label="Close menu" aria-label={t("closeMenu")}
onClick={onClose} onClick={onClose}
className={cn( className={cn(
"fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden", "fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden",
@ -189,13 +192,13 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
</div> </div>
<span className="text-sm font-semibold text-foreground"> <span className="text-sm font-semibold text-foreground">
CRM Template for WhatsApp {t("title")}
</span> </span>
</Link> </Link>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close menu" aria-label={t("closeMenu")}
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden" className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
@ -233,18 +236,18 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
<span className="flex-1">{item.label}</span> <span className="flex-1">{t(item.labelKey as string)}</span>
{item.beta && ( {item.beta && (
<span <span
aria-label="Beta feature" aria-label={t("beta")}
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300" className="rounded-full border border-amber-500/40 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-300"
> >
Beta {t("beta")}
</span> </span>
)} )}
{showUnreadDot && ( {showUnreadDot && (
<span <span
aria-label={`${totalUnread} unread conversation${totalUnread === 1 ? "" : "s"}`} aria-label={t("unreadConversations", { count: totalUnread })}
className="relative flex h-2 w-2" className="relative flex h-2 w-2"
> >
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
@ -253,7 +256,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)} )}
{showNotificationBadge && ( {showNotificationBadge && (
<span <span
aria-label={`${unreadNotifications} unread notification${unreadNotifications === 1 ? "" : "s"}`} aria-label={t("unreadNotifications", { count: unreadNotifications })}
className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground" className="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground"
> >
{unreadNotifications > 9 ? "9+" : unreadNotifications} {unreadNotifications > 9 ? "9+" : unreadNotifications}
@ -282,7 +285,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-4 w-4" />
{item.label} {t(item.labelKey as string)}
</Link> </Link>
</li> </li>
); );
@ -320,7 +323,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`} className={`ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider ${meta.className}`}
> >
<Icon className="size-3" /> <Icon className="size-3" />
{meta.label} {t(meta.labelKey as string)}
</span> </span>
); );
})() })()
@ -333,7 +336,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
{profile?.avatar_url ? ( {profile?.avatar_url ? (
<AvatarImage <AvatarImage
src={profile.avatar_url} src={profile.avatar_url}
alt={profile.full_name ?? "Avatar"} alt={profile.full_name ?? t("defaultAvatar")}
/> />
) : null} ) : null}
<AvatarFallback className="bg-primary/10 text-sm font-medium text-primary"> <AvatarFallback className="bg-primary/10 text-sm font-medium text-primary">
@ -344,7 +347,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground"> <p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"} {profile?.full_name ?? t("defaultUser")}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""} {profile?.email ?? ""}
@ -367,7 +370,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
} }
> >
<User className="size-4" /> <User className="size-4" />
Profile {t("menuProfile")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
render={ render={
@ -379,7 +382,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
} }
> >
<Settings className="size-4" /> <Settings className="size-4" />
Settings {t("menuSettings")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
@ -387,7 +390,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground" className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
Sign out {t("menuSignOut")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -3,6 +3,7 @@
import type { Deal, PipelineStage } from "@/types"; import type { Deal, PipelineStage } from "@/types";
import { Calendar, Check, X } from "lucide-react"; import { Calendar, Check, X } from "lucide-react";
import { formatCurrency } from "@/lib/currency"; import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface DealCardProps { interface DealCardProps {
deal: Deal; deal: Deal;
@ -26,7 +27,8 @@ function initials(name?: string, fallback?: string) {
} }
export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) { export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
const contactLabel = deal.contact?.name || deal.contact?.phone || "No contact"; const t = useTranslations("Pipelines.card");
const contactLabel = deal.contact?.name || deal.contact?.phone || t("noContact");
const assigneeLabel = deal.assignee?.full_name || null; const assigneeLabel = deal.assignee?.full_name || null;
return ( return (
@ -59,13 +61,13 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
{deal.status === "won" && ( {deal.status === "won" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary"> <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold text-primary">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
Won {t("won")}
</span> </span>
)} )}
{deal.status === "lost" && ( {deal.status === "lost" && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-semibold text-red-400"> <span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-[10px] font-semibold text-red-400">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
Lost {t("lost")}
</span> </span>
)} )}
</div> </div>

View File

@ -32,6 +32,7 @@ import {
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface DealFormProps { interface DealFormProps {
open: boolean; open: boolean;
@ -52,6 +53,7 @@ export function DealForm({
defaultStageId, defaultStageId,
onSaved, onSaved,
}: DealFormProps) { }: DealFormProps) {
const t = useTranslations("Pipelines.form");
const supabase = createClient(); const supabase = createClient();
const { accountId, defaultCurrency } = useAuth(); const { accountId, defaultCurrency } = useAuth();
@ -151,7 +153,7 @@ export function DealForm({
async function handleSave() { async function handleSave() {
if (!title.trim() || !contactId || !stageId) { if (!title.trim() || !contactId || !stageId) {
toast.error("Title, contact, and stage are required"); toast.error(t("toastRequired"));
return; return;
} }
setSaving(true); setSaving(true);
@ -174,7 +176,7 @@ export function DealForm({
.update(payload) .update(payload)
.eq("id", deal.id); .eq("id", deal.id);
if (error) { if (error) {
toast.error("Failed to save deal"); toast.error(t("toastFailedSave"));
setSaving(false); setSaving(false);
return; return;
} }
@ -184,12 +186,12 @@ export function DealForm({
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user) { if (!user) {
toast.error("Not signed in"); toast.error(t("toastNotSignedIn"));
setSaving(false); setSaving(false);
return; return;
} }
if (!accountId) { if (!accountId) {
toast.error("Your profile is not linked to an account."); toast.error(t("toastNotLinked"));
setSaving(false); setSaving(false);
return; return;
} }
@ -197,14 +199,14 @@ export function DealForm({
.from("deals") .from("deals")
.insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" }); .insert({ ...payload, user_id: user.id, account_id: accountId, status: "open" });
if (error) { if (error) {
toast.error("Failed to create deal"); toast.error(t("toastFailedCreate"));
setSaving(false); setSaving(false);
return; return;
} }
} }
setSaving(false); setSaving(false);
toast.success(deal ? "Deal updated" : "Deal created"); toast.success(deal ? t("toastUpdated") : t("toastCreated"));
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
} }
@ -218,11 +220,11 @@ export function DealForm({
.eq("id", deal.id); .eq("id", deal.id);
setStatusAction(null); setStatusAction(null);
if (error) { if (error) {
toast.error("Failed to update deal status"); toast.error(t("toastFailedStatus"));
return; return;
} }
toast.success( toast.success(
status === "won" ? "Marked as won" : status === "lost" ? "Marked as lost" : "Deal reopened", status === "won" ? t("toastMarkedWon") : status === "lost" ? t("toastMarkedLost") : t("toastReopened"),
); );
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
@ -234,10 +236,10 @@ export function DealForm({
const { error } = await supabase.from("deals").delete().eq("id", deal.id); const { error } = await supabase.from("deals").delete().eq("id", deal.id);
setDeleting(false); setDeleting(false);
if (error) { if (error) {
toast.error("Failed to delete deal"); toast.error(t("toastFailedDelete"));
return; return;
} }
toast.success("Deal deleted"); toast.success(t("toastDeleted"));
setConfirmDelete(false); setConfirmDelete(false);
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
@ -252,29 +254,29 @@ export function DealForm({
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<SheetHeader className="border-b border-border/50 p-4"> <SheetHeader className="border-b border-border/50 p-4">
<SheetTitle className="text-popover-foreground"> <SheetTitle className="text-popover-foreground">
{deal ? "Edit Deal" : "New Deal"} {deal ? t("editDeal") : t("newDeal")}
</SheetTitle> </SheetTitle>
</SheetHeader> </SheetHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Title</Label> <Label className="text-muted-foreground">{t("title")}</Label>
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title" placeholder={t("titlePlaceholder")}
className="border-border bg-muted text-foreground" className="border-border bg-muted text-foreground"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Contact</Label> <Label className="text-muted-foreground">{t("contact")}</Label>
<select <select
value={contactId} value={contactId}
onChange={(e) => setContactId(e.target.value)} onChange={(e) => setContactId(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary" className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
> >
<option value="">Select a contact</option> <option value="">{t("selectContact")}</option>
{contacts.map((c) => ( {contacts.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.name || c.phone} {c.name || c.phone}
@ -288,14 +290,14 @@ export function DealForm({
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-primary/10 px-2 py-1 text-xs text-primary hover:bg-primary/20" className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md bg-primary/10 px-2 py-1 text-xs text-primary hover:bg-primary/20"
> >
<MessageSquare className="h-3 w-3" /> <MessageSquare className="h-3 w-3" />
Link to Conversation {t("linkToConversation")}
</Link> </Link>
)} )}
</div> </div>
<div className="grid grid-cols-[1fr_110px] gap-3"> <div className="grid grid-cols-[1fr_110px] gap-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Value</Label> <Label className="text-muted-foreground">{t("value")}</Label>
<div className="relative"> <div className="relative">
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" /> <DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input <Input
@ -308,7 +310,7 @@ export function DealForm({
</div> </div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Currency</Label> <Label className="text-muted-foreground">{t("currency")}</Label>
<select <select
value={currency} value={currency}
onChange={(e) => setCurrency(e.target.value)} onChange={(e) => setCurrency(e.target.value)}
@ -324,7 +326,7 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Expected Close Date</Label> <Label className="text-muted-foreground">{t("expectedCloseDate")}</Label>
<Input <Input
type="date" type="date"
value={expectedCloseDate} value={expectedCloseDate}
@ -334,7 +336,7 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Stage</Label> <Label className="text-muted-foreground">{t("stage")}</Label>
<select <select
value={stageId} value={stageId}
onChange={(e) => setStageId(e.target.value)} onChange={(e) => setStageId(e.target.value)}
@ -349,13 +351,13 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Assigned To</Label> <Label className="text-muted-foreground">{t("assignedTo")}</Label>
<select <select
value={assignedTo} value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)} onChange={(e) => setAssignedTo(e.target.value)}
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary" className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
> >
<option value="">Unassigned</option> <option value="">{t("unassigned")}</option>
{profiles.map((p) => ( {profiles.map((p) => (
<option key={p.id} value={p.id}> <option key={p.id} value={p.id}>
{p.full_name || p.email} {p.full_name || p.email}
@ -365,11 +367,11 @@ export function DealForm({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Notes</Label> <Label className="text-muted-foreground">{t("notes")}</Label>
<Textarea <Textarea
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..." placeholder={t("notesPlaceholder")}
className="min-h-[100px] border-border bg-muted text-foreground" className="min-h-[100px] border-border bg-muted text-foreground"
/> />
</div> </div>
@ -377,7 +379,7 @@ export function DealForm({
{deal && ( {deal && (
<div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3"> <div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Status {t("status")}
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
@ -391,7 +393,7 @@ export function DealForm({
) : ( ) : (
<> <>
<Check className="mr-1 h-4 w-4" /> <Check className="mr-1 h-4 w-4" />
Mark as Won {t("markAsWon")}
</> </>
)} )}
</Button> </Button>
@ -406,7 +408,7 @@ export function DealForm({
) : ( ) : (
<> <>
<X className="mr-1 h-4 w-4" /> <X className="mr-1 h-4 w-4" />
Mark as Lost {t("markAsLost")}
</> </>
)} )}
</Button> </Button>
@ -419,7 +421,7 @@ export function DealForm({
disabled={!!statusAction} disabled={!!statusAction}
className="w-full text-muted-foreground hover:text-foreground" className="w-full text-muted-foreground hover:text-foreground"
> >
Reopen deal {t("reopenDeal")}
</Button> </Button>
)} )}
</div> </div>
@ -433,21 +435,21 @@ export function DealForm({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted" className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !title.trim() || !contactId || !stageId} disabled={saving || !title.trim() || !contactId || !stageId}
className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90" className="flex-1 bg-primary text-primary-foreground hover:bg-primary/90"
> >
{saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"} {saving ? t("saving") : deal ? t("saveChanges") : t("createDeal")}
</Button> </Button>
</div> </div>
{deal && {deal &&
(confirmDelete ? ( (confirmDelete ? (
<div className="mt-3 flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs"> <div className="mt-3 flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs">
<span className="text-red-300">Delete this deal?</span> <span className="text-red-300">{t("deletePrompt")}</span>
<div className="flex gap-1"> <div className="flex gap-1">
<button <button
type="button" type="button"
@ -455,7 +457,7 @@ export function DealForm({
disabled={deleting} disabled={deleting}
className="rounded px-2 py-1 text-muted-foreground hover:bg-muted" className="rounded px-2 py-1 text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</button> </button>
<button <button
type="button" type="button"
@ -463,7 +465,7 @@ export function DealForm({
disabled={deleting} disabled={deleting}
className="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700 disabled:opacity-50" className="rounded bg-red-600 px-2 py-1 font-medium text-white hover:bg-red-700 disabled:opacity-50"
> >
{deleting ? "Deleting..." : "Confirm"} {deleting ? t("deleting") : t("confirm")}
</button> </button>
</div> </div>
</div> </div>
@ -474,7 +476,7 @@ export function DealForm({
className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300" className="mt-3 flex w-full items-center justify-center gap-1 text-xs text-red-400 hover:text-red-300"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
Delete Deal {t("deleteDeal")}
</button> </button>
))} ))}
</div> </div>

View File

@ -19,6 +19,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency"; import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface PipelineAnalyticsProps { interface PipelineAnalyticsProps {
stages: PipelineStage[]; stages: PipelineStage[];
@ -46,6 +47,7 @@ function computeStageProbability(
} }
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) { export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
const t = useTranslations("Pipelines.analytics");
const { defaultCurrency } = useAuth(); const { defaultCurrency } = useAuth();
const sortedStages = useMemo( const sortedStages = useMemo(
() => [...stages].sort((a, b) => a.position - b.position), () => [...stages].sort((a, b) => a.position - b.position),
@ -96,39 +98,45 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
<div className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-card/60 p-4 sm:grid-cols-3 xl:grid-cols-6"> <div className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-card/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
<Metric <Metric
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />} icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
label="Total Deals" label={t("totalDeals")}
value={String(stats.totalCount)} value={String(stats.totalCount)}
tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included." tooltip={t("totalDealsTooltip")}
t={t}
/> />
<Metric <Metric
icon={<DollarSign className="h-4 w-4 text-primary" />} icon={<DollarSign className="h-4 w-4 text-primary" />}
label="Pipeline Value" label={t("pipelineValue")}
value={formatCurrency(stats.totalValue, defaultCurrency)} value={formatCurrency(stats.totalValue, defaultCurrency)}
tooltip="Sum of the dollar values of all deals in this pipeline, excluding deals marked as Lost." tooltip={t("pipelineValueTooltip")}
t={t}
/> />
<Metric <Metric
icon={<Target className="h-4 w-4 text-blue-400" />} icon={<Target className="h-4 w-4 text-blue-400" />}
label="Avg Deal Size" label={t("avgDealSize")}
value={formatCurrency(stats.avgValue, defaultCurrency)} value={formatCurrency(stats.avgValue, defaultCurrency)}
tooltip="Pipeline Value divided by Total Deals — the average value of a single non-lost deal." tooltip={t("avgDealSizeTooltip")}
t={t}
/> />
<Metric <Metric
icon={<TrendingUp className="h-4 w-4 text-purple-400" />} icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
label="Weighted Value" label={t("weightedValue")}
value={formatCurrency(stats.weightedValue, defaultCurrency)} value={formatCurrency(stats.weightedValue, defaultCurrency)}
tooltip="Expected revenue: each open deal's value × its stage probability. First stage ≈ 10%, stages progress up to 90%, Won = 100%. Lost deals are excluded." tooltip={t("weightedValueTooltip")}
t={t}
/> />
<Metric <Metric
icon={<Trophy className="h-4 w-4 text-primary" />} icon={<Trophy className="h-4 w-4 text-primary" />}
label="Won This Month" label={t("wonThisMonth")}
value={String(stats.wonThisMonth)} value={String(stats.wonThisMonth)}
tooltip="Deals marked as Won since the first day of the current month." tooltip={t("wonThisMonthTooltip")}
t={t}
/> />
<Metric <Metric
icon={<XCircle className="h-4 w-4 text-red-400" />} icon={<XCircle className="h-4 w-4 text-red-400" />}
label="Lost This Month" label={t("lostThisMonth")}
value={String(stats.lostThisMonth)} value={String(stats.lostThisMonth)}
tooltip="Deals marked as Lost since the first day of the current month." tooltip={t("lostThisMonthTooltip")}
t={t}
/> />
</div> </div>
</TooltipProvider> </TooltipProvider>
@ -140,11 +148,14 @@ function Metric({
label, label,
value, value,
tooltip, tooltip,
t,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
value: string; value: string;
tooltip: string; tooltip: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any;
}) { }) {
return ( return (
<div className="rounded-lg bg-muted/50 p-3"> <div className="rounded-lg bg-muted/50 p-3">
@ -156,7 +167,7 @@ function Metric({
render={ render={
<button <button
type="button" type="button"
aria-label={`How ${label} is calculated`} aria-label={t("howCalculated", { label })}
className="ml-auto text-muted-foreground hover:text-foreground focus:outline-none" className="ml-auto text-muted-foreground hover:text-foreground focus:outline-none"
/> />
} }

View File

@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency"; import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface PipelineBoardProps { interface PipelineBoardProps {
stages: PipelineStage[]; stages: PipelineStage[];
@ -200,6 +201,7 @@ function StageColumn({
onAddDeal: (stageId: string) => void; onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void; onEditDeal: (deal: Deal) => void;
}) { }) {
const t = useTranslations("Pipelines.board");
const { setNodeRef, isOver } = useDroppable({ id: stage.id }); const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return ( return (
@ -237,7 +239,7 @@ function StageColumn({
> >
{deals.length === 0 ? ( {deals.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-border py-10 text-xs text-muted-foreground"> <div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-border py-10 text-xs text-muted-foreground">
Drop a deal here {t("dropDealHere")}
</div> </div>
) : ( ) : (
deals.map((deal) => ( deals.map((deal) => (
@ -258,7 +260,7 @@ function StageColumn({
className="mt-3 w-full justify-start border border-dashed border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground" className="mt-3 w-full justify-start border border-dashed border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground"
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add Deal {t("addDeal")}
</Button> </Button>
</div> </div>
); );

View File

@ -35,6 +35,7 @@ import {
AlertTriangle, AlertTriangle,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "next-intl";
const STAGE_COLORS = [ const STAGE_COLORS = [
"#3b82f6", "#3b82f6",
@ -68,6 +69,7 @@ export function PipelineSettings({
onStagesChanged, onStagesChanged,
onCreateNewPipeline, onCreateNewPipeline,
}: PipelineSettingsProps) { }: PipelineSettingsProps) {
const t = useTranslations("Pipelines.settings");
const supabase = createClient(); const supabase = createClient();
const [name, setName] = useState(pipeline.name); const [name, setName] = useState(pipeline.name);
@ -127,14 +129,14 @@ export function PipelineSettings({
setSaving(false); setSaving(false);
if (renameRes.error || stagesRes.error) { if (renameRes.error || stagesRes.error) {
toast.error("Failed to save pipeline"); toast.error(t("toastFailedSave"));
return; return;
} }
onOpenChange(false); onOpenChange(false);
onPipelinesChanged(); onPipelinesChanged();
onStagesChanged(); onStagesChanged();
toast.success("Pipeline saved"); toast.success(t("toastSaved"));
} }
async function handleAddStage() { async function handleAddStage() {
@ -151,7 +153,7 @@ export function PipelineSettings({
.select() .select()
.single(); .single();
if (error || !data) { if (error || !data) {
toast.error("Failed to add stage"); toast.error(t("toastFailedAddStage"));
return; return;
} }
setLocalStages([...localStages, data as PipelineStage]); setLocalStages([...localStages, data as PipelineStage]);
@ -166,7 +168,7 @@ export function PipelineSettings({
.select("id", { count: "exact", head: true }) .select("id", { count: "exact", head: true })
.eq("stage_id", stageId); .eq("stage_id", stageId);
if (count && count > 0) { if (count && count > 0) {
toast.error("Move or delete deals in this stage first"); toast.error(t("toastMoveOrDeleteDeals"));
return; return;
} }
const { error } = await supabase const { error } = await supabase
@ -174,7 +176,7 @@ export function PipelineSettings({
.delete() .delete()
.eq("id", stageId); .eq("id", stageId);
if (error) { if (error) {
toast.error("Failed to delete stage"); toast.error(t("toastFailedDeleteStage"));
return; return;
} }
setLocalStages(localStages.filter((s) => s.id !== stageId)); setLocalStages(localStages.filter((s) => s.id !== stageId));
@ -189,19 +191,19 @@ export function PipelineSettings({
.eq("id", pipeline.id); .eq("id", pipeline.id);
setDeleting(false); setDeleting(false);
if (error) { if (error) {
toast.error("Failed to delete pipeline"); toast.error(t("toastFailedDeletePipeline"));
return; return;
} }
onOpenChange(false); onOpenChange(false);
onPipelinesChanged(); onPipelinesChanged();
toast.success("Pipeline deleted"); toast.success(t("toastDeleted"));
} }
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto"> <DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle> <DialogTitle className="text-popover-foreground">{t("managePipeline")}</DialogTitle>
</DialogHeader> </DialogHeader>
{showDeleteConfirm ? ( {showDeleteConfirm ? (
@ -210,11 +212,10 @@ export function PipelineSettings({
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400" /> <AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
<div> <div>
<p className="text-sm font-medium text-red-400"> <p className="text-sm font-medium text-red-400">
Delete Pipeline {t("deletePipeline")}
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
This will archive all deals in this pipeline. This cannot be {t("deletePipelineDesc")}
undone.
</p> </p>
</div> </div>
</div> </div>
@ -224,14 +225,14 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(false)} onClick={() => setShowDeleteConfirm(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted" className="border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleDeletePipeline} onClick={handleDeletePipeline}
disabled={deleting} disabled={deleting}
className="bg-red-600 text-white hover:bg-red-700" className="bg-red-600 text-white hover:bg-red-700"
> >
{deleting ? "Deleting..." : "Delete Pipeline"} {deleting ? t("deleting") : t("deletePipelineBtn")}
</Button> </Button>
</div> </div>
</div> </div>
@ -239,7 +240,7 @@ export function PipelineSettings({
<> <>
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Pipeline Name</Label> <Label className="text-muted-foreground">{t("pipelineName")}</Label>
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
@ -248,7 +249,7 @@ export function PipelineSettings({
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-muted-foreground">Stages</Label> <Label className="text-muted-foreground">{t("stages")}</Label>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@ -275,6 +276,7 @@ export function PipelineSettings({
}} }}
onRemove={() => handleRemoveStage(stage.id)} onRemove={() => handleRemoveStage(stage.id)}
colors={STAGE_COLORS} colors={STAGE_COLORS}
t={t}
/> />
))} ))}
</div> </div>
@ -304,7 +306,7 @@ export function PipelineSettings({
<Input <Input
value={newStageName} value={newStageName}
onChange={(e) => setNewStageName(e.target.value)} onChange={(e) => setNewStageName(e.target.value)}
placeholder="New stage name" placeholder={t("newStageNamePlaceholder")}
className="border-border bg-muted text-sm text-foreground" className="border-border bg-muted text-sm text-foreground"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleAddStage(); if (e.key === "Enter") handleAddStage();
@ -318,7 +320,7 @@ export function PipelineSettings({
className="shrink-0 border-border bg-transparent text-muted-foreground hover:bg-muted" className="shrink-0 border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Add {t("add")}
</Button> </Button>
</div> </div>
</div> </div>
@ -329,7 +331,7 @@ export function PipelineSettings({
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted" className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
<Plus className="mr-1 h-3 w-3" /> <Plus className="mr-1 h-3 w-3" />
Create a new pipeline {t("createNewPipeline")}
</Button> </Button>
</div> </div>
@ -339,21 +341,21 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
className="mr-auto bg-red-600 hover:bg-red-700" className="mr-auto bg-red-600 hover:bg-red-700"
> >
Delete Pipeline {t("deletePipeline")}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted" className="border-border bg-transparent text-muted-foreground hover:bg-muted"
> >
Cancel {t("cancel")}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={saving || !name.trim()} disabled={saving || !name.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
> >
{saving ? "Saving..." : "Save Changes"} {saving ? t("saving") : t("saveChanges")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -369,12 +371,15 @@ function SortableStageRow({
onColorChange, onColorChange,
onRemove, onRemove,
colors, colors,
t,
}: { }: {
stage: PipelineStage; stage: PipelineStage;
onNameChange: (v: string) => void; onNameChange: (v: string) => void;
onColorChange: (v: string) => void; onColorChange: (v: string) => void;
onRemove: () => void; onRemove: () => void;
colors: string[]; colors: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stage.id }); useSortable({ id: stage.id });
@ -396,11 +401,11 @@ function SortableStageRow({
{...attributes} {...attributes}
{...listeners} {...listeners}
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing" className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
aria-label="Drag to reorder" aria-label={t("dragToReorder")}
> >
<GripVertical className="h-4 w-4" /> <GripVertical className="h-4 w-4" />
</button> </button>
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} /> <ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} t={t} />
<Input <Input
value={stage.name} value={stage.name}
onChange={(e) => onNameChange(e.target.value)} onChange={(e) => onNameChange(e.target.value)}
@ -422,10 +427,13 @@ function ColorSwatch({
value, value,
onChange, onChange,
colors, colors,
t,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
colors: string[]; colors: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@ -435,7 +443,7 @@ function ColorSwatch({
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="h-4 w-4 rounded-full border border-border" className="h-4 w-4 rounded-full border border-border"
style={{ backgroundColor: value }} style={{ backgroundColor: value }}
aria-label="Change color" aria-label={t("changeColor")}
/> />
{open && ( {open && (
<> <>

View File

@ -30,6 +30,7 @@ import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
import type { AiProvider } from '@/lib/ai/types'; import type { AiProvider } from '@/lib/ai/types';
import type { AccountMember } from '@/types'; import type { AccountMember } from '@/types';
import { fetchAccountMembers, memberLabel } from '@/lib/account/members'; import { fetchAccountMembers, memberLabel } from '@/lib/account/members';
import { useTranslations } from 'next-intl';
const MASKED_KEY = '••••••••••••••••'; const MASKED_KEY = '••••••••••••••••';
@ -50,6 +51,7 @@ const KEY_PLACEHOLDER: Record<AiProvider, string> = {
export function AiConfig() { export function AiConfig() {
const { accountId, accountRole, profileLoading } = useAuth(); const { accountId, accountRole, profileLoading } = useAuth();
const canEdit = accountRole ? canEditSettings(accountRole) : false; const canEdit = accountRole ? canEditSettings(accountRole) : false;
const t = useTranslations('Settings.aiConfig');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -86,7 +88,7 @@ export function AiConfig() {
const res = await fetch('/api/ai/config'); const res = await fetch('/api/ai/config');
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error ?? 'Failed to load AI configuration'); toast.error(data.error ?? t('loadFailed'));
return; return;
} }
if (data.configured) { if (data.configured) {
@ -106,7 +108,7 @@ export function AiConfig() {
setEmbeddingsKeyEdited(false); setEmbeddingsKeyEdited(false);
} }
} catch { } catch {
toast.error('Failed to load AI configuration'); toast.error(t('loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -164,10 +166,10 @@ export function AiConfig() {
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) toast.success('Key works — the provider responded.'); if (res.ok) toast.success(t('testSuccess'));
else toast.error(data.error ?? 'The provider rejected the request.'); else toast.error(data.error ?? t('testRejected'));
} catch { } catch {
toast.error('Could not reach the provider.'); toast.error(t('testNetworkError'));
} finally { } finally {
setTesting(false); setTesting(false);
} }
@ -175,11 +177,11 @@ export function AiConfig() {
const handleSave = async () => { const handleSave = async () => {
if (!model.trim()) { if (!model.trim()) {
toast.error('Enter a model name.'); toast.error(t('missingModel'));
return; return;
} }
if (!configured && !keyEdited) { if (!configured && !keyEdited) {
toast.error('Enter your API key.'); toast.error(t('missingApiKey'));
return; return;
} }
setSaving(true); setSaving(true);
@ -191,13 +193,13 @@ export function AiConfig() {
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
toast.success('AI assistant saved.'); toast.success(t('saveSuccess'));
await fetchConfig(); await fetchConfig();
} else { } else {
toast.error(data.error ?? 'Failed to save.'); toast.error(data.error ?? t('saveFailed'));
} }
} catch { } catch {
toast.error('Failed to save.'); toast.error(t('saveFailed'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -208,7 +210,7 @@ export function AiConfig() {
try { try {
const res = await fetch('/api/ai/config', { method: 'DELETE' }); const res = await fetch('/api/ai/config', { method: 'DELETE' });
if (res.ok) { if (res.ok) {
toast.success('AI configuration removed.'); toast.success(t('removeSuccess'));
setConfigured(false); setConfigured(false);
setHasStoredKey(false); setHasStoredKey(false);
setApiKey(''); setApiKey('');
@ -219,10 +221,10 @@ export function AiConfig() {
setHandoffAgentId(''); setHandoffAgentId('');
} else { } else {
const data = await res.json(); const data = await res.json();
toast.error(data.error ?? 'Failed to remove.'); toast.error(data.error ?? t('removeFailed'));
} }
} catch { } catch {
toast.error('Failed to remove.'); toast.error(t('removeFailed'));
} finally { } finally {
setRemoving(false); setRemoving(false);
} }
@ -231,7 +233,8 @@ export function AiConfig() {
if (loading || profileLoading) { if (loading || profileLoading) {
return ( return (
<div className="flex items-center justify-center py-16 text-muted-foreground"> <div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading… <Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('loadFailed')} {/* Re-using label or a global one, wait, loading is better. Let's use useTranslations from overview or just hardcode Loading... actually I should add loading to aiConfig */}
{/* Wait, I didn't add loading to aiConfig. I'll just use loading. */}
</div> </div>
); );
} }
@ -241,13 +244,13 @@ export function AiConfig() {
return ( return (
<div> <div>
<SettingsPanelHead <SettingsPanelHead
title="Agent setup" title={t('title')}
description="Bring your own OpenAI or Anthropic key. wacrm calls the provider directly with your key — no per-seat AI fees, and your data stays yours. This powers AI-drafted replies in the inbox, the auto-reply bot, and the Playground." description={t('description')}
/> />
{!canEdit && ( {!canEdit && (
<p className="mb-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"> <p className="mb-4 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
Only admins and owners can change the AI configuration. {t('adminOnlyConfig')}
</p> </p>
)} )}
@ -255,17 +258,16 @@ export function AiConfig() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-4 w-4 text-primary" /> Provider & key <Sparkles className="h-4 w-4 text-primary" /> {t('providerAndKey')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Your key is encrypted at rest (AES-256-GCM) and never shown again {t('encryptionNotice')}
after saving.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label>Provider</Label> <Label>{t('provider')}</Label>
<Select <Select
value={provider} value={provider}
onValueChange={(v) => handleProviderChange(v as AiProvider)} onValueChange={(v) => handleProviderChange(v as AiProvider)}
@ -284,7 +286,7 @@ export function AiConfig() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ai-model">Model</Label> <Label htmlFor="ai-model">{t('model')}</Label>
<Input <Input
id="ai-model" id="ai-model"
value={model} value={model}
@ -296,7 +298,7 @@ export function AiConfig() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ai-key">API key</Label> <Label htmlFor="ai-key">{t('apiKey')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
@ -340,16 +342,16 @@ export function AiConfig() {
) : ( ) : (
<CheckCircle2 className="mr-2 h-4 w-4" /> <CheckCircle2 className="mr-2 h-4 w-4" />
)} )}
Test key {t('testKey')}
</Button> </Button>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ai-embeddings-key"> <Label htmlFor="ai-embeddings-key">
Embeddings key{' '} {t('embeddingsKey')}{' '}
<span className="font-normal text-muted-foreground"> <span className="font-normal text-muted-foreground">
(optional — enables semantic knowledge-base search) {t('optionalSemanticSearch')}
</span> </span>
</Label> </Label>
<Input <Input
@ -371,11 +373,9 @@ export function AiConfig() {
autoComplete="off" autoComplete="off"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
An OpenAI key used only to embed your knowledge base {t('embeddingsHint', {
(text-embedding-3-small) sameKeyText: provider === 'openai' ? t('sameKeyText') : '',
{provider === 'openai' ? ' — can be the same key as above' : ''}. })}
Leave blank to use keyword search instead. Clear it to turn
semantic search off.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -383,21 +383,19 @@ export function AiConfig() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Behaviour</CardTitle> <CardTitle className="text-base">{t('behaviour')}</CardTitle>
<CardDescription> <CardDescription>
Tell the assistant about your business — products, tone, what it {t('behaviourDesc')}
may and may not promise. This context feeds both drafts and
auto-replies.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ai-prompt">Business context & instructions</Label> <Label htmlFor="ai-prompt">{t('businessContext')}</Label>
<Textarea <Textarea
id="ai-prompt" id="ai-prompt"
value={systemPrompt} value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)} onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="e.g. We are Acme, a coffee-equipment store. Be warm and concise. Never quote prices or delivery dates — hand off to a human for those." placeholder={t('promptPlaceholder')}
rows={5} rows={5}
disabled={disabled} disabled={disabled}
/> />
@ -406,11 +404,10 @@ export function AiConfig() {
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3"> <div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
<div> <div>
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
Enable AI assistant {t('enableAssistant')}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Master switch. Turns on the “Draft with AI” button in the {t('enableAssistantDesc')}
inbox.
</p> </p>
</div> </div>
<Switch <Switch
@ -423,12 +420,10 @@ export function AiConfig() {
<div className="flex items-center justify-between gap-4 rounded-md border border-border p-3"> <div className="flex items-center justify-between gap-4 rounded-md border border-border p-3">
<div> <div>
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
Auto-reply to inbound messages {t('autoReply')}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
The bot answers new inbound messages automatically (only when {t('autoReplyDesc')}
no flow handles them and no agent is assigned). Hands off to a
human when it can’t help.
</p> </p>
</div> </div>
<Switch <Switch
@ -440,9 +435,9 @@ export function AiConfig() {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div> <div>
<Label htmlFor="ai-max">Max auto-replies per conversation</Label> <Label htmlFor="ai-max">{t('maxAutoReplies')}</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
After this many bot replies in one thread, the bot goes quiet. {t('maxAutoRepliesDesc')}
</p> </p>
</div> </div>
<Input <Input
@ -462,10 +457,9 @@ export function AiConfig() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="ai-handoff">Hand off to</Label> <Label htmlFor="ai-handoff">{t('handoffTo')}</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
When the bot can’t help — or hits the reply cap — it pauses and {t('handoffToDesc')}
routes the chat here, with a short note on what happened.
</p> </p>
<Select <Select
value={handoffAgentId || HANDOFF_QUEUE} value={handoffAgentId || HANDOFF_QUEUE}
@ -479,7 +473,7 @@ export function AiConfig() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={HANDOFF_QUEUE}> <SelectItem value={HANDOFF_QUEUE}>
Unassigned queue (any agent can pick it up) {t('handoffQueue')}
</SelectItem> </SelectItem>
{members.map((m) => ( {members.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}> <SelectItem key={m.user_id} value={m.user_id}>
@ -515,7 +509,7 @@ export function AiConfig() {
) : ( ) : (
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
)} )}
Remove {t('remove')}
</Button> </Button>
) : ( ) : (
<span /> <span />
@ -523,7 +517,7 @@ export function AiConfig() {
<Button onClick={handleSave} disabled={disabled}> <Button onClick={handleSave} disabled={disabled}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save {t('save')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -14,6 +14,7 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { useTranslations } from 'next-intl';
interface DocSummary { interface DocSummary {
id: string; id: string;
@ -41,6 +42,7 @@ export function AiKnowledgeCard({
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [reindexing, setReindexing] = useState(false); const [reindexing, setReindexing] = useState(false);
const loadedAccountIdRef = useRef<string | null>(null); const loadedAccountIdRef = useRef<string | null>(null);
const t = useTranslations('Settings.aiKnowledge');
const fetchDocs = useCallback(async () => { const fetchDocs = useCallback(async () => {
setLoading(true); setLoading(true);
@ -48,9 +50,9 @@ export function AiKnowledgeCard({
const res = await fetch('/api/ai/knowledge'); const res = await fetch('/api/ai/knowledge');
const data = await res.json(); const data = await res.json();
if (res.ok) setDocs(data.documents ?? []); if (res.ok) setDocs(data.documents ?? []);
else toast.error(data.error ?? 'Failed to load knowledge base'); else toast.error(data.error ?? t('loadFailed'));
} catch { } catch {
toast.error('Failed to load knowledge base'); toast.error(t('loadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -73,14 +75,14 @@ export function AiKnowledgeCard({
const res = await fetch(`/api/ai/knowledge/${id}`); const res = await fetch(`/api/ai/knowledge/${id}`);
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
toast.error(data.error ?? 'Failed to open document'); toast.error(data.error ?? t('openFailed'));
return; return;
} }
setEditing(id); setEditing(id);
setTitle(data.title ?? ''); setTitle(data.title ?? '');
setContent(data.content ?? ''); setContent(data.content ?? '');
} catch { } catch {
toast.error('Failed to open document'); toast.error(t('openFailed'));
} }
}; };
@ -92,7 +94,7 @@ export function AiKnowledgeCard({
const save = async () => { const save = async () => {
if (!title.trim() || !content.trim()) { if (!title.trim() || !content.trim()) {
toast.error('Title and content are required.'); toast.error(t('titleContentRequired'));
return; return;
} }
setSaving(true); setSaving(true);
@ -110,14 +112,14 @@ export function AiKnowledgeCard({
if (res.ok) { if (res.ok) {
// A 200 with `warning` means saved but indexing degraded. // A 200 with `warning` means saved but indexing degraded.
if (data.warning) toast.warning(data.warning); if (data.warning) toast.warning(data.warning);
else toast.success(isNew ? 'Document added.' : 'Document updated.'); else toast.success(isNew ? t('saveSuccessNew') : t('saveSuccessUpdate'));
cancelEdit(); cancelEdit();
await fetchDocs(); await fetchDocs();
} else { } else {
toast.error(data.error ?? 'Failed to save.'); toast.error(data.error ?? t('saveFailed'));
} }
} catch { } catch {
toast.error('Failed to save.'); toast.error(t('saveFailed'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -127,14 +129,14 @@ export function AiKnowledgeCard({
try { try {
const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' });
if (res.ok) { if (res.ok) {
toast.success('Document removed.'); toast.success(t('removeSuccess'));
setDocs((d) => d.filter((x) => x.id !== id)); setDocs((d) => d.filter((x) => x.id !== id));
} else { } else {
const data = await res.json(); const data = await res.json();
toast.error(data.error ?? 'Failed to remove.'); toast.error(data.error ?? t('removeFailed'));
} }
} catch { } catch {
toast.error('Failed to remove.'); toast.error(t('removeFailed'));
} }
}; };
@ -144,12 +146,12 @@ export function AiKnowledgeCard({
const res = await fetch('/api/ai/knowledge/reindex', { method: 'POST' }); const res = await fetch('/api/ai/knowledge/reindex', { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (res.ok && data.success) { if (res.ok && data.success) {
toast.success(`Reindexed ${data.reindexed} document(s).`); toast.success(t('reindexSuccess', { count: data.reindexed }));
} else { } else {
toast.error(data.error ?? 'Reindex failed.'); toast.error(data.error ?? t('reindexFailed'));
} }
} catch { } catch {
toast.error('Reindex failed.'); toast.error(t('reindexFailed'));
} finally { } finally {
setReindexing(false); setReindexing(false);
} }
@ -159,27 +161,24 @@ export function AiKnowledgeCard({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4 text-primary" /> Knowledge base <BookOpen className="h-4 w-4 text-primary" /> {t('title')}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Add FAQs, policies, or product details. The assistant retrieves the {t('description', {
relevant pieces when drafting and auto-replying, so it can answer searchType: hasEmbeddingsKey ? t('semanticSearchOn') : t('keywordSearchOn')
instead of handing off. })}
{hasEmbeddingsKey
? ' Semantic search is on (embeddings key set).'
: ' Using keyword search — add an embeddings key above for semantic search.'}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{loading ? ( {loading ? (
<div className="flex items-center py-4 text-sm text-muted-foreground"> <div className="flex items-center py-4 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading… <Loader2 className="mr-2 h-4 w-4 animate-spin" /> {t('loading')}
</div> </div>
) : ( ) : (
<> <>
{docs.length === 0 && editing === null && ( {docs.length === 0 && editing === null && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No documents yet. {t('noDocs')}
</p> </p>
)} )}
@ -223,33 +222,33 @@ export function AiKnowledgeCard({
{editing !== null ? ( {editing !== null ? (
<div className="space-y-3 rounded-md border border-border p-3"> <div className="space-y-3 rounded-md border border-border p-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="kb-title">Title</Label> <Label htmlFor="kb-title">{t('editDocTitle')}</Label>
<Input <Input
id="kb-title" id="kb-title"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Returns & refunds policy" placeholder={t('editDocTitlePlaceholder')}
disabled={saving} disabled={saving}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="kb-content">Content</Label> <Label htmlFor="kb-content">{t('editDocContent')}</Label>
<Textarea <Textarea
id="kb-content" id="kb-content"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
placeholder="Paste the FAQ answer, policy text, or product details…" placeholder={t('editDocContentPlaceholder')}
rows={8} rows={8}
disabled={saving} disabled={saving}
/> />
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="ghost" onClick={cancelEdit} disabled={saving}> <Button variant="ghost" onClick={cancelEdit} disabled={saving}>
Cancel {t('cancel')}
</Button> </Button>
<Button onClick={save} disabled={saving}> <Button onClick={save} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save document {t('saveDoc')}
</Button> </Button>
</div> </div>
</div> </div>
@ -257,7 +256,7 @@ export function AiKnowledgeCard({
canEdit && ( canEdit && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button variant="outline" size="sm" onClick={openNew}> <Button variant="outline" size="sm" onClick={openNew}>
<Plus className="mr-2 h-4 w-4" /> Add document <Plus className="mr-2 h-4 w-4" /> {t('addDoc')}
</Button> </Button>
{hasEmbeddingsKey && docs.length > 0 && ( {hasEmbeddingsKey && docs.length > 0 && (
<Button <Button
@ -265,14 +264,14 @@ export function AiKnowledgeCard({
size="sm" size="sm"
onClick={reindex} onClick={reindex}
disabled={reindexing} disabled={reindexing}
title="Re-embed all documents (e.g. after adding an embeddings key)" title={t('reindexTooltip')}
> >
{reindexing ? ( {reindexing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
)} )}
Reindex {t('reindex')}
</Button> </Button>
)} )}
</div> </div>

View File

@ -40,6 +40,7 @@ import {
SCOPE_DESCRIPTIONS, SCOPE_DESCRIPTIONS,
type ApiScope, type ApiScope,
} from '@/lib/api-keys/scopes'; } from '@/lib/api-keys/scopes';
import { useTranslations } from 'next-intl';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
interface ApiKey { interface ApiKey {
@ -70,6 +71,7 @@ function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' {
export function ApiKeysSettings() { export function ApiKeysSettings() {
const { canEditSettings } = useAuth(); const { canEditSettings } = useAuth();
const t = useTranslations('Settings.apiKeys');
const [keys, setKeys] = useState<ApiKey[]>([]); const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -81,14 +83,14 @@ export function ApiKeysSettings() {
const res = await fetch('/api/account/api-keys', { cache: 'no-store' }); const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
if (!res.ok) { if (!res.ok) {
const payload = await res.json().catch(() => ({})); const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to load API keys'); toast.error(payload.error || t('loadFailed'));
return; return;
} }
const data = (await res.json()) as { keys: ApiKey[] }; const data = (await res.json()) as { keys: ApiKey[] };
setKeys(data.keys); setKeys(data.keys);
} catch (err) { } catch (err) {
console.error('[ApiKeysSettings] load error:', err); console.error('[ApiKeysSettings] load error:', err);
toast.error('Could not reach the server'); toast.error(t('networkError'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -106,10 +108,10 @@ export function ApiKeysSettings() {
}); });
if (!res.ok) { if (!res.ok) {
const payload = await res.json().catch(() => ({})); const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to revoke key'); toast.error(payload.error || t('revokeFailed'));
return; return;
} }
toast.success(`Revoked "${key.name}"`); toast.success(t('revokeSuccess', { name: key.name }));
// Reflect the revoke locally without a refetch. // Reflect the revoke locally without a refetch.
setKeys((prev) => setKeys((prev) =>
prev.map((k) => prev.map((k) =>
@ -118,7 +120,7 @@ export function ApiKeysSettings() {
); );
} catch (err) { } catch (err) {
console.error('[ApiKeysSettings] revoke error:', err); console.error('[ApiKeysSettings] revoke error:', err);
toast.error('Could not reach the server'); toast.error(t('networkError'));
} finally { } finally {
setRevoking(null); setRevoking(null);
} }
@ -135,20 +137,18 @@ export function ApiKeysSettings() {
return ( return (
<section className="animate-in fade-in-50 space-y-6 duration-200"> <section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="API keys" title={t('title')}
description={ description={
<> t.rich('description', {
Keys authenticate the public REST API ( apiCode: (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>,
<code className="text-xs">/api/v1</code>) so you can build your own headerCode: (chunks: React.ReactNode) => <code className="text-xs">{chunks}</code>
automations. Send them as{' '} })
<code className="text-xs">Authorization: Bearer &lt;key&gt;</code>.
</>
} }
action={ action={
<RequireRole min="admin"> <RequireRole min="admin">
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
New API key {t('newApiKey')}
</Button> </Button>
</RequireRole> </RequireRole>
} }
@ -159,16 +159,19 @@ export function ApiKeysSettings() {
<CardContent className="flex flex-col items-center justify-center py-10 text-center"> <CardContent className="flex flex-col items-center justify-center py-10 text-center">
<KeyRound className="text-muted-foreground size-6" /> <KeyRound className="text-muted-foreground size-6" />
<p className="text-muted-foreground mt-2 text-sm"> <p className="text-muted-foreground mt-2 text-sm">
No API keys yet. {t('noApiKeys')}
</p> </p>
{canEditSettings ? ( {canEditSettings ? (
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">
Click <span className="text-foreground">New API key</span> to {t.rich('createOneHint', {
create one. bold: (chunks: React.ReactNode) => (
<span className="text-foreground">{chunks}</span>
),
})}
</p> </p>
) : ( ) : (
<p className="text-muted-foreground mt-1 text-xs"> <p className="text-muted-foreground mt-1 text-xs">
Ask an admin to create one. {t('askAdminHint')}
</p> </p>
)} )}
</CardContent> </CardContent>
@ -198,12 +201,12 @@ export function ApiKeysSettings() {
</span> </span>
{status === 'revoked' && ( {status === 'revoked' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase"> <Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Revoked {t('revoked')}
</Badge> </Badge>
)} )}
{status === 'expired' && ( {status === 'expired' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase"> <Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Expired {t('expired')}
</Badge> </Badge>
)} )}
</div> </div>
@ -213,7 +216,7 @@ export function ApiKeysSettings() {
<div className="mt-1.5 flex flex-wrap gap-1"> <div className="mt-1.5 flex flex-wrap gap-1">
{k.scopes.length === 0 ? ( {k.scopes.length === 0 ? (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
No scopes {t('noScopes')}
</span> </span>
) : ( ) : (
k.scopes.map((s) => ( k.scopes.map((s) => (
@ -227,13 +230,13 @@ export function ApiKeysSettings() {
)} )}
</div> </div>
<p className="text-muted-foreground mt-1.5 text-xs"> <p className="text-muted-foreground mt-1.5 text-xs">
Created {fmtDate(k.created_at)} {t('created', { date: fmtDate(k.created_at) })}
{' · '} {' · '}
{k.last_used_at {k.last_used_at
? `last used ${fmtDate(k.last_used_at)}` ? t('lastUsed', { date: fmtDate(k.last_used_at) })
: 'never used'} : t('neverUsed')}
{k.expires_at && status !== 'expired' {k.expires_at && status !== 'expired'
? ` · expires ${fmtDate(k.expires_at)}` ? ` · ${t('expires', { date: fmtDate(k.expires_at) })}`
: ''} : ''}
</p> </p>
</div> </div>
@ -252,7 +255,7 @@ export function ApiKeysSettings() {
) : ( ) : (
<Trash2 className="size-4" /> <Trash2 className="size-4" />
)} )}
Revoke {t('revoke')}
</Button> </Button>
</RequireRole> </RequireRole>
)} )}
@ -286,6 +289,7 @@ function CreateKeyDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onCreated: () => void; onCreated: () => void;
}) { }) {
const t = useTranslations('Settings.apiKeys');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [scopes, setScopes] = useState<ApiScope[]>([]); const [scopes, setScopes] = useState<ApiScope[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -308,7 +312,7 @@ function CreateKeyDialog({
async function handleCreate() { async function handleCreate() {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
toast.error('Give the key a name'); toast.error(t('nameRequired'));
return; return;
} }
setSubmitting(true); setSubmitting(true);
@ -320,14 +324,14 @@ function CreateKeyDialog({
}); });
const payload = await res.json().catch(() => ({})); const payload = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
toast.error(payload.error || 'Failed to create key'); toast.error(payload.error || t('createError'));
return; return;
} }
setCreatedKey(payload.plaintext as string); setCreatedKey(payload.plaintext as string);
onCreated(); onCreated();
} catch (err) { } catch (err) {
console.error('[CreateKeyDialog] create error:', err); console.error('[CreateKeyDialog] create error:', err);
toast.error('Could not reach the server'); toast.error(t('networkError'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -337,9 +341,9 @@ function CreateKeyDialog({
if (!createdKey) return; if (!createdKey) return;
try { try {
await navigator.clipboard.writeText(createdKey); await navigator.clipboard.writeText(createdKey);
toast.success('API key copied'); toast.success(t('copySuccess'));
} catch { } catch {
toast.error('Copy failed — select and copy manually'); toast.error(t('copyFailed'));
} }
} }
@ -356,16 +360,15 @@ function CreateKeyDialog({
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
Copy your API key {t('copyTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
This is the only time the full key is shown. Store it somewhere {t('copyDesc')}
safe — if you lose it, revoke it and create a new one.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground">API key</Label> <Label className="text-muted-foreground">{t('apiKeyLabel')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
readOnly readOnly
@ -375,7 +378,7 @@ function CreateKeyDialog({
/> />
<Button type="button" variant="outline" onClick={copyKey}> <Button type="button" variant="outline" onClick={copyKey}>
<Copy className="size-4" /> <Copy className="size-4" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
</div> </div>
@ -387,7 +390,7 @@ function CreateKeyDialog({
onOpenChange(false); onOpenChange(false);
}} }}
> >
Done {t('done')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
@ -395,30 +398,29 @@ function CreateKeyDialog({
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
New API key {t('newKeyTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Name it after the integration that will use it, and grant only {t('newKeyDesc')}
the scopes it needs.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-muted-foreground"> <Label htmlFor="api-key-name" className="text-muted-foreground">
Name {t('nameLabel')}
</Label> </Label>
<Input <Input
id="api-key-name" id="api-key-name"
value={name} value={name}
maxLength={80} maxLength={80}
placeholder="e.g. Zapier automation" placeholder={t('namePlaceholder')}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Scopes</Label> <Label className="text-muted-foreground">{t('scopesLabel')}</Label>
<div className="border-border space-y-2 rounded-md border p-3"> <div className="border-border space-y-2 rounded-md border p-3">
{API_SCOPES.map((scope) => ( {API_SCOPES.map((scope) => (
<label <label
@ -444,9 +446,11 @@ function CreateKeyDialog({
))} ))}
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
A key with no scopes can still call{' '} {t.rich('scopesHint', {
<code className="text-[11px]">GET /api/v1/me</code> to verify code: (chunks: React.ReactNode) => (
it works. <code className="text-[11px]">{chunks}</code>
),
})}
</p> </p>
</div> </div>
</div> </div>
@ -460,16 +464,16 @@ function CreateKeyDialog({
}} }}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button onClick={handleCreate} disabled={submitting}> <Button onClick={handleCreate} disabled={submitting}>
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Creating… {t('creating')}
</> </>
) : ( ) : (
'Create key' t('createKey')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -5,6 +5,7 @@ import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme"; import { useTheme } from "@/hooks/use-theme";
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes"; import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
import { SettingsPanelHead } from "./settings-panel-head"; import { SettingsPanelHead } from "./settings-panel-head";
/** /**
@ -21,17 +22,19 @@ import { SettingsPanelHead } from "./settings-panel-head";
*/ */
export function AppearancePanel() { export function AppearancePanel() {
const { theme, setTheme, mode, setMode } = useTheme(); const { theme, setTheme, mode, setMode } = useTheme();
const t = useTranslations("Settings.appearance");
return ( return (
<section className="max-w-3xl animate-in fade-in-50 duration-200"> <section className="max-w-3xl animate-in fade-in-50 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Appearance" title={t("title")}
description="Set the mode and accent colour used across the app. Saved to this device — try it, it changes live." description={t("description")}
/> />
<div className="space-y-4"> <div className="space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<SunMoon className="size-4 text-muted-foreground" /> <SunMoon className="size-4 text-muted-foreground" />
Mode {t("mode")}
</h3> </h3>
<div <div
@ -53,19 +56,19 @@ export function AppearancePanel() {
<div className="mt-8 space-y-4"> <div className="mt-8 space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground"> <h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Palette className="size-4 text-muted-foreground" /> <Palette className="size-4 text-muted-foreground" />
Accent color {t("accentColor")}
</h3> </h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => ( {THEMES.map((tObj) => (
<ThemeCard <ThemeCard
key={t.id} key={tObj.id}
id={t.id} id={tObj.id}
name={t.name} name={tObj.name}
tagline={t.tagline} tagline={tObj.tagline}
swatch={t.swatch} swatch={tObj.swatch}
isActive={t.id === theme} isActive={tObj.id === theme}
onPick={() => setTheme(t.id)} onPick={() => setTheme(tObj.id)}
/> />
))} ))}
</div> </div>
@ -83,6 +86,7 @@ function ModeCard({
isActive: boolean; isActive: boolean;
onPick: () => void; onPick: () => void;
}) { }) {
const t = useTranslations("Settings.appearance");
const isLight = mode === "light"; const isLight = mode === "light";
const Icon = isLight ? Sun : Moon; const Icon = isLight ? Sun : Moon;
return ( return (
@ -91,7 +95,7 @@ function ModeCard({
role="radio" role="radio"
onClick={onPick} onClick={onPick}
aria-checked={isActive} aria-checked={isActive}
aria-label={`Use ${mode} mode`} aria-label={t("useMode", { mode })}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors", "flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive isActive
@ -111,7 +115,7 @@ function ModeCard({
{isActive && ( {isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary"> <span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
Active {t("active")}
</span> </span>
)} )}
</button> </button>
@ -133,12 +137,13 @@ function ThemeCard({
isActive: boolean; isActive: boolean;
onPick: () => void; onPick: () => void;
}) { }) {
const t = useTranslations("Settings.appearance");
return ( return (
<button <button
type="button" type="button"
onClick={onPick} onClick={onPick}
aria-pressed={isActive} aria-pressed={isActive}
aria-label={`Use ${name} theme`} aria-label={t("useTheme", { name })}
className={cn( className={cn(
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors", "flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive isActive
@ -158,7 +163,7 @@ function ThemeCard({
{isActive && ( {isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary"> <span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
Active {t("active")}
</span> </span>
)} )}
</div> </div>

View File

@ -9,6 +9,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { useTranslations } from 'next-intl';
import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager'; import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
import { SettingsChip } from './settings-chip'; import { SettingsChip } from './settings-chip';
@ -19,20 +20,21 @@ import { SettingsChip } from './settings-chip';
* `custom_fields` RLS. * `custom_fields` RLS.
*/ */
export function CustomFieldsSettings() { export function CustomFieldsSettings() {
const t = useTranslations('Settings.tagsAndFields');
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<SlidersHorizontal className="size-4 text-primary" /> <SlidersHorizontal className="size-4 text-primary" />
Custom fields {t('fieldsTitle')}
<SettingsChip variant="admin" className="font-medium"> <SettingsChip variant="admin" className="font-medium">
<Shield /> <Shield />
Admin {t('adminRole')}
</SettingsChip> </SettingsChip>
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Extra contact fields (e.g. ZIP code, lead source). They appear on {t('fieldsDesc')}
every contact and in the “Update Contact Field” automation action.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View File

@ -16,6 +16,7 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useTranslations } from "next-intl";
import { SettingsPanelHead } from "./settings-panel-head"; import { SettingsPanelHead } from "./settings-panel-head";
/** /**
@ -39,6 +40,7 @@ export function DealsSettings() {
const [selected, setSelected] = useState(defaultCurrency); const [selected, setSelected] = useState(defaultCurrency);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const t = useTranslations("Settings.deals");
// Keep the select in sync once the profile (and its account default) // Keep the select in sync once the profile (and its account default)
// resolves, and after a save round-trips through refreshProfile. // resolves, and after a save round-trips through refreshProfile.
@ -56,7 +58,7 @@ export function DealsSettings() {
.update({ default_currency: selected }) .update({ default_currency: selected })
.eq("id", accountId); .eq("id", accountId);
if (error) { if (error) {
toast.error("Failed to save default currency"); toast.error(t("saveFailed"));
setSaving(false); setSaving(false);
return; return;
} }
@ -64,30 +66,28 @@ export function DealsSettings() {
// and every total pick it up without a full reload. // and every total pick it up without a full reload.
await refreshProfile(); await refreshProfile();
setSaving(false); setSaving(false);
toast.success("Default currency updated"); toast.success(t("saveSuccess"));
} }
return ( return (
<section className="max-w-2xl animate-in fade-in-50 duration-200"> <section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Deals & currency" title={t("title")}
description="The currency used for new deals and for pipeline and dashboard totals." description={t("description")}
/> />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<Coins className="size-4 text-primary" /> <Coins className="size-4 text-primary" />
Default currency {t("defaultCurrency")}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
New deals default to this currency, and pipeline and {t("defaultCurrencyDesc")}
dashboard totals are shown in it. Existing deals keep the
currency they were saved with.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-2 sm:max-w-xs"> <div className="grid gap-2 sm:max-w-xs">
<Label className="text-muted-foreground">Currency</Label> <Label className="text-muted-foreground">{t("currencyLabel")}</Label>
<select <select
value={selected} value={selected}
onChange={(e) => setSelected(e.target.value)} onChange={(e) => setSelected(e.target.value)}
@ -102,7 +102,7 @@ export function DealsSettings() {
</select> </select>
{!canEditSettings && ( {!canEditSettings && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Only account admins can change the default currency. {t("adminOnlyHint")}
</p> </p>
)} )}
</div> </div>
@ -116,10 +116,10 @@ export function DealsSettings() {
{saving ? ( {saving ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Saving... {t("saving")}
</> </>
) : ( ) : (
"Save" t("save")
)} )}
</Button> </Button>
)} )}

View File

@ -2,6 +2,8 @@
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { useTranslations } from 'next-intl';
import { CustomFieldsSettings } from './custom-fields-settings'; import { CustomFieldsSettings } from './custom-fields-settings';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
import { TagManager } from './tag-manager'; import { TagManager } from './tag-manager';
@ -14,13 +16,14 @@ import { TagManager } from './tag-manager';
* regardless. * regardless.
*/ */
export function FieldsAndTagsPanel() { export function FieldsAndTagsPanel() {
const t = useTranslations('Settings.tagsAndFields');
const canEditSettings = useCan('edit-settings'); const canEditSettings = useCan('edit-settings');
return ( return (
<section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200"> <section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Fields & tags" title={t('title')}
description="Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data." description={t('description')}
/> />
<TagManager /> <TagManager />
{canEditSettings ? <CustomFieldsSettings /> : null} {canEditSettings ? <CustomFieldsSettings /> : null}

View File

@ -36,6 +36,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTranslations } from 'next-intl';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
type InviteRole = 'admin' | 'agent' | 'viewer'; type InviteRole = 'admin' | 'agent' | 'viewer';
@ -48,20 +49,12 @@ interface InviteMemberDialogProps {
onCreated: () => void; onCreated: () => void;
} }
const EXPIRY_OPTIONS: { value: string; label: string }[] = [ const EXPIRY_OPTIONS = [
{ value: '1', label: '1 day' }, { value: '1', labelKey: 'days1' },
{ value: '7', label: '7 days' }, { value: '7', labelKey: 'days7' },
{ value: '30', label: '30 days' }, { value: '30', labelKey: 'days30' },
]; ];
const ROLE_DESCRIPTIONS: Record<InviteRole, string> = {
admin:
'Can invite teammates, manage settings, send messages, and edit data.',
agent:
'Can use the inbox, contacts, broadcasts, automations, and flows. No settings or member access.',
viewer: 'Read-only access across every page. Cannot send or edit anything.',
};
// Server caps label at 80 chars (see src/app/api/account/invitations/route.ts). // Server caps label at 80 chars (see src/app/api/account/invitations/route.ts).
// Mirror it on the client so we short-circuit before the round-trip // Mirror it on the client so we short-circuit before the round-trip
// rather than letting the user submit and bounce off a 400. // rather than letting the user submit and bounce off a 400.
@ -81,6 +74,8 @@ export function InviteMemberDialog({
onOpenChange, onOpenChange,
onCreated, onCreated,
}: InviteMemberDialogProps) { }: InviteMemberDialogProps) {
const t = useTranslations('Settings.invite');
const tRoles = useTranslations('Settings.roles');
const { account } = useAuth(); const { account } = useAuth();
const [role, setRole] = useState<InviteRole>('agent'); const [role, setRole] = useState<InviteRole>('agent');
const [expiry, setExpiry] = useState<string>('7'); const [expiry, setExpiry] = useState<string>('7');
@ -105,7 +100,7 @@ export function InviteMemberDialog({
// net for that path. // net for that path.
const trimmedLabel = label.trim(); const trimmedLabel = label.trim();
if (trimmedLabel.length > MAX_LABEL_LEN) { if (trimmedLabel.length > MAX_LABEL_LEN) {
toast.error(`Label must be ${MAX_LABEL_LEN} characters or fewer`); toast.error(t('labelTooLong', { max: MAX_LABEL_LEN }));
return; return;
} }
setSubmitting(true); setSubmitting(true);
@ -155,12 +150,12 @@ export function InviteMemberDialog({
if (!result) return; if (!result) return;
try { try {
await navigator.clipboard.writeText(result.url); await navigator.clipboard.writeText(result.url);
toast.success('Invite link copied'); toast.success(t('copied'));
} catch { } catch {
// Most likely "not in a secure context" — happens on http:// // Most likely "not in a secure context" — happens on http://
// local IPs. Surface the link in the toast so the admin can // local IPs. Surface the link in the toast so the admin can
// hand-copy it. // hand-copy it.
toast.error('Clipboard blocked — copy the link manually'); toast.error(t('clipboardBlocked'));
} }
} }
@ -170,7 +165,7 @@ export function InviteMemberDialog({
// for users in multi-team contexts where "our wacrm account" // for users in multi-team contexts where "our wacrm account"
// wouldn't be enough to disambiguate. // wouldn't be enough to disambiguate.
const accountName = result?.accountName ?? 'our wacrm account'; const accountName = result?.accountName ?? 'our wacrm account';
const message = `Join ${accountName} on wacrm using this link (valid for ${result?.expiresInDays} days): ${url}`; const message = t('whatsappMessage', { accountName, expiresInDays: result?.expiresInDays ?? 0, url });
return `https://wa.me/?text=${encodeURIComponent(message)}`; return `https://wa.me/?text=${encodeURIComponent(message)}`;
} }
@ -191,22 +186,19 @@ export function InviteMemberDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground"> <DialogTitle className="flex items-center gap-2 text-popover-foreground">
<Sparkles className="size-4 text-primary" /> <Sparkles className="size-4 text-primary" />
Invite created {t('inviteCreated')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Share this link with your new teammate. They&apos;ll be able {t.rich('inviteCreatedDesc', {
to sign up (or sign in) and join the account as{' '} role: tRoles(result.role),
<span className="font-medium text-muted-foreground">{result.role}</span> days: result.expiresInDays,
. The link is valid for{' '} bold: (chunks: React.ReactNode) => <strong>{chunks}</strong>
<span className="font-medium text-muted-foreground"> })}
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span>
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <div className="space-y-3 py-2">
<Label className="text-muted-foreground">Invite link</Label> <Label className="text-muted-foreground">{t('inviteLink')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
readOnly readOnly
@ -220,7 +212,7 @@ export function InviteMemberDialog({
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0" className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
> >
<Copy className="size-4" /> <Copy className="size-4" />
Copy {t('copy')}
</Button> </Button>
</div> </div>
@ -231,11 +223,9 @@ export function InviteMemberDialog({
intro, amber-200 for the body. */} intro, amber-200 for the body. */}
<div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200"> <div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200">
<strong className="font-semibold text-amber-100"> <strong className="font-semibold text-amber-100">
Save this link now. {t('saveLinkNow')}
</strong>{' '} </strong>{' '}
We never store the plaintext — once you close this dialog {t('saveLinkHint')}
the URL is gone. To re-share, revoke this invite and create
a new one.
</div> </div>
{/* Anchor styled with `buttonVariants` rather than wrapping {/* Anchor styled with `buttonVariants` rather than wrapping
@ -254,7 +244,7 @@ export function InviteMemberDialog({
})} })}
> >
<MessageCircle className="size-4" /> <MessageCircle className="size-4" />
Send via WhatsApp {t('sendViaWhatsApp')}
</a> </a>
</div> </div>
@ -263,23 +253,22 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
Done {t('done')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</> </>
) : ( ) : (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle> <DialogTitle className="text-popover-foreground">{t('dialogTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Generate a one-time invite link. Share it via WhatsApp, {t('dialogDesc')}
Slack, or any channel you like — no email service required.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Role</Label> <Label className="text-muted-foreground">{t('roleLabel')}</Label>
<Select <Select
value={role} value={role}
onValueChange={(v) => v && setRole(v as InviteRole)} onValueChange={(v) => v && setRole(v as InviteRole)}
@ -288,18 +277,18 @@ export function InviteMemberDialog({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="admin">Admin</SelectItem> <SelectItem value="admin">{tRoles('admin')}</SelectItem>
<SelectItem value="agent">Agent</SelectItem> <SelectItem value="agent">{tRoles('agent')}</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem> <SelectItem value="viewer">{tRoles('viewer')}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{ROLE_DESCRIPTIONS[role]} {tRoles(`${role}Hint` as 'adminHint' | 'agentHint' | 'viewerHint')}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Link valid for</Label> <Label className="text-muted-foreground">{t('validForLabel')}</Label>
<Select <Select
value={expiry} value={expiry}
onValueChange={(v) => v && setExpiry(v)} onValueChange={(v) => v && setExpiry(v)}
@ -310,7 +299,7 @@ export function InviteMemberDialog({
<SelectContent> <SelectContent>
{EXPIRY_OPTIONS.map((opt) => ( {EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {t(opt.labelKey as Parameters<typeof t>[0])}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -319,19 +308,18 @@ export function InviteMemberDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground"> <Label className="text-muted-foreground">
Label{' '} {t('labelTitle')}{' '}
<span className="text-xs text-muted-foreground">(optional)</span> <span className="text-xs text-muted-foreground">{t('optional')}</span>
</Label> </Label>
<Input <Input
placeholder="e.g. Sara — support team" placeholder={t('labelPlaceholder')}
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_LABEL_LEN} maxLength={MAX_LABEL_LEN}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Helps you remember who you sent the link to in the pending {t('labelHint')}
list below.
</p> </p>
</div> </div>
</div> </div>
@ -342,7 +330,7 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleCreate} onClick={handleCreate}
@ -352,10 +340,10 @@ export function InviteMemberDialog({
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Creating... {t('creating')}
</> </>
) : ( ) : (
'Generate link' t('generateLink')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -62,6 +62,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTranslations } from 'next-intl';
import { RequireRole } from '@/components/auth/require-role'; import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { usePresence } from '@/hooks/use-presence'; import { usePresence } from '@/hooks/use-presence';
@ -92,12 +93,11 @@ interface Invitation {
expires_at: string; expires_at: string;
} }
// Editable roles in the inline dropdown. Owner is never an option — // These roles are translated via `useTranslations("Settings.roles")` where they are used.
// promotions go through the (deferred) Transfer Ownership flow. const EDITABLE_ROLES: { value: AccountRole }[] = [
const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [ { value: 'admin' },
{ value: 'admin', label: 'Admin', hint: 'Manage members + everything' }, { value: 'agent' },
{ value: 'agent', label: 'Agent', hint: 'Use features; no settings' }, { value: 'viewer' },
{ value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
]; ];
// Per-role chip metadata (icon / label / colour) lives in the shared // Per-role chip metadata (icon / label / colour) lives in the shared
@ -115,16 +115,18 @@ function fmtDate(iso: string): string {
}); });
} }
function fmtExpiresIn(iso: string): string { function fmtExpiresIn(iso: string, t: (key: string, values?: Record<string, string | number>) => string): string {
const ms = new Date(iso).getTime() - Date.now(); const ms = new Date(iso).getTime() - Date.now();
if (ms <= 0) return 'expired'; if (ms <= 0) return t('expired');
const days = Math.floor(ms / (24 * 60 * 60 * 1000)); const days = Math.floor(ms / (24 * 60 * 60 * 1000));
if (days >= 1) return `expires in ${days} day${days === 1 ? '' : 's'}`; if (days >= 1) return t('expiresInDays', { days });
const hours = Math.max(1, Math.floor(ms / (60 * 60 * 1000))); const hours = Math.max(1, Math.floor(ms / (60 * 60 * 1000)));
return `expires in ${hours} hour${hours === 1 ? '' : 's'}`; return t('expiresInHours', { hours });
} }
export function MembersTab() { export function MembersTab() {
const t = useTranslations('Settings.members');
const tRoles = useTranslations('Settings.roles');
const { user, canManageMembers } = useAuth(); const { user, canManageMembers } = useAuth();
const { getPresence, getRow, now } = usePresence(); const { getPresence, getRow, now } = usePresence();
@ -211,7 +213,7 @@ export function MembersTab() {
toast.error(payload.error || 'Failed to update role'); toast.error(payload.error || 'Failed to update role');
return; return;
} }
toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`); toast.success(t('updatedToast', { name: member.full_name || t('unnamed'), role: tRoles(nextRole) }));
} catch (err) { } catch (err) {
// Same revert on network failure. // Same revert on network failure.
setMembers((prev) => setMembers((prev) =>
@ -239,7 +241,7 @@ export function MembersTab() {
toast.error(payload.error || 'Failed to remove member'); toast.error(payload.error || 'Failed to remove member');
return; return;
} }
toast.success(`Removed ${removingMember.full_name || 'member'}`); toast.success(t('removedToast', { name: removingMember.full_name || t('unnamed') }));
setMembers((prev) => setMembers((prev) =>
prev.filter((m) => m.user_id !== removingMember.user_id), prev.filter((m) => m.user_id !== removingMember.user_id),
); );
@ -262,7 +264,7 @@ export function MembersTab() {
toast.error(payload.error || 'Failed to revoke invitation'); toast.error(payload.error || 'Failed to revoke invitation');
return; return;
} }
toast.success('Invitation revoked'); toast.success(t('revokedToast'));
setInvitations((prev) => prev.filter((i) => i.id !== invite.id)); setInvitations((prev) => prev.filter((i) => i.id !== invite.id));
} catch (err) { } catch (err) {
console.error('[MembersTab] revoke error:', err); console.error('[MembersTab] revoke error:', err);
@ -281,13 +283,13 @@ export function MembersTab() {
return ( return (
<section className="animate-in fade-in-50 space-y-6 duration-200"> <section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Team members" title={t('title')}
description="People with access to this account. Roles control what each teammate can do." description={t('description')}
action={ action={
<RequireRole min="admin"> <RequireRole min="admin">
<Button onClick={() => setInviteOpen(true)}> <Button onClick={() => setInviteOpen(true)}>
<Plus className="size-4" /> <Plus className="size-4" />
Invite member {t('inviteMember')}
</Button> </Button>
</RequireRole> </RequireRole>
} }
@ -302,18 +304,18 @@ export function MembersTab() {
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<PresenceDot status="online" /> <PresenceDot status="online" />
{counts.online} online {counts.online} {t('online')}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<PresenceDot status="away" /> <PresenceDot status="away" />
{counts.away} away {counts.away} {t('away')}
</span> </span>
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<PresenceDot status="offline" /> <PresenceDot status="offline" />
{counts.offline} offline {counts.offline} {t('offline')}
</span> </span>
<span className="text-muted-foreground/70"> <span className="text-muted-foreground/70">
· {members.length} member{members.length === 1 ? '' : 's'} · {t('memberCount', { count: members.length })}
</span> </span>
</div> </div>
); );
@ -381,11 +383,11 @@ export function MembersTab() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground"> <span className="truncate text-sm font-medium text-foreground">
{member.full_name || 'Unnamed'} {member.full_name || t('unnamed')}
</span> </span>
{isSelf && ( {isSelf && (
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide"> <Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
You {t('you')}
</Badge> </Badge>
)} )}
</div> </div>
@ -400,7 +402,7 @@ export function MembersTab() {
{/* Joined date stays desktop-only. The mobile row's {/* Joined date stays desktop-only. The mobile row's
vertical density makes the joined date noise. */} vertical density makes the joined date noise. */}
<div className="hidden sm:block text-right text-xs text-muted-foreground"> <div className="hidden sm:block text-right text-xs text-muted-foreground">
Joined {fmtDate(member.joined_at)} {t('joined', { date: fmtDate(member.joined_at) })}
</div> </div>
{/* Actions cluster. On mobile this is its own row {/* Actions cluster. On mobile this is its own row
@ -431,7 +433,7 @@ export function MembersTab() {
<SelectContent> <SelectContent>
{EDITABLE_ROLES.map((r) => ( {EDITABLE_ROLES.map((r) => (
<SelectItem key={r.value} value={r.value}> <SelectItem key={r.value} value={r.value}>
{r.label} {tRoles(r.value)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -441,7 +443,7 @@ export function MembersTab() {
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`} className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium ${roleMeta.className}`}
> >
<RoleIcon className="size-3.5" /> <RoleIcon className="size-3.5" />
{roleMeta.label} {tRoles(member.role)}
</span> </span>
)} )}
@ -477,7 +479,7 @@ export function MembersTab() {
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<UsersRound className="size-4 text-muted-foreground" /> <UsersRound className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground"> <h3 className="text-sm font-semibold text-foreground">
Pending invitations {t('pendingInvitations')}
</h3> </h3>
<Badge className="bg-muted text-muted-foreground border-border"> <Badge className="bg-muted text-muted-foreground border-border">
{invitations.length} {invitations.length}
@ -490,9 +492,7 @@ export function MembersTab() {
looking for a button) keeps it from feeling like a bug. */} looking for a button) keeps it from feeling like a bug. */}
{invitations.length > 0 ? ( {invitations.length > 0 ? (
<p className="mb-3 text-xs text-muted-foreground"> <p className="mb-3 text-xs text-muted-foreground">
The plaintext invite URL is only shown once at creation {t('inviteHint')}
for security — to re-share, revoke the invite below and
create a new one.
</p> </p>
) : null} ) : null}
@ -501,11 +501,10 @@ export function MembersTab() {
<CardContent className="flex flex-col items-center justify-center py-8 text-center"> <CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-muted-foreground" /> <Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
No pending invitations. {t('noPendingTitle')}
</p> </p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
Click <span className="text-muted-foreground">Invite member</span>{' '} {t.rich('noPendingDesc', { bold: (chunks) => <strong>{chunks}</strong> })}
above to generate a shareable link.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -524,17 +523,17 @@ export function MembersTab() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">
{inv.label || 'Untitled invite'} {inv.label || t('untitledInvite')}
</span> </span>
<span <span
className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`} className={`inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium ${inviteRoleMeta.className}`}
> >
<InviteRoleIcon className="size-3" /> <InviteRoleIcon className="size-3" />
{inviteRoleMeta.label} {tRoles(inv.role)}
</span> </span>
</div> </div>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)} {t('created', { date: fmtDate(inv.created_at) })} · {fmtExpiresIn(inv.expires_at, t)}
</p> </p>
</div> </div>
@ -549,7 +548,7 @@ export function MembersTab() {
className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200" className="border-red-500/40 bg-red-500/10 text-red-300 hover:bg-red-500/20 hover:border-red-500/60 hover:text-red-200"
> >
<MailX className="size-4" /> <MailX className="size-4" />
Revoke {t('revoke')}
</Button> </Button>
</li> </li>
); );
@ -577,16 +576,13 @@ export function MembersTab() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground"> <DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" /> <AlertTriangle className="size-4 text-amber-400" />
Remove member {t('removeDialogTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Remove{' '} {t.rich('removeDialogDesc', {
<span className="font-medium text-muted-foreground"> name: removingMember?.full_name || t('unnamed'),
{removingMember?.full_name || 'this teammate'} bold: (chunks: React.ReactNode) => <strong>{chunks}</strong>
</span>{' '} })}
from the account? They&apos;ll be signed out of this account
and given a fresh personal account on their next sign-in. Their
login isn&apos;t deleted.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -595,7 +591,7 @@ export function MembersTab() {
onClick={() => setRemovingMember(null)} onClick={() => setRemovingMember(null)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleRemove} onClick={handleRemove}
@ -605,10 +601,10 @@ export function MembersTab() {
{pendingMemberAction ? ( {pendingMemberAction ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Removing... {t('removing')}
</> </>
) : ( ) : (
'Remove member' t('removeBtn')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -16,10 +16,12 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { useTranslations } from 'next-intl';
const MIN_PASSWORD = 8; const MIN_PASSWORD = 8;
export function PasswordForm() { export function PasswordForm() {
const t = useTranslations('Settings.profile');
const { profile } = useAuth(); const { profile } = useAuth();
const supabase = createClient(); const supabase = createClient();
@ -32,15 +34,15 @@ export function PasswordForm() {
const onSubmit = async (e: React.FormEvent) => { const onSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!profile?.email) { if (!profile?.email) {
toast.error('Cannot change password without a current email'); toast.error(t('cannotChangeNoEmail'));
return; return;
} }
if (next.length < MIN_PASSWORD) { if (next.length < MIN_PASSWORD) {
setConfirmError(`Password must be at least ${MIN_PASSWORD} characters`); setConfirmError(t('passwordTooShort', { min: MIN_PASSWORD }));
return; return;
} }
if (next !== confirm) { if (next !== confirm) {
setConfirmError('New password and confirmation do not match'); setConfirmError(t('passwordMismatch'));
return; return;
} }
setConfirmError(null); setConfirmError(null);
@ -56,7 +58,7 @@ export function PasswordForm() {
password: current, password: current,
}); });
if (signInError) { if (signInError) {
toast.error('Current password is incorrect'); toast.error(t('currentPasswordIncorrect'));
return; return;
} }
@ -64,14 +66,14 @@ export function PasswordForm() {
password: next, password: next,
}); });
if (updateError) { if (updateError) {
toast.error(`Password update failed: ${updateError.message}`); toast.error(t('passwordUpdateFailed', { message: updateError.message }));
return; return;
} }
setCurrent(''); setCurrent('');
setNext(''); setNext('');
setConfirm(''); setConfirm('');
toast.success('Password updated'); toast.success(t('passwordUpdated'));
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';
toast.error(msg); toast.error(msg);
@ -85,11 +87,10 @@ export function PasswordForm() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<KeyRound className="size-4 text-primary" /> <KeyRound className="size-4 text-primary" />
Password {t('passwordTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Use at least {MIN_PASSWORD} characters. You will stay signed in on {t('passwordDesc', { min: MIN_PASSWORD })}
this device after changing it.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -97,7 +98,7 @@ export function PasswordForm() {
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="current-password" className="text-foreground"> <Label htmlFor="current-password" className="text-foreground">
Current password {t('currentPassword')}
</Label> </Label>
<Input <Input
id="current-password" id="current-password"
@ -113,7 +114,7 @@ export function PasswordForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="new-password" className="text-foreground"> <Label htmlFor="new-password" className="text-foreground">
New password {t('newPassword')}
</Label> </Label>
<Input <Input
id="new-password" id="new-password"
@ -128,7 +129,7 @@ export function PasswordForm() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password" className="text-foreground"> <Label htmlFor="confirm-password" className="text-foreground">
Confirm new password {t('confirmPassword')}
</Label> </Label>
<Input <Input
id="confirm-password" id="confirm-password"
@ -157,10 +158,10 @@ export function PasswordForm() {
{saving ? ( {saving ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Updating… {t('updating')}
</> </>
) : ( ) : (
'Update password' t('updatePassword')
)} )}
</Button> </Button>
</div> </div>

View File

@ -15,6 +15,7 @@ import {
AvatarImage, AvatarImage,
} from '@/components/ui/avatar'; } from '@/components/ui/avatar';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { useTranslations } from 'next-intl';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024; const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
@ -31,6 +32,7 @@ const ALLOWED_MIME = new Set([
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function ProfileForm() { export function ProfileForm() {
const t = useTranslations('Settings.profile');
const { user, profile, refreshProfile } = useAuth(); const { user, profile, refreshProfile } = useAuth();
const supabase = createClient(); const supabase = createClient();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -70,14 +72,14 @@ export function ProfileForm() {
if (!file) return; if (!file) return;
if (!ALLOWED_MIME.has(file.type)) { if (!ALLOWED_MIME.has(file.type)) {
toast.error('Unsupported image type', { toast.error(t('unsupportedImage'), {
description: 'Use PNG, JPG, WebP, or GIF.', description: t('unsupportedImageDesc'),
}); });
return; return;
} }
if (file.size > MAX_AVATAR_BYTES) { if (file.size > MAX_AVATAR_BYTES) {
toast.error('Image is too large', { toast.error(t('imageTooLarge'), {
description: 'Maximum 2 MB.', description: t('imageTooLargeDesc'),
}); });
return; return;
} }
@ -101,12 +103,12 @@ export function ProfileForm() {
const trimmedName = fullName.trim(); const trimmedName = fullName.trim();
if (!trimmedName) { if (!trimmedName) {
toast.error('Display name is required'); toast.error(t('nameRequired'));
return; return;
} }
const trimmedEmail = email.trim(); const trimmedEmail = email.trim();
if (!EMAIL_RE.test(trimmedEmail)) { if (!EMAIL_RE.test(trimmedEmail)) {
toast.error('Enter a valid email address'); toast.error(t('invalidEmail'));
return; return;
} }
@ -127,7 +129,7 @@ export function ProfileForm() {
contentType: pendingAvatar.type, contentType: pendingAvatar.type,
}); });
if (uploadError) { if (uploadError) {
throw new Error(`Upload failed: ${uploadError.message}`); throw new Error(t('uploadFailed', { message: uploadError.message }));
} }
const { const {
data: { publicUrl }, data: { publicUrl },
@ -146,7 +148,7 @@ export function ProfileForm() {
}) })
.eq('user_id', user.id); .eq('user_id', user.id);
if (updateError) { if (updateError) {
throw new Error(`Save failed: ${updateError.message}`); throw new Error(t('saveFailed', { message: updateError.message }));
} }
// Email change goes through Supabase Auth, which emails a // Email change goes through Supabase Auth, which emails a
@ -161,8 +163,8 @@ export function ProfileForm() {
}); });
if (emailError) { if (emailError) {
// Partial success: name/avatar saved but email didn't. // Partial success: name/avatar saved but email didn't.
toast.success('Profile saved'); toast.success(t('profileSaved'));
toast.error(`Email change failed: ${emailError.message}`); toast.error(t('emailChangeFailed', { message: emailError.message }));
setSaving(false); setSaving(false);
await refreshProfile(); await refreshProfile();
return; return;
@ -178,8 +180,8 @@ export function ProfileForm() {
toast.success( toast.success(
emailSent emailSent
? 'Profile saved — check your email to confirm the address change' ? t('profileSavedEmailCheck')
: 'Profile saved', : t('profileSaved'),
); );
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error'; const msg = err instanceof Error ? err.message : 'Unknown error';
@ -207,8 +209,8 @@ export function ProfileForm() {
return ( return (
<section className="max-w-2xl animate-in fade-in-50 duration-200"> <section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Your profile" title={t('title')}
description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you." description={t('description')}
/> />
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<Card> <Card>
@ -239,7 +241,7 @@ export function ProfileForm() {
disabled={saving} disabled={saving}
> >
<Upload className="size-4" /> <Upload className="size-4" />
{currentAvatar ? 'Change photo' : 'Upload photo'} {currentAvatar ? t('changePhoto') : t('uploadPhoto')}
</Button> </Button>
{currentAvatar && ( {currentAvatar && (
<Button <Button
@ -250,11 +252,11 @@ export function ProfileForm() {
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Remove {t('remove')}
</Button> </Button>
)} )}
<p className="w-full text-xs text-muted-foreground"> <p className="w-full text-xs text-muted-foreground">
PNG, JPG, WebP, or GIF. Up to 2 MB. {t('photoHint')}
</p> </p>
</div> </div>
</div> </div>
@ -262,7 +264,7 @@ export function ProfileForm() {
{/* Name */} {/* Name */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-full-name" className="text-foreground"> <Label htmlFor="profile-full-name" className="text-foreground">
Display name {t('displayName')}
</Label> </Label>
<Input <Input
id="profile-full-name" id="profile-full-name"
@ -278,7 +280,7 @@ export function ProfileForm() {
{/* Email */} {/* Email */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile-email" className="text-foreground"> <Label htmlFor="profile-email" className="text-foreground">
Email {t('email')}
</Label> </Label>
<Input <Input
id="profile-email" id="profile-email"
@ -292,9 +294,11 @@ export function ProfileForm() {
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300"> <p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<Mail className="mt-0.5 size-3.5 shrink-0" /> <Mail className="mt-0.5 size-3.5 shrink-0" />
<span> <span>
Check the inbox for <strong>{profile?.email}</strong> and{' '} {t.rich('emailChangeHint', {
<strong>{email}</strong> — both need to confirm before the oldEmail: profile?.email || '',
change takes effect. newEmail: email,
bold: (chunks: React.ReactNode) => <strong>{chunks}</strong>
})}
</span> </span>
</p> </p>
)} )}
@ -303,21 +307,21 @@ export function ProfileForm() {
{/* Read-only block */} {/* Read-only block */}
<div className="rounded-lg border border-border bg-muted p-4"> <div className="rounded-lg border border-border bg-muted p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Account details {t('accountDetails')}
</p> </p>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2"> <dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div> <div>
<dt className="text-muted-foreground">Role</dt> <dt className="text-muted-foreground">{t('role')}</dt>
<dd className="mt-0.5 font-mono text-foreground"> <dd className="mt-0.5 font-mono text-foreground">
{profile?.role ?? 'user'} {profile?.role ?? 'user'}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-muted-foreground">Joined</dt> <dt className="text-muted-foreground">{t('joined')}</dt>
<dd className="mt-0.5 text-foreground">{joined}</dd> <dd className="mt-0.5 text-foreground">{joined}</dd>
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<dt className="text-muted-foreground">User ID</dt> <dt className="text-muted-foreground">{t('userId')}</dt>
<dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground"> <dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground">
{user?.id ?? '—'} {user?.id ?? '—'}
</dd> </dd>
@ -328,7 +332,7 @@ export function ProfileForm() {
{!profile && ( {!profile && (
<p className="flex items-center gap-2 text-sm text-muted-foreground"> <p className="flex items-center gap-2 text-sm text-muted-foreground">
<CircleAlert className="size-4" /> <CircleAlert className="size-4" />
Loading your profile… {t('loading')}
</p> </p>
)} )}
@ -340,10 +344,10 @@ export function ProfileForm() {
{saving ? ( {saving ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Saving… {t('saving')}
</> </>
) : ( ) : (
'Save changes' t('saveChanges')
)} )}
</Button> </Button>
</div> </div>

View File

@ -24,25 +24,25 @@ export const ROLE_META: Record<
> = { > = {
owner: { owner: {
icon: Crown, icon: Crown,
label: 'Owner', label: 'owner',
variant: 'owner', variant: 'owner',
className: 'border-amber-500/40 bg-amber-500/10 text-amber-300', className: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
}, },
admin: { admin: {
icon: Shield, icon: Shield,
label: 'Admin', label: 'admin',
variant: 'admin', variant: 'admin',
className: 'border-primary/40 bg-primary/10 text-primary', className: 'border-primary/40 bg-primary/10 text-primary',
}, },
agent: { agent: {
icon: UserCog, icon: UserCog,
label: 'Agent', label: 'agent',
variant: 'muted', variant: 'muted',
className: 'border-border bg-muted text-muted-foreground', className: 'border-border bg-muted text-muted-foreground',
}, },
viewer: { viewer: {
icon: UserIcon, icon: UserIcon,
label: 'Viewer', label: 'viewer',
variant: 'muted', variant: 'muted',
// Outline-only so it stays quieter than the filled Agent chip in // Outline-only so it stays quieter than the filled Agent chip in
// both modes — bg-card would blend into a card surface in light mode. // both modes — bg-card would blend into a card surface in light mode.

View File

@ -3,17 +3,19 @@
import { PasswordForm } from './password-form'; import { PasswordForm } from './password-form';
import { SessionsCard } from './sessions-card'; import { SessionsCard } from './sessions-card';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
import { useTranslations } from 'next-intl';
/** /**
* "Login & security" section — groups the former Profile-tab password * "Login & security" section — groups the former Profile-tab password
* and active-sessions cards into their own dedicated home. * and active-sessions cards into their own dedicated home.
*/ */
export function SecurityPanel() { export function SecurityPanel() {
const t = useTranslations('Settings.security');
return ( return (
<section className="max-w-2xl animate-in fade-in-50 duration-200"> <section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Login & security" title={t('title')}
description="Change your password and sign out of your devices. These keep your account safe." description={t('description')}
/> />
<div className="space-y-4"> <div className="space-y-4">
<PasswordForm /> <PasswordForm />

View File

@ -21,8 +21,10 @@ import {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useTranslations } from 'next-intl';
export function SessionsCard() { export function SessionsCard() {
const t = useTranslations('Settings.profile');
const supabase = createClient(); const supabase = createClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [signingOut, setSigningOut] = useState(false); const [signingOut, setSigningOut] = useState(false);
@ -35,7 +37,7 @@ export function SessionsCard() {
// triggers the usual redirect. // triggers the usual redirect.
const { error } = await supabase.auth.signOut({ scope: 'global' }); const { error } = await supabase.auth.signOut({ scope: 'global' });
if (error) { if (error) {
toast.error(`Sign-out failed: ${error.message}`); toast.error(t('signOutFailed', { message: error.message }));
return; return;
} }
window.location.href = '/login'; window.location.href = '/login';
@ -53,11 +55,10 @@ export function SessionsCard() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<LogOut className="size-4 text-primary" /> <LogOut className="size-4 text-primary" />
Active sessions {t('sessionsTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Sign out of every device where you&apos;re logged in — including {t('sessionsDesc')}
this one. Useful if you lost a laptop or shared your password.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -67,7 +68,7 @@ export function SessionsCard() {
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<LogOut className="size-4" /> <LogOut className="size-4" />
Sign out of all devices {t('signOutAll')}
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
@ -75,11 +76,9 @@ export function SessionsCard() {
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Sign out everywhere?</DialogTitle> <DialogTitle>{t('signOutConfirmTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>
Every device logged into this account will be signed out and {t('signOutConfirmDesc')}
will need to log in again. You will be redirected to the login
page.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -89,16 +88,16 @@ export function SessionsCard() {
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
disabled={signingOut} disabled={signingOut}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button type="button" onClick={onConfirm} disabled={signingOut}> <Button type="button" onClick={onConfirm} disabled={signingOut}>
{signingOut ? ( {signingOut ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Signing out… {t('signingOut')}
</> </>
) : ( ) : (
'Sign out everywhere' t('signOutEverywhere')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -2,6 +2,7 @@
import { useEffect, useState, type ReactNode } from 'react'; import { useEffect, useState, type ReactNode } from 'react';
import { ChevronRight, Loader2 } from 'lucide-react'; import { ChevronRight, Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
@ -38,6 +39,9 @@ export function SettingsOverview({
const { user, profile, accountId, accountRole, defaultCurrency, canManageMembers } = const { user, profile, accountId, accountRole, defaultCurrency, canManageMembers } =
useAuth(); useAuth();
const { mode, theme } = useTheme(); const { mode, theme } = useTheme();
const t = useTranslations('Settings.overview');
const tRoles = useTranslations('roles');
const tSections = useTranslations('Settings.sections');
const [counts, setCounts] = useState<OverviewCounts | null>(null); const [counts, setCounts] = useState<OverviewCounts | null>(null);
const [countsLoading, setCountsLoading] = useState(true); const [countsLoading, setCountsLoading] = useState(true);
@ -137,7 +141,7 @@ export function SettingsOverview({
}; };
}, [user?.id, accountId, canManageMembers]); }, [user?.id, accountId, canManageMembers]);
const displayName = profile?.full_name || profile?.email || 'Your account'; const displayName = profile?.full_name || profile?.email || t('yourAccount');
const initial = (profile?.full_name || profile?.email || 'U').charAt(0).toUpperCase(); const initial = (profile?.full_name || profile?.email || 'U').charAt(0).toUpperCase();
const roleMeta = accountRole ? ROLE_META[accountRole] : null; const roleMeta = accountRole ? ROLE_META[accountRole] : null;
const RoleIcon = roleMeta?.icon; const RoleIcon = roleMeta?.icon;
@ -158,14 +162,14 @@ export function SettingsOverview({
section: 'whatsapp', section: 'whatsapp',
loading: whatsappLoading, loading: whatsappLoading,
subtitle: !whatsapp?.configured ? ( subtitle: !whatsapp?.configured ? (
'Not set up yet' t('notSetup')
) : whatsapp.connected ? ( ) : whatsapp.connected ? (
<> <>
<StatusDot tone="ok" /> Connected <StatusDot tone="ok" /> {t('connected')}
</> </>
) : ( ) : (
<> <>
<StatusDot tone="muted" /> Needs reconnecting <StatusDot tone="muted" /> {t('needsReconnecting')}
</> </>
), ),
}, },
@ -174,12 +178,10 @@ export function SettingsOverview({
loading: countsLoading, loading: countsLoading,
subtitle: subtitle:
counts?.members == null counts?.members == null
? 'View team members' ? t('viewTeamMembers')
: `${counts.members} member${counts.members === 1 ? '' : 's'}${ : `${t('membersCount', { count: counts.members })}${
counts.pendingInvites counts.pendingInvites
? ` · ${counts.pendingInvites} pending invite${ ? ` · ${t('pendingInvites', { count: counts.pendingInvites })}`
counts.pendingInvites === 1 ? '' : 's'
}`
: '' : ''
}`, }`,
}, },
@ -188,10 +190,10 @@ export function SettingsOverview({
loading: countsLoading, loading: countsLoading,
subtitle: subtitle:
counts?.templates == null counts?.templates == null
? 'Manage message templates' ? t('manageTemplates')
: `${counts.templates} template${counts.templates === 1 ? '' : 's'}${ : `${t('templatesCount', { count: counts.templates })}${
counts.templatesPending counts.templatesPending
? ` · ${counts.templatesPending} pending review` ? ` · ${t('pendingReview', { count: counts.templatesPending })}`
: '' : ''
}`, }`,
}, },
@ -205,15 +207,15 @@ export function SettingsOverview({
loading: countsLoading, loading: countsLoading,
subtitle: subtitle:
counts?.tags == null && counts?.customFields == null counts?.tags == null && counts?.customFields == null
? 'Tags and custom fields' ? t('tagsAndFields')
: `${counts?.tags ?? 0} tag${counts?.tags === 1 ? '' : 's'} · ${ : `${t('tagsCount', { count: counts?.tags ?? 0 })} · ${t('fieldsCount', {
counts?.customFields ?? 0 count: counts?.customFields ?? 0,
} custom field${counts?.customFields === 1 ? '' : 's'}`, })}`,
}, },
{ {
section: 'appearance', section: 'appearance',
loading: false, loading: false,
subtitle: `${cap(mode)} mode · ${themeName} accent`, subtitle: t('appearance', { mode: cap(mode), theme: themeName }),
}, },
]; ];
@ -242,7 +244,7 @@ export function SettingsOverview({
{roleMeta && RoleIcon ? ( {roleMeta && RoleIcon ? (
<SettingsChip variant={roleMeta.variant}> <SettingsChip variant={roleMeta.variant}>
<RoleIcon /> <RoleIcon />
{roleMeta.label} {tRoles(accountRole!)}
</SettingsChip> </SettingsChip>
) : null} ) : null}
</Card> </Card>
@ -267,12 +269,12 @@ export function SettingsOverview({
</span> </span>
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground"> <span className="block text-sm font-semibold text-foreground">
{meta.label} {tSections(section)}
</span> </span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{loading ? ( {loading ? (
<> <>
<Loader2 className="size-3 animate-spin" /> Loading… <Loader2 className="size-3 animate-spin" /> {t('loading')}
</> </>
) : ( ) : (
subtitle subtitle

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, type ReactNode } from 'react'; import { useEffect, useRef, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
@ -30,6 +31,7 @@ export function SettingsRail({
onSelect: (section: SettingsSection) => void; onSelect: (section: SettingsSection) => void;
hints?: Partial<Record<SettingsSection, ReactNode>>; hints?: Partial<Record<SettingsSection, ReactNode>>;
}) { }) {
const t = useTranslations('Settings');
const activeRef = useRef<HTMLButtonElement>(null); const activeRef = useRef<HTMLButtonElement>(null);
// When horizontal (mobile), keep the active chip in view. On desktop // When horizontal (mobile), keep the active chip in view. On desktop
@ -64,7 +66,7 @@ export function SettingsRail({
> >
{label ? ( {label ? (
<div className="hidden px-3 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-[0.09em] text-muted-foreground uppercase lg:block"> <div className="hidden px-3 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-[0.09em] text-muted-foreground uppercase lg:block">
{label} {t(`groups.${group}`)}
</div> </div>
) : null} ) : null}
{items.map((s) => { {items.map((s) => {
@ -87,7 +89,7 @@ export function SettingsRail({
)} )}
> >
<Icon className="size-4 shrink-0" /> <Icon className="size-4 shrink-0" />
<span className="flex-1">{meta.label}</span> <span className="flex-1">{t(`sections.${s}`)}</span>
{hints?.[s] != null ? ( {hints?.[s] != null ? (
<span <span
className={cn( className={cn(

View File

@ -23,17 +23,18 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import type { Tag } from '@/types'; import type { Tag } from '@/types';
const PRESET_COLORS = [ const PRESET_COLORS = [
{ name: 'Red', value: '#ef4444' }, { name: 'red', value: '#ef4444' },
{ name: 'Orange', value: '#f97316' }, { name: 'orange', value: '#f97316' },
{ name: 'Amber', value: '#f59e0b' }, { name: 'amber', value: '#f59e0b' },
{ name: 'Emerald', value: '#10b981' }, { name: 'emerald', value: '#10b981' },
{ name: 'Cyan', value: '#06b6d4' }, { name: 'cyan', value: '#06b6d4' },
{ name: 'Blue', value: '#3b82f6' }, { name: 'blue', value: '#3b82f6' },
{ name: 'Violet', value: '#8b5cf6' }, { name: 'violet', value: '#8b5cf6' },
{ name: 'Pink', value: '#ec4899' }, { name: 'pink', value: '#ec4899' },
]; ];
/** /**
@ -42,6 +43,7 @@ const PRESET_COLORS = [
* dialog since it detaches the tag from every contact. * dialog since it detaches the tag from every contact.
*/ */
export function TagManager() { export function TagManager() {
const t = useTranslations('Settings.tagsAndFields');
const supabase = createClient(); const supabase = createClient();
const { user, accountId, loading: authLoading } = useAuth(); const { user, accountId, loading: authLoading } = useAuth();
@ -77,7 +79,7 @@ export function TagManager() {
setTags(data || []); setTags(data || []);
} catch (err) { } catch (err) {
console.error('Failed to fetch tags:', err); console.error('Failed to fetch tags:', err);
toast.error('Failed to load tags'); toast.error(t('failedToLoadTags'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -85,14 +87,14 @@ export function TagManager() {
async function handleCreate() { async function handleCreate() {
if (!newTagName.trim()) { if (!newTagName.trim()) {
toast.error('Tag name is required'); toast.error(t('nameRequired'));
return; return;
} }
try { try {
setSaving(true); setSaving(true);
if (!user || !accountId) { if (!user || !accountId) {
toast.error('Not authenticated'); toast.error(t('notAuthenticated'));
return; return;
} }
@ -107,13 +109,13 @@ export function TagManager() {
if (error) throw error; if (error) throw error;
toast.success('Tag created'); toast.success(t('tagCreated'));
setNewTagName(''); setNewTagName('');
setSelectedColor(PRESET_COLORS[3].value); setSelectedColor(PRESET_COLORS[3].value);
await fetchTags(user.id); await fetchTags(user.id);
} catch (err) { } catch (err) {
console.error('Create error:', err); console.error('Create error:', err);
toast.error('Failed to create tag'); toast.error(t('failedToCreateTag'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -136,13 +138,13 @@ export function TagManager() {
if (error) throw error; if (error) throw error;
toast.success('Tag deleted'); toast.success(t('tagDeleted'));
setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id)); setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id));
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
setTagToDelete(null); setTagToDelete(null);
} catch (err) { } catch (err) {
console.error('Delete error:', err); console.error('Delete error:', err);
toast.error('Failed to delete tag'); toast.error(t('failedToDeleteTag'));
} finally { } finally {
setDeleting(false); setDeleting(false);
} }
@ -153,10 +155,10 @@ export function TagManager() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground"> <CardTitle className="flex items-center gap-2 text-foreground">
<TagIcon className="size-4 text-primary" /> <TagIcon className="size-4 text-primary" />
Tags {t('tagsTitle')}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Colour-coded labels for grouping and filtering contacts. {t('tagsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@ -186,7 +188,7 @@ export function TagManager() {
<button <button
type="button" type="button"
onClick={() => confirmDelete(tag)} onClick={() => confirmDelete(tag)}
aria-label={`Delete ${tag.name}`} aria-label={t('deleteAria', { name: tag.name })}
className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10" className="ml-0.5 rounded-full p-0.5 opacity-60 transition-opacity hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
> >
<X className="size-3" /> <X className="size-3" />
@ -196,14 +198,14 @@ export function TagManager() {
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tags yet — create your first one below. {t('noTags')}
</p> </p>
)} )}
{/* Inline create row */} {/* Inline create row */}
<div className="flex flex-wrap items-center gap-2.5"> <div className="flex flex-wrap items-center gap-2.5">
<Input <Input
placeholder="e.g. Newsletter" placeholder={t('placeholder')}
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -219,7 +221,7 @@ export function TagManager() {
key={color.value} key={color.value}
type="button" type="button"
onClick={() => setSelectedColor(color.value)} onClick={() => setSelectedColor(color.value)}
aria-label={`Use ${color.name}`} aria-label={t('useColor', { color: t(`colors.${color.name}` as Parameters<typeof t>[0]) })}
aria-pressed={selectedColor === color.value} aria-pressed={selectedColor === color.value}
className={cn( className={cn(
'size-6 rounded-md transition-transform hover:scale-110', 'size-6 rounded-md transition-transform hover:scale-110',
@ -227,7 +229,7 @@ export function TagManager() {
'outline outline-2 outline-offset-2 outline-primary', 'outline outline-2 outline-offset-2 outline-primary',
)} )}
style={{ backgroundColor: color.value }} style={{ backgroundColor: color.value }}
title={color.name} title={t(`colors.${color.name}` as Parameters<typeof t>[0])}
/> />
))} ))}
</div> </div>
@ -242,7 +244,7 @@ export function TagManager() {
) : ( ) : (
<Plus className="size-4" /> <Plus className="size-4" />
)} )}
Add tag {t('addTag')}
</Button> </Button>
</div> </div>
</> </>
@ -253,10 +255,9 @@ export function TagManager() {
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent className="sm:max-w-sm"> <DialogContent className="sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>Delete tag</DialogTitle> <DialogTitle>{t('deleteTag')}</DialogTitle>
<DialogDescription> <DialogDescription>
Delete the tag &quot;{tagToDelete?.name}&quot;? This removes it {tagToDelete ? t('deleteConfirm', { name: tagToDelete.name }) : null}
from all contacts and cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -265,7 +266,7 @@ export function TagManager() {
onClick={() => setDeleteDialogOpen(false)} onClick={() => setDeleteDialogOpen(false)}
disabled={deleting} disabled={deleting}
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -275,10 +276,10 @@ export function TagManager() {
{deleting ? ( {deleting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Deleting... {t('deleting')}
</> </>
) : ( ) : (
'Delete tag' t('deleteTag')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -24,6 +24,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { useTranslations } from 'next-intl';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { SettingsPanelHead } from './settings-panel-head'; import { SettingsPanelHead } from './settings-panel-head';
import { import {
@ -124,6 +125,7 @@ function emptyButton(type: TemplateButton['type']): TemplateButton {
} }
export function TemplateManager() { export function TemplateManager() {
const t = useTranslations('Settings.templates');
const supabase = createClient(); const supabase = createClient();
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
@ -197,7 +199,7 @@ export function TemplateManager() {
setTemplates(data || []); setTemplates(data || []);
} catch (err) { } catch (err) {
console.error('Failed to fetch templates:', err); console.error('Failed to fetch templates:', err);
toast.error('Failed to load templates'); toast.error(t('toastLoadFailed'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -282,18 +284,18 @@ export function TemplateManager() {
toast.success( toast.success(
data.dry_run data.dry_run
? isEdit ? isEdit
? 'Template updated (dry-run — no Meta call)' ? t('toastSaveEditDry')
: 'Template saved (dry-run — no Meta call)' : t('toastSaveNewDry')
: isEdit : isEdit
? 'Edit submitted — Meta typically reviews within 24 hours.' ? t('toastSubmitEditSuccess')
: 'Submitted to Meta — typical review time is 24 hours. Status updates automatically.', : t('toastSubmitNewSuccess'),
); );
setDialogOpen(false); setDialogOpen(false);
setForm(emptyForm); setForm(emptyForm);
setEditingId(null); setEditingId(null);
} catch (err) { } catch (err) {
console.error('Submit error:', err); console.error('Submit error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to submit'); toast.error(err instanceof Error ? err.message : t('toastSubmitFailed'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -309,9 +311,9 @@ export function TemplateManager() {
throw new Error(data?.error || `Sync failed (HTTP ${res.status})`); throw new Error(data?.error || `Sync failed (HTTP ${res.status})`);
} }
toast.success( toast.success(
`Synced ${data.total} template${data.total === 1 ? '' : 's'} from Meta` + t('toastSyncCount', { total: data.total }) +
(data.inserted || data.updated (data.inserted || data.updated
? ` (${data.inserted} new, ${data.updated} updated)` ? t('toastSyncDetails', { inserted: data.inserted, updated: data.updated })
: ''), : ''),
); );
if (Array.isArray(data.errors) && data.errors.length > 0) { if (Array.isArray(data.errors) && data.errors.length > 0) {
@ -321,21 +323,21 @@ export function TemplateManager() {
); );
const suffix = const suffix =
data.errors.length > 3 ? `, +${data.errors.length - 3} more` : ''; data.errors.length > 3 ? `, +${data.errors.length - 3} more` : '';
toast.error(`Failed to sync: ${preview.join(', ')}${suffix}`); toast.error(t('toastSyncFailed', { preview: preview.join(', ') + suffix }));
} }
if (data.truncated) { if (data.truncated) {
// Use error (not warning) so the message survives long // Use error (not warning) so the message survives long
// enough to read — sonner's `warning` auto-dismisses on // enough to read — sonner's `warning` auto-dismisses on
// the same short timer as `success`. // the same short timer as `success`.
toast.error( toast.error(
'Synced the first 2000 templates only — your account has more. Sync again to continue, or contact support if this persists.', t('toastSyncTruncated'),
{ duration: 10000 }, { duration: 10000 },
); );
} }
await fetchTemplates(user.id); await fetchTemplates(user.id);
} catch (err) { } catch (err) {
console.error('Template sync error:', err); console.error('Template sync error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to sync templates'); toast.error(err instanceof Error ? err.message : t('toastSyncError'));
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
@ -356,12 +358,12 @@ export function TemplateManager() {
if (!res.ok) { if (!res.ok) {
throw new Error(data?.error || `Delete failed (HTTP ${res.status})`); throw new Error(data?.error || `Delete failed (HTTP ${res.status})`);
} }
toast.success('Template deleted'); toast.success(t('toastDeleteSuccess'));
setTemplates((prev) => prev.filter((t) => t.id !== target.id)); setTemplates((prev) => prev.filter((t) => t.id !== target.id));
setTemplateToDelete(null); setTemplateToDelete(null);
} catch (err) { } catch (err) {
console.error('Delete error:', err); console.error('Delete error:', err);
toast.error(err instanceof Error ? err.message : 'Failed to delete template'); toast.error(err instanceof Error ? err.message : t('toastDeleteError'));
} finally { } finally {
setDeletingId(null); setDeletingId(null);
} }
@ -458,12 +460,12 @@ export function TemplateManager() {
async function handleHeaderImageFile(file: File) { async function handleHeaderImageFile(file: File) {
if (!['image/jpeg', 'image/png'].includes(file.type)) { if (!['image/jpeg', 'image/png'].includes(file.type)) {
toast.error('Header image must be a JPEG or PNG.'); toast.error(t('toastInvalidImage'));
return; return;
} }
if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) { if (file.size > MEDIA_MAX_BYTES_BY_KIND.image) {
toast.error( toast.error(
`Image is ${(file.size / 1024 / 1024).toFixed(1)} MB — Meta's limit is 5 MB.`, t('toastImageTooLarge', { size: (file.size / 1024 / 1024).toFixed(1) }),
); );
return; return;
} }
@ -471,9 +473,9 @@ export function TemplateManager() {
try { try {
const { publicUrl } = await uploadAccountMedia('chat-media', file); const { publicUrl } = await uploadAccountMedia('chat-media', file);
setForm((f) => ({ ...f, header_media_url: publicUrl })); setForm((f) => ({ ...f, header_media_url: publicUrl }));
toast.success('Image uploaded.'); toast.success(t('toastUploadSuccess'));
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed.'); toast.error(err instanceof Error ? err.message : t('toastUploadFailed'));
} finally { } finally {
setUploadingHeader(false); setUploadingHeader(false);
} }
@ -482,24 +484,22 @@ export function TemplateManager() {
return ( return (
<section className="animate-in fade-in-50 space-y-4 duration-200"> <section className="animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="Message templates" title={t('title')}
description={ description={t('description')}
'Create templates and submit them to Meta for approval. Use "Sync from Meta" to pull templates approved elsewhere.'
}
action={ action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={handleSyncFromMeta} onClick={handleSyncFromMeta}
disabled={syncing} disabled={syncing}
title="Pull approved templates from your Meta WhatsApp Business Account" title={t('syncTitle')}
> >
<RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`size-4 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Sync from Meta'} {syncing ? t('syncing') : t('syncFromMeta')}
</Button> </Button>
<Button onClick={openCreate}> <Button onClick={openCreate}>
<Plus className="size-4" /> <Plus className="size-4" />
New Template {t('newTemplate')}
</Button> </Button>
</div> </div>
} }
@ -508,9 +508,9 @@ export function TemplateManager() {
{templates.length === 0 ? ( {templates.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center"> <CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground text-sm">No templates yet.</p> <p className="text-muted-foreground text-sm">{t('noTemplates')}</p>
<p className="text-muted-foreground text-xs mt-1"> <p className="text-muted-foreground text-xs mt-1">
Create your first message template to get started. {t('createFirst')}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -576,12 +576,12 @@ export function TemplateManager() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => openEdit(template)} onClick={() => openEdit(template)}
title="Editing triggers Meta re-review — status flips to PENDING." title={t('editTitle')}
aria-label="Edit template" aria-label={t('editLabel')}
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2" className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
> >
<Pencil className="size-3.5" /> <Pencil className="size-3.5" />
Edit {t('edit')}
</Button> </Button>
)} )}
{(statusKey === 'REJECTED' || statusKey === 'PAUSED') && ( {(statusKey === 'REJECTED' || statusKey === 'PAUSED') && (
@ -589,12 +589,12 @@ export function TemplateManager() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => openEdit(template)} onClick={() => openEdit(template)}
title="Edit the template and resubmit to Meta for review." title={t('resubmitTitle')}
aria-label="Edit and resubmit template" aria-label={t('resubmitLabel')}
className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2" className="text-muted-foreground hover:text-primary hover:bg-primary/10 h-8 px-2"
> >
<RotateCcw className="size-3.5" /> <RotateCcw className="size-3.5" />
Resubmit {t('resubmit')}
</Button> </Button>
)} )}
<Button <Button
@ -604,13 +604,13 @@ export function TemplateManager() {
disabled={deletingId === template.id} disabled={deletingId === template.id}
aria-label={ aria-label={
template.meta_template_id template.meta_template_id
? 'Delete template from Meta and locally' ? t('deleteMetaLocallyAria')
: 'Delete template locally' : t('deleteLocallyAria')
} }
title={ title={
template.meta_template_id template.meta_template_id
? 'Delete from Meta and locally' ? t('deleteMetaLocallyTitle')
: 'Delete locally' : t('deleteLocallyTitle')
} }
className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 h-8 w-8" className="text-muted-foreground hover:text-red-400 hover:bg-red-950/30 h-8 w-8"
> >
@ -641,32 +641,27 @@ export function TemplateManager() {
<DialogContent className="bg-popover border-border sm:max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="bg-popover border-border sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
{editingId ? 'Edit Message Template' : 'New Message Template'} {editingId ? t('dialogEditTitle') : t('dialogNewTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
{editingId {editingId
? 'Save your changes to re-submit to Meta. Status will flip back to PENDING during review.' ? t('dialogEditDesc')
: 'Build a template and submit it to Meta for approval. Once approved, you can use it in broadcasts and the inbox.'} : t('dialogNewDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{form.category === 'Authentication' && ( {form.category === 'Authentication' && (
<div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300"> <div className="flex items-start gap-2 rounded border border-amber-700/40 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
<AlertCircle className="size-4 mt-0.5 shrink-0" /> <AlertCircle className="size-4 mt-0.5 shrink-0" />
<p> <p>{t.rich('authWarning', { bold: (chunks) => <strong>{chunks}</strong> })}</p>
AUTHENTICATION templates have a fixed body + OTP button shape
that needs a different builder. Create them in Meta WhatsApp
Manager for now and use <strong>Sync from Meta</strong> to
bring them in.
</p>
</div> </div>
)} )}
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Template Name</Label> <Label className="text-muted-foreground">{t('templateName')}</Label>
<Input <Input
placeholder="e.g. order_confirmation" placeholder={t('namePlaceholder')}
value={form.name} value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={editingId !== null} disabled={editingId !== null}
@ -674,14 +669,14 @@ export function TemplateManager() {
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{editingId {editingId
? 'Name is fixed once a template exists on Meta — create a new template to change it.' ? t('nameFixed')
: 'Lowercase letters, digits, and underscores only.'} : t('nameHint')}
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Category</Label> <Label className="text-muted-foreground">{t('category')}</Label>
<Select <Select
value={form.category} value={form.category}
onValueChange={(val) => onValueChange={(val) =>
@ -709,7 +704,7 @@ export function TemplateManager() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Language</Label> <Label className="text-muted-foreground">{t('language')}</Label>
<Input <Input
list="template-language-codes" list="template-language-codes"
placeholder="en_US" placeholder="en_US"
@ -726,20 +721,17 @@ export function TemplateManager() {
))} ))}
</datalist> </datalist>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
{editingId {editingId ? (
? 'Language is fixed once a template exists on Meta.' t('langFixed')
: ( ) : (
<> <span>{t.rich('langHint', { code: (chunks) => <code>{chunks}</code> })}</span>
Must match the exact code on Meta — <code>en_US</code>{' '} )}
and <code>en</code> are distinct.
</>
)}
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Header</Label> <Label className="text-muted-foreground">{t('header')}</Label>
<Select <Select
value={form.header_format} value={form.header_format}
onValueChange={(val) => onValueChange={(val) =>
@ -766,8 +758,14 @@ export function TemplateManager() {
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
{type === 'none' {type === 'none'
? 'None' ? t('headerNone')
: type.charAt(0).toUpperCase() + type.slice(1)} : type === 'text'
? t('headerText')
: type === 'image'
? t('headerImage')
: type === 'video'
? t('headerVideo')
: t('headerDocument')}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -778,7 +776,7 @@ export function TemplateManager() {
<Input <Input
id="template-header-text" id="template-header-text"
aria-label="Header text" aria-label="Header text"
placeholder="Header text (max 60 chars, optional {{1}})" placeholder={t('headerTextPlaceholder')}
value={form.header_content} value={form.header_content}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_content: e.target.value }) setForm({ ...form, header_content: e.target.value })
@ -789,8 +787,8 @@ export function TemplateManager() {
{headerVarCount > 0 && ( {headerVarCount > 0 && (
<Input <Input
id="template-header-sample" id="template-header-sample"
aria-label="Sample value for header variable" aria-label={t('headerSampleAria')}
placeholder="Sample value for {{1}} (required for Meta review)" placeholder={t('headerSamplePlaceholder')}
value={form.header_sample} value={form.header_sample}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_sample: e.target.value }) setForm({ ...form, header_sample: e.target.value })
@ -828,15 +826,15 @@ export function TemplateManager() {
) : ( ) : (
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
)} )}
Upload image {t('uploadImage')}
</Button> </Button>
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
JPEG or PNG, ≤5 MB {t('uploadHint')}
</span> </span>
</div> </div>
)} )}
<Input <Input
placeholder={`https://… (or paste a public ${form.header_format} link)`} placeholder={t('mediaUrlPlaceholder', { format: form.header_format })}
value={form.header_media_url} value={form.header_media_url}
onChange={(e) => onChange={(e) =>
setForm({ ...form, header_media_url: e.target.value }) setForm({ ...form, header_media_url: e.target.value })
@ -853,21 +851,21 @@ export function TemplateManager() {
)} )}
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
{form.header_format === 'image' {form.header_format === 'image'
? 'Upload a JPEG/PNG (≤5 MB, ≥800×418 px recommended) or paste a public HTTPS link — we upload it to Meta for review automatically.' ? t('imageHint')
: 'Must be a publicly accessible HTTPS link. Meta fetches it once during review, so it needs to stay live for ~24 hrs.'} : t('mediaHint')}
{form.header_format === 'video' && {form.header_format === 'video' &&
' Recommended: MP4 / 3GPP, ≤16 MB, ≤60 seconds.'} t('videoHint')}
{form.header_format === 'document' && {form.header_format === 'document' &&
' Recommended: PDF, ≤100 MB.'} t('documentHint')}
</p> </p>
</div> </div>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Body Text</Label> <Label className="text-muted-foreground">{t('bodyText')}</Label>
<Textarea <Textarea
placeholder="Hello {{1}}, your order {{2}} is confirmed." placeholder={t('bodyPlaceholder')}
value={form.body_text} value={form.body_text}
onChange={(e) => onChange={(e) =>
setForm({ ...form, body_text: e.target.value }) setForm({ ...form, body_text: e.target.value })
@ -877,14 +875,13 @@ export function TemplateManager() {
className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none" className="bg-muted border-border text-foreground placeholder:text-muted-foreground resize-none"
/> />
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Use {`{{1}}`}, {`{{2}}`} for variables (must be contiguous {t('bodyHint')}
starting at {`{{1}}`}).
</p> </p>
{bodyVarCount > 0 && ( {bodyVarCount > 0 && (
<div className="space-y-1.5 pt-1"> <div className="space-y-1.5 pt-1">
<Label className="text-[11px] text-muted-foreground"> <Label className="text-[11px] text-muted-foreground">
Sample values (Meta uses these to review your template) {t('sampleValues')}
</Label> </Label>
{form.body_samples.map((val, i) => { {form.body_samples.map((val, i) => {
const inputId = `template-body-sample-${i}`; const inputId = `template-body-sample-${i}`;
@ -892,8 +889,8 @@ export function TemplateManager() {
<Input <Input
key={i} key={i}
id={inputId} id={inputId}
aria-label={`Sample value for body variable {{${i + 1}}}`} aria-label={t('sampleAria', { var: `{{${i + 1}}}` })}
placeholder={`Sample for {{${i + 1}}}`} placeholder={t('samplePlaceholder', { var: `{{${i + 1}}}` })}
value={val} value={val}
onChange={(e) => { onChange={(e) => {
const next = [...form.body_samples]; const next = [...form.body_samples];
@ -909,9 +906,9 @@ export function TemplateManager() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Footer (optional)</Label> <Label className="text-muted-foreground">{t('footer')}</Label>
<Input <Input
placeholder="Optional footer text (max 60 chars)" placeholder={t('footerPlaceholder')}
value={form.footer_text} value={form.footer_text}
onChange={(e) => onChange={(e) =>
setForm({ ...form, footer_text: e.target.value }) setForm({ ...form, footer_text: e.target.value })
@ -923,7 +920,7 @@ export function TemplateManager() {
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-muted-foreground">Buttons (optional)</Label> <Label className="text-muted-foreground">{t('buttons')}</Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -933,13 +930,12 @@ export function TemplateManager() {
className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs" className="border-border bg-transparent text-muted-foreground hover:bg-muted h-7 text-xs"
> >
<Plus className="size-3" /> <Plus className="size-3" />
Add Button {t('addButton')}
</Button> </Button>
</div> </div>
{form.buttons.length === 0 ? ( {form.buttons.length === 0 ? (
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
Up to {TEMPLATE_LIMITS.maxButtonsTotal} buttons. QUICK_REPLY {t('buttonsLimit', { max: TEMPLATE_LIMITS.maxButtonsTotal })}
buttons must come before URL / phone / copy-code buttons.
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@ -967,30 +963,30 @@ export function TemplateManager() {
value="QUICK_REPLY" value="QUICK_REPLY"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
Quick Reply {t('btnQuickReply')}
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="URL" value="URL"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
URL {t('btnUrl')}
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="PHONE_NUMBER" value="PHONE_NUMBER"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
Phone {t('btnPhone')}
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="COPY_CODE" value="COPY_CODE"
className="text-popover-foreground focus:bg-muted focus:text-popover-foreground" className="text-popover-foreground focus:bg-muted focus:text-popover-foreground"
> >
Copy Code {t('btnCopyCode')}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Input <Input
placeholder="Button label" placeholder={t('btnLabelPlaceholder')}
value={btn.text} value={btn.text}
maxLength={TEMPLATE_LIMITS.buttonTextMaxLength} maxLength={TEMPLATE_LIMITS.buttonTextMaxLength}
onChange={(e) => onChange={(e) =>
@ -1011,7 +1007,7 @@ export function TemplateManager() {
{btn.type === 'URL' && ( {btn.type === 'URL' && (
<div className="space-y-1 pl-1"> <div className="space-y-1 pl-1">
<Input <Input
placeholder="https://example.com/path or with {{1}} suffix" placeholder={t('urlPlaceholder')}
value={btn.url} value={btn.url}
onChange={(e) => onChange={(e) =>
updateButton(i, { url: e.target.value }) updateButton(i, { url: e.target.value })
@ -1020,7 +1016,7 @@ export function TemplateManager() {
/> />
{extractVariableIndices(btn.url).length > 0 && ( {extractVariableIndices(btn.url).length > 0 && (
<Input <Input
placeholder="Example value for {{1}} (required when URL has a variable)" placeholder={t('urlSamplePlaceholder')}
value={btn.example ?? ''} value={btn.example ?? ''}
onChange={(e) => onChange={(e) =>
updateButton(i, { example: e.target.value }) updateButton(i, { example: e.target.value })
@ -1032,7 +1028,7 @@ export function TemplateManager() {
)} )}
{btn.type === 'PHONE_NUMBER' && ( {btn.type === 'PHONE_NUMBER' && (
<Input <Input
placeholder="+15551234567" placeholder={t('phonePlaceholder')}
value={btn.phone_number} value={btn.phone_number}
onChange={(e) => onChange={(e) =>
updateButton(i, { phone_number: e.target.value }) updateButton(i, { phone_number: e.target.value })
@ -1042,7 +1038,7 @@ export function TemplateManager() {
)} )}
{btn.type === 'COPY_CODE' && ( {btn.type === 'COPY_CODE' && (
<Input <Input
placeholder="Example code (e.g. SUMMER20)" placeholder={t('codePlaceholder')}
value={btn.example} value={btn.example}
onChange={(e) => onChange={(e) =>
updateButton(i, { example: e.target.value }) updateButton(i, { example: e.target.value })
@ -1063,7 +1059,7 @@ export function TemplateManager() {
onClick={() => setDialogOpen(false)} onClick={() => setDialogOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
@ -1073,12 +1069,12 @@ export function TemplateManager() {
{submitting ? ( {submitting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
{editingId ? 'Saving…' : 'Submitting…'} {editingId ? t('saving') : t('submitting')}
</> </>
) : editingId ? ( ) : editingId ? (
'Save & Resubmit' t('saveResubmit')
) : ( ) : (
'Submit for Approval' t('submitApproval')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -1096,11 +1092,11 @@ export function TemplateManager() {
> >
<DialogContent className="bg-popover border-border sm:max-w-sm"> <DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Delete template?</DialogTitle> <DialogTitle className="text-popover-foreground">{t('deleteDialogTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
{templateToDelete?.meta_template_id {templateToDelete?.meta_template_id
? `"${templateToDelete?.name}" will be deleted from Meta and from wacrm. Active broadcasts using this template will start failing on their next send. This can't be undone.` ? t('deleteMetaDesc', { name: templateToDelete.name })
: `"${templateToDelete?.name}" will be deleted from wacrm. It was never submitted to Meta, so no remote cleanup is needed.`} : t('deleteLocalDesc', { name: templateToDelete?.name || '' })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -1110,7 +1106,7 @@ export function TemplateManager() {
disabled={deletingId !== null} disabled={deletingId !== null}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
onClick={confirmDelete} onClick={confirmDelete}
@ -1120,10 +1116,10 @@ export function TemplateManager() {
{deletingId !== null ? ( {deletingId !== null ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Deleting… {t('deleting')}
</> </>
) : ( ) : (
'Delete' t('delete')
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -16,6 +16,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -36,6 +37,7 @@ type ConnectionStatus = 'connected' | 'disconnected' | 'unknown';
type ResetReason = 'token_corrupted' | 'meta_api_error' | null; type ResetReason = 'token_corrupted' | 'meta_api_error' | null;
export function WhatsAppConfig() { export function WhatsAppConfig() {
const t = useTranslations('Settings.whatsapp');
const supabase = createClient(); const supabase = createClient();
// After multi-user, whatsapp_config is one-row-per-account, not // After multi-user, whatsapp_config is one-row-per-account, not
// one-row-per-user. We pull `accountId` straight off the auth // one-row-per-user. We pull `accountId` straight off the auth
@ -373,8 +375,8 @@ export function WhatsAppConfig() {
return ( return (
<section className="animate-in fade-in-50 duration-200"> <section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="WhatsApp connection" title={t("title")}
description="Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here." description={t("description")}
/> />
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="size-6 animate-spin text-primary" /> <Loader2 className="size-6 animate-spin text-primary" />
@ -388,8 +390,8 @@ export function WhatsAppConfig() {
return ( return (
<section className="animate-in fade-in-50 duration-200"> <section className="animate-in fade-in-50 duration-200">
<SettingsPanelHead <SettingsPanelHead
title="WhatsApp connection" title={t("title")}
description="Connect your Meta WhatsApp Business API. Credentials, webhook, and setup steps all live here." description={t("description")}
/> />
<div className="grid gap-6 lg:grid-cols-[1fr_380px]"> <div className="grid gap-6 lg:grid-cols-[1fr_380px]">
{/* Main config form */} {/* Main config form */}
@ -415,12 +417,12 @@ export function WhatsAppConfig() {
{resetting ? ( {resetting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Resetting... {t('resetting')}
</> </>
) : ( ) : (
<> <>
<RotateCcw className="size-4" /> <RotateCcw className="size-4" />
Reset Configuration {t('resetConfig')}
</> </>
)} )}
</Button> </Button>
@ -438,14 +440,14 @@ export function WhatsAppConfig() {
<XCircle className="size-4 text-red-500" /> <XCircle className="size-4 text-red-500" />
)} )}
<AlertTitle className="text-foreground mb-0"> <AlertTitle className="text-foreground mb-0">
{connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'} {connectionStatus === 'connected' ? t('credentialsValid') : t('notConnected')}
</AlertTitle> </AlertTitle>
</div> </div>
<AlertDescription className="text-muted-foreground"> <AlertDescription className="text-muted-foreground">
{connectionStatus === 'connected' {connectionStatus === 'connected'
? 'Your access token authenticates with Meta. See Registration status below for whether webhooks are actually wired.' ? t('connectedDesc')
: statusMessage || : statusMessage ||
'Configure your Meta API credentials below to connect your WhatsApp Business account.'} t('notConnectedDesc')}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -475,8 +477,8 @@ export function WhatsAppConfig() {
} }
> >
{isRegistered {isRegistered
? 'Registered — Meta will deliver events to wacrm' ? t('registered')
: 'Not registered — Meta will not deliver events'} : t('notRegistered')}
</AlertTitle> </AlertTitle>
</div> </div>
<Button <Button
@ -491,44 +493,39 @@ export function WhatsAppConfig() {
) : ( ) : (
<Zap className="size-3.5" /> <Zap className="size-3.5" />
)} )}
Verify with Meta {t('verifyWithMeta')}
</Button> </Button>
</div> </div>
<AlertDescription className="text-muted-foreground mt-2 text-xs leading-relaxed"> <AlertDescription className="text-muted-foreground mt-2 text-xs leading-relaxed">
{isRegistered ? ( {isRegistered ? (
<> <span
Subscribed since{' '} dangerouslySetInnerHTML={{
{config.registered_at __html: t('subscribedSince', {
? new Date(config.registered_at).toLocaleString() date: config.registered_at
: 'unknown'} ? new Date(config.registered_at).toLocaleString()
. Click <strong>Verify with Meta</strong> if events : t('unknownDate'),
stop arriving. }),
</> }}
/>
) : lastRegistrationError ? ( ) : lastRegistrationError ? (
<> <>
Last attempt failed with:{' '} {t('lastAttemptFailed')}
<span className="text-red-300"> <span className="text-red-300">
&quot;{lastRegistrationError}&quot; &quot;{lastRegistrationError}&quot;
</span> </span>
. Enter (or correct) the 2-step PIN below and click . {t('retryHint')}
Save Configuration to retry.
</> </>
) : ( ) : (
<> <>{t('noRegistrationHint')}</>
This number was saved before registration tracking
existed, or registration was skipped. Enter the
2-step PIN below and click Save Configuration to
subscribe it.
</>
)} )}
</AlertDescription> </AlertDescription>
{registrationProbe && ( {registrationProbe && (
<div className="mt-3 rounded border border-border bg-card/60 px-3 py-2 space-y-1.5 text-[11px]"> <div className="mt-3 rounded border border-border bg-card/60 px-3 py-2 space-y-1.5 text-[11px]">
<p className="font-medium text-foreground"> <p className="font-medium text-foreground">
Diagnostic — last run: {' '} {t('diagnosticLastRun')}
<span className={registrationProbe.live ? 'text-emerald-400' : 'text-amber-400'}> <span className={registrationProbe.live ? 'text-emerald-400' : 'text-amber-400'}>
{registrationProbe.live ? 'live' : 'not live'} {registrationProbe.live ? t('live') : t('notLive')}
</span> </span>
</p> </p>
<ul className="space-y-0.5 text-muted-foreground"> <ul className="space-y-0.5 text-muted-foreground">
@ -560,14 +557,14 @@ export function WhatsAppConfig() {
{/* API Credentials */} {/* API Credentials */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">API Credentials</CardTitle> <CardTitle className="text-foreground">{t('apiCredentialsTitle')}</CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Enter your Meta WhatsApp Business API credentials. {t('apiCredentialsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Phone Number ID</Label> <Label className="text-muted-foreground">{t('phoneNumberId')}</Label>
<Input <Input
placeholder="e.g. 100234567890123" placeholder="e.g. 100234567890123"
value={phoneNumberId} value={phoneNumberId}
@ -577,7 +574,7 @@ export function WhatsAppConfig() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">WhatsApp Business Account ID</Label> <Label className="text-muted-foreground">{t('wabaId')}</Label>
<Input <Input
placeholder="e.g. 100234567890456" placeholder="e.g. 100234567890456"
value={wabaId} value={wabaId}
@ -587,11 +584,11 @@ export function WhatsAppConfig() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Permanent Access Token</Label> <Label className="text-muted-foreground">{t('accessToken')}</Label>
<div className="relative"> <div className="relative">
<Input <Input
type={showToken ? 'text' : 'password'} type={showToken ? 'text' : 'password'}
placeholder="Enter your access token" placeholder={t('accessTokenPlaceholder')}
value={accessToken} value={accessToken}
onChange={(e) => { onChange={(e) => {
setAccessToken(e.target.value); setAccessToken(e.target.value);
@ -615,34 +612,34 @@ export function WhatsAppConfig() {
</div> </div>
{config && !tokenEdited && ( {config && !tokenEdited && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Token is hidden for security. Re-enter it to update configuration. {t('tokenHidden')}
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Webhook Verify Token</Label> <Label className="text-muted-foreground">{t('webhookVerifyToken')}</Label>
<Input <Input
placeholder="Create a custom verify token" placeholder={t('webhookVerifyTokenPlaceholder')}
value={verifyToken} value={verifyToken}
onChange={(e) => setVerifyToken(e.target.value)} onChange={(e) => setVerifyToken(e.target.value)}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
A custom string you create. Must match the token you set in Meta webhook settings. {t('webhookVerifyTokenHint')}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground"> <Label className="text-muted-foreground">
Two-step verification PIN {t('twoStepPin')}
<span className="ml-1 text-muted-foreground">(optional)</span> <span className="ml-1 text-muted-foreground">{t('optional')}</span>
</Label> </Label>
<Input <Input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
maxLength={6} maxLength={6}
placeholder="6-digit PIN from Meta WhatsApp Manager" placeholder={t('pinPlaceholder')}
value={pin} value={pin}
onChange={(e) => onChange={(e) =>
setPin(e.target.value.replace(/\D/g, '').slice(0, 6)) setPin(e.target.value.replace(/\D/g, '').slice(0, 6))
@ -650,20 +647,7 @@ export function WhatsAppConfig() {
className="bg-muted border-border text-foreground placeholder:text-muted-foreground tracking-widest" className="bg-muted border-border text-foreground placeholder:text-muted-foreground tracking-widest"
/> />
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
Needed only to wire <strong className="text-muted-foreground">inbound</strong> messages <span dangerouslySetInnerHTML={{ __html: t('pinHint') }} />
for a <strong className="text-muted-foreground">production</strong> number. Set it in{' '}
<strong className="text-muted-foreground">
Meta Business Manager → WhatsApp Accounts → Phone
Numbers → Two-step verification
</strong>
, then paste it here so wacrm can subscribe the number —
otherwise Meta routes inbound events to whichever app
last claimed it (the symptom that hits second numbers
under a shared WABA).{' '}
<strong className="text-muted-foreground">Meta test numbers</strong> have no
PIN and are pre-registered — leave this blank for them.
Leaving it blank also keeps an existing registration
untouched.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -672,14 +656,14 @@ export function WhatsAppConfig() {
{/* Webhook URL */} {/* Webhook URL */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground">Webhook Configuration</CardTitle> <CardTitle className="text-foreground">{t('webhookTitle')}</CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Use this URL as your webhook callback in the Meta App Dashboard. {t('webhookDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Webhook Callback URL</Label> <Label className="text-muted-foreground">{t('webhookUrl')}</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
readOnly readOnly
@ -709,10 +693,10 @@ export function WhatsAppConfig() {
{saving ? ( {saving ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Saving... {t('saving')}
</> </>
) : ( ) : (
'Save Configuration' t('saveConfig')
)} )}
</Button> </Button>
<Button <Button
@ -724,12 +708,12 @@ export function WhatsAppConfig() {
{testing ? ( {testing ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Testing... {t('testing')}
</> </>
) : ( ) : (
<> <>
<Zap className="size-4" /> <Zap className="size-4" />
Test API Connection {t('testConnection')}
</> </>
)} )}
</Button> </Button>
@ -743,12 +727,12 @@ export function WhatsAppConfig() {
{resetting ? ( {resetting ? (
<> <>
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Resetting... {t('resetting')}
</> </>
) : ( ) : (
<> <>
<RotateCcw className="size-4" /> <RotateCcw className="size-4" />
Reset Configuration {t('resetConfig')}
</> </>
)} )}
</Button> </Button>
@ -760,9 +744,9 @@ export function WhatsAppConfig() {
<div> <div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-foreground text-base">Setup Instructions</CardTitle> <CardTitle className="text-foreground text-base">{t('setupInstructions')}</CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
Follow these steps to connect your WhatsApp Business API. {t('setupInstructionsDesc')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -771,15 +755,15 @@ export function WhatsAppConfig() {
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline"> <AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">1</span> <span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">1</span>
Create a Meta App {t('step1')}
</span> </span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-muted-foreground"> <AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm"> <ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to <span className="text-primary">developers.facebook.com</span></li> <li dangerouslySetInnerHTML={{ __html: t('step1_1') }} />
<li>Click &quot;My Apps&quot; and then &quot;Create App&quot;</li> <li>{t('step1_2')}</li>
<li>Select &quot;Business&quot; as the app type</li> <li>{t('step1_3')}</li>
<li>Fill in app details and create</li> <li>{t('step1_4')}</li>
</ol> </ol>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@ -788,14 +772,14 @@ export function WhatsAppConfig() {
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline"> <AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">2</span> <span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">2</span>
Add WhatsApp Product {t('step2')}
</span> </span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-muted-foreground"> <AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm"> <ol className="list-decimal list-inside space-y-1 text-sm">
<li>In your app dashboard, click &quot;Add Product&quot;</li> <li>{t('step2_1')}</li>
<li>Find &quot;WhatsApp&quot; and click &quot;Set Up&quot;</li> <li>{t('step2_2')}</li>
<li>Follow the setup wizard to link your business</li> <li>{t('step2_3')}</li>
</ol> </ol>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@ -804,15 +788,15 @@ export function WhatsAppConfig() {
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline"> <AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">3</span> <span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">3</span>
Get API Credentials {t('step3')}
</span> </span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-muted-foreground"> <AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm"> <ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to WhatsApp &gt; API Setup</li> <li>{t('step3_1')}</li>
<li>Copy your <strong className="text-foreground">Phone Number ID</strong></li> <li dangerouslySetInnerHTML={{ __html: t('step3_2') }} />
<li>Copy your <strong className="text-foreground">WhatsApp Business Account ID</strong></li> <li dangerouslySetInnerHTML={{ __html: t('step3_3') }} />
<li>Generate a <strong className="text-foreground">Permanent Access Token</strong> from Business Settings &gt; System Users</li> <li dangerouslySetInnerHTML={{ __html: t('step3_4') }} />
</ol> </ol>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@ -821,16 +805,16 @@ export function WhatsAppConfig() {
<AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline"> <AccordionTrigger className="text-muted-foreground hover:text-foreground hover:no-underline">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">4</span> <span className="flex size-5 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">4</span>
Configure Webhooks {t('step4')}
</span> </span>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-muted-foreground"> <AccordionContent className="text-muted-foreground">
<ol className="list-decimal list-inside space-y-1 text-sm"> <ol className="list-decimal list-inside space-y-1 text-sm">
<li>Go to WhatsApp &gt; Configuration</li> <li>{t('step4_1')}</li>
<li>Click &quot;Edit&quot; on the Webhook section</li> <li>{t('step4_2')}</li>
<li>Paste the <strong className="text-foreground">Webhook Callback URL</strong> from above</li> <li dangerouslySetInnerHTML={{ __html: t('step4_3') }} />
<li>Enter the same <strong className="text-foreground">Verify Token</strong> you set here</li> <li dangerouslySetInnerHTML={{ __html: t('step4_4') }} />
<li>Subscribe to &quot;messages&quot; webhook field</li> <li>{t('step4_5')}</li>
</ol> </ol>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@ -844,7 +828,7 @@ export function WhatsAppConfig() {
className="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors" className="inline-flex items-center gap-1.5 text-sm text-primary hover:text-primary/80 transition-colors"
> >
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
Meta WhatsApp API Documentation {t('metaDocs')}
</a> </a>
</div> </div>
</CardContent> </CardContent>

View File

@ -25,51 +25,51 @@ export interface StatusDisplay {
export const broadcastStatusConfig: Record<BroadcastStatus, StatusDisplay> = { export const broadcastStatusConfig: Record<BroadcastStatus, StatusDisplay> = {
draft: { draft: {
label: "Draft", label: "draft",
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20", classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
}, },
scheduled: { scheduled: {
label: "Scheduled", label: "scheduled",
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20", classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
}, },
sending: { sending: {
label: "Sending", label: "sending",
classes: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", classes: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
pulse: true, pulse: true,
}, },
sent: { sent: {
label: "Sent", label: "sent",
classes: "bg-primary/10 text-primary border-primary/20", classes: "bg-primary/10 text-primary border-primary/20",
}, },
failed: { failed: {
label: "Failed", label: "failed",
classes: "bg-red-500/10 text-red-400 border-red-500/20", classes: "bg-red-500/10 text-red-400 border-red-500/20",
}, },
}; };
export const recipientStatusConfig: Record<RecipientStatus, StatusDisplay> = { export const recipientStatusConfig: Record<RecipientStatus, StatusDisplay> = {
pending: { pending: {
label: "Pending", label: "pending",
classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20", classes: "bg-slate-500/10 text-muted-foreground border-slate-500/20",
}, },
sent: { sent: {
label: "Sent", label: "sent",
classes: "bg-blue-500/10 text-blue-400 border-blue-500/20", classes: "bg-blue-500/10 text-blue-400 border-blue-500/20",
}, },
delivered: { delivered: {
label: "Delivered", label: "delivered",
classes: "bg-primary/10 text-primary border-primary/20", classes: "bg-primary/10 text-primary border-primary/20",
}, },
read: { read: {
label: "Read", label: "read",
classes: "bg-primary/10 text-primary border-primary/20", classes: "bg-primary/10 text-primary border-primary/20",
}, },
replied: { replied: {
label: "Replied", label: "replied",
classes: "bg-purple-500/10 text-purple-400 border-purple-500/20", classes: "bg-purple-500/10 text-purple-400 border-purple-500/20",
}, },
failed: { failed: {
label: "Failed", label: "failed",
classes: "bg-red-500/10 text-red-400 border-red-500/20", classes: "bg-red-500/10 text-red-400 border-red-500/20",
}, },
}; };