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

View File

@ -10,6 +10,7 @@ import {
ChevronDown,
ChevronRight,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { createClient } from "@/lib/supabase/client"
import type {
@ -28,6 +29,7 @@ export default function AutomationLogsPage({
}) {
const { id } = use(params)
const router = useRouter()
const t = useTranslations("Automations.logs")
const [automation, setAutomation] = useState<Automation | null>(null)
const [logs, setLogs] = useState<AutomationLog[] | null>(null)
@ -56,7 +58,7 @@ export default function AutomationLogsPage({
setAutomation(autRes.data as Automation | null)
setLogs((logRes.data ?? []) as AutomationLog[])
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load logs")
setError(err instanceof Error ? err.message : t("loadError"))
}
}
load()
@ -67,7 +69,7 @@ export default function AutomationLogsPage({
<div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-red-400">{error}</p>
<Button variant="outline" onClick={() => router.push("/automations")}>
Back
{t("back")}
</Button>
</div>
)
@ -88,21 +90,21 @@ export default function AutomationLogsPage({
type="button"
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"
aria-label="Back"
aria-label={t("backAria")}
>
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<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>
{logs.length === 0 ? (
<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">
Trigger this automation to see runs here.
{t("emptyDesc")}
</p>
</div>
) : (
@ -124,14 +126,14 @@ export default function AutomationLogsPage({
) : (
<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="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 className="truncate text-xs text-muted-foreground">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step
{log.steps_executed?.length === 1 ? "" : "s"}
{log.trigger_event} · {log.steps_executed?.length ?? 0}{" "}
{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 className="text-xs text-muted-foreground">
@ -150,7 +152,7 @@ export default function AutomationLogsPage({
<StepRow key={i} result={r} />
))}
{(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>
</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 =
status === "success"
? "border-primary/30 bg-primary/10 text-primary"
@ -178,7 +180,7 @@ function StatusBadge({ status }: { status: AutomationLog["status"] }) {
classes,
)}
>
{status}
{t(`status.${status}`)}
</span>
)
}

View File

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

View File

@ -38,6 +38,7 @@ import {
getBroadcastStatus,
getRecipientStatus,
} from '@/lib/broadcast-status';
import { useTranslations } from 'next-intl';
interface StatCardProps {
label: string;
@ -144,6 +145,8 @@ function downloadBlob(filename: string, content: string) {
export default function BroadcastDetailPage() {
const params = useParams();
const router = useRouter();
const t = useTranslations('Broadcasts.detail');
const tStatus = useTranslations('Broadcasts.status');
const broadcastId = params.id as string;
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
@ -179,7 +182,7 @@ export default function BroadcastDetailPage() {
if (recsError) throw recsError;
setRecipients(recs ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
setError(err instanceof Error ? err.message : t('notFound'));
} finally {
setLoading(false);
}
@ -199,14 +202,13 @@ export default function BroadcastDetailPage() {
function handleExport() {
if (!broadcast) return;
const header = [
'Contact',
'Phone',
'Status',
'Sent At',
'Delivered At',
'Read At',
'Replied At',
'Error',
t('table.contact'),
t('table.phone'),
t('table.status'),
t('table.sent'),
t('table.delivered'),
t('table.read'),
t('table.error'),
];
const rows = recipients.map((r) => [
r.contact?.name ?? '',
@ -215,7 +217,6 @@ export default function BroadcastDetailPage() {
r.sent_at ?? '',
r.delivered_at ?? '',
r.read_at ?? '',
r.replied_at ?? '',
r.error_message ?? '',
]);
const csv = toCsv([header, ...rows]);
@ -236,10 +237,10 @@ export default function BroadcastDetailPage() {
.eq('id', broadcastId);
setDeleting(false);
if (delErr) {
toast.error(`Failed to delete: ${delErr.message}`);
toast.error(t('toastFailedDelete', { error: delErr.message }));
return;
}
toast.success('Broadcast deleted');
toast.success(t('toastDeleted'));
router.push('/broadcasts');
}
@ -254,9 +255,9 @@ export default function BroadcastDetailPage() {
if (error || !broadcast) {
return (
<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')}>
Back to Broadcasts
{t('backToBroadcasts')}
</Button>
</div>
);
@ -265,10 +266,10 @@ export default function BroadcastDetailPage() {
const status = getBroadcastStatus(broadcast.status);
const funnelSteps: FunnelStep[] = [
{ label: 'Sent', value: broadcast.sent_count, color: 'bg-primary' },
{ label: 'Delivered', value: broadcast.delivered_count, color: 'bg-teal-500' },
{ label: 'Read', value: broadcast.read_count, color: 'bg-blue-500' },
{ label: 'Replied', value: broadcast.replied_count, color: 'bg-indigo-500' },
{ label: t('stats.sent'), value: broadcast.sent_count, color: 'bg-primary' },
{ label: t('stats.delivered'), value: broadcast.delivered_count, color: 'bg-teal-500' },
{ label: t('stats.read'), value: broadcast.read_count, color: 'bg-blue-500' },
{ label: t('stats.replied'), value: broadcast.replied_count, color: 'bg-indigo-500' },
];
return (
@ -290,14 +291,14 @@ export default function BroadcastDetailPage() {
<span
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>
</div>
<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>
Created {new Date(broadcast.created_at).toLocaleDateString()}
{t('createdAt', { date: new Date(broadcast.created_at).toLocaleDateString() })}
</span>
</div>
</div>
@ -309,7 +310,7 @@ export default function BroadcastDetailPage() {
funnel inconsistent. */}
{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">
<span className="text-red-300">Delete this broadcast?</span>
<span className="text-red-300">{t('deletePrompt')}</span>
<Button
variant="outline"
size="sm"
@ -317,7 +318,7 @@ export default function BroadcastDetailPage() {
disabled={deleting}
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
{t('cancel')}
</Button>
<Button
size="sm"
@ -325,7 +326,7 @@ export default function BroadcastDetailPage() {
disabled={deleting}
className="h-7 bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? 'Deleting…' : 'Confirm'}
{deleting ? t('deleting') : t('confirm')}
</Button>
</div>
) : (
@ -336,13 +337,13 @@ export default function BroadcastDetailPage() {
onClick={() => setConfirmDelete(true)}
title={
broadcast.status === 'sending'
? 'Cannot delete while a broadcast is actively sending'
: 'Delete this broadcast'
? t('cannotDeleteSending')
: t('deleteHover')
}
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" />
Delete
{t('delete')}
</Button>
)}
</div>
@ -350,42 +351,42 @@ export default function BroadcastDetailPage() {
{/* 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">
<StatCard
label="Total Recipients"
label={t('stats.totalRecipients')}
value={broadcast.total_recipients}
total={broadcast.total_recipients}
icon={<Users className="h-4 w-4" />}
color="bg-muted text-muted-foreground"
/>
<StatCard
label="Sent"
label={t('stats.sent')}
value={broadcast.sent_count}
total={broadcast.total_recipients}
icon={<Send className="h-4 w-4" />}
color="bg-primary/10 text-primary"
/>
<StatCard
label="Delivered"
label={t('stats.delivered')}
value={broadcast.delivered_count}
total={broadcast.total_recipients}
icon={<CheckCheck className="h-4 w-4" />}
color="bg-teal-500/10 text-teal-400"
/>
<StatCard
label="Read"
label={t('stats.read')}
value={broadcast.read_count}
total={broadcast.total_recipients}
icon={<Eye className="h-4 w-4" />}
color="bg-blue-500/10 text-blue-400"
/>
<StatCard
label="Replied"
label={t('stats.replied')}
value={broadcast.replied_count}
total={broadcast.total_recipients}
icon={<MessageCircle className="h-4 w-4" />}
color="bg-indigo-500/10 text-indigo-400"
/>
<StatCard
label="Failed"
label={t('stats.failed')}
value={broadcast.failed_count}
total={broadcast.total_recipients}
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="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">
Recipients ({filteredRecipients.length}
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
{statusFilter !== 'all'
? t('recipientsHeader', { filtered: filteredRecipients.length, total: recipients.length })
: t('recipientsHeaderAll', { total: recipients.length })}
</h2>
<div className="flex items-center gap-2">
<DropdownMenu>
@ -415,8 +417,8 @@ export default function BroadcastDetailPage() {
>
<Filter className="h-3.5 w-3.5" />
{statusFilter === 'all'
? 'All statuses'
: getRecipientStatus(statusFilter).label}
? t('allStatuses')
: tStatus(getRecipientStatus(statusFilter).label)}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="border-border bg-popover">
@ -426,7 +428,7 @@ export default function BroadcastDetailPage() {
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
}
>
All statuses
{t('allStatuses')}
</DropdownMenuItem>
{RECIPIENT_STATUSES.map((s) => (
<DropdownMenuItem
@ -438,7 +440,7 @@ export default function BroadcastDetailPage() {
: 'text-popover-foreground'
}
>
{getRecipientStatus(s).label}
{tStatus(getRecipientStatus(s).label)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@ -452,7 +454,7 @@ export default function BroadcastDetailPage() {
className="border-border text-muted-foreground hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Export CSV
{t('exportCsv')}
</Button>
</div>
</div>
@ -461,8 +463,8 @@ export default function BroadcastDetailPage() {
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground">
{recipients.length === 0
? 'No recipients found.'
: 'No recipients match this filter.'}
? t('noRecipients')
: t('noRecipientsFilter')}
</p>
</div>
) : (
@ -470,13 +472,13 @@ export default function BroadcastDetailPage() {
<Table>
<TableHeader>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Contact</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="text-muted-foreground">Sent</TableHead>
<TableHead className="text-muted-foreground">Delivered</TableHead>
<TableHead className="text-muted-foreground">Read</TableHead>
<TableHead className="text-muted-foreground">Error</TableHead>
<TableHead className="text-muted-foreground">{t('table.contact')}</TableHead>
<TableHead className="text-muted-foreground">{t('table.phone')}</TableHead>
<TableHead className="text-muted-foreground">{t('table.status')}</TableHead>
<TableHead className="text-muted-foreground">{t('table.sent')}</TableHead>
<TableHead className="text-muted-foreground">{t('table.delivered')}</TableHead>
<TableHead className="text-muted-foreground">{t('table.read')}</TableHead>
<TableHead className="text-muted-foreground">{t('table.error')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -494,7 +496,7 @@ export default function BroadcastDetailPage() {
<span
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>
</TableCell>
<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 { useBroadcastSending } from '@/hooks/use-broadcast-sending';
import { Check } from 'lucide-react';
import { useTranslations } from 'next-intl';
const steps = [
{ label: 'Template', key: 'template' },
{ label: 'Audience', key: 'audience' },
{ label: 'Personalize', key: 'personalize' },
{ label: 'Send', key: 'send' },
{ label: 'template', key: 'template' },
{ label: 'audience', key: 'audience' },
{ label: 'personalize', key: 'personalize' },
{ label: 'send', key: 'send' },
] as const;
export default function NewBroadcastPage() {
const router = useRouter();
const t = useTranslations('Broadcasts.new');
const { accountId } = useAuth();
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
@ -82,7 +84,7 @@ export default function NewBroadcastPage() {
*/
async function handleSaveDraft() {
if (!template || !name.trim()) {
toast.error('Give the broadcast a name before saving a draft.');
toast.error(t('toastGiveName'));
return;
}
const supabase = createClient();
@ -91,11 +93,11 @@ export default function NewBroadcastPage() {
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) {
toast.error('Not signed in.');
toast.error(t('toastNotSignedIn'));
return;
}
if (!accountId) {
toast.error('Your profile is not linked to an account.');
toast.error(t('toastNotLinked'));
return;
}
@ -120,10 +122,10 @@ export default function NewBroadcastPage() {
});
if (error) {
toast.error(`Failed to save draft: ${error.message}`);
toast.error(t('toastFailedDraft', { error: error.message }));
return;
}
toast.success('Draft saved');
toast.success(t('toastDraftSaved'));
router.push('/broadcasts');
}
@ -131,9 +133,9 @@ export default function NewBroadcastPage() {
<div className="mx-auto max-w-3xl space-y-8">
{/* Header */}
<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">
Create and send a broadcast message to your contacts.
{t('subtitle')}
</p>
</div>
@ -162,7 +164,7 @@ export default function NewBroadcastPage() {
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
}`}
>
{step.label}
{t(`steps.${step.label}`)}
</span>
</div>
{index < steps.length - 1 && (

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,8 @@ import {
import { toast } from "sonner";
import { format, formatDistanceToNow } from "date-fns";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@ -96,6 +98,8 @@ const STATUS_META: Record<
export default function FlowRunsPage() {
const router = useRouter();
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 [runs, setRuns] = useState<RunRow[]>([]);
@ -128,7 +132,7 @@ export default function FlowRunsPage() {
} catch (err) {
if (!cancelled) {
console.error(err);
toast.error("Couldn't load runs.");
toast.error(t("loadError"));
}
} finally {
if (!cancelled) setLoading(false);
@ -158,13 +162,13 @@ export default function FlowRunsPage() {
if (notFound || !flow) {
return (
<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
type="button"
onClick={() => router.push("/flows")}
className="text-sm text-primary hover:opacity-80"
>
← Back to flows
{tEdit("backToFlows")}
</button>
</div>
);
@ -180,16 +184,14 @@ export default function FlowRunsPage() {
<ArrowLeft className="h-3 w-3" />
{flow.name}
</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">
The 50 most recent times this flow ran. Expand a row to see the engine&apos;s
per-step log.
{t("description")}
</p>
{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">
No runs yet. Trigger the flow from a personal WhatsApp number to see
it appear here.
{t("emptyState")}
</div>
) : (
<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)}
expanded={expanded.has(run.id)}
onToggle={() => toggle(run.id)}
t={t}
/>
))}
</div>
@ -213,16 +216,18 @@ function RunCard({
events,
expanded,
onToggle,
t,
}: {
run: RunRow;
events: EventRow[];
expanded: boolean;
onToggle: () => void;
t: ReturnType<typeof useTranslations>;
}) {
const meta = STATUS_META[run.status];
const StatusIcon = meta.icon;
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
? formatDistanceToNow(new Date(run.ended_at), {
addSuffix: false,
@ -247,20 +252,32 @@ function RunCard({
</span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
<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>
{run.status === "active" && run.current_node_key && (
<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>
)}
</div>
<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 && (
<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>
</button>
@ -269,7 +286,7 @@ function RunCard({
{Object.keys(run.vars).length > 0 && (
<details className="mb-3">
<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>
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
{JSON.stringify(run.vars, null, 2)}
@ -279,7 +296,7 @@ function RunCard({
<div className="flex flex-col gap-1">
{events.length === 0 ? (
<p className="text-xs text-muted-foreground">
No events recorded for this run.
{t("noEvents")}
</p>
) : (
events.map((ev, ix) => <EventLine key={ix} ev={ev} />)

View File

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

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import { createClient } from '@/lib/supabase/client';
import { MessageTemplate } from '@/types';
import { Button } from '@/components/ui/button';
import { Loader2, FileText, ArrowRight } from 'lucide-react';
import { useTranslations } from 'next-intl';
const categoryColors: Record<string, string> = {
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) {
const t = useTranslations('Broadcasts.wizard');
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -40,7 +42,7 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
if (fetchError) throw fetchError;
setTemplates(data ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load templates');
setError(err instanceof Error ? err.message : t('chooseTemplate.errorLoad'));
} finally {
setLoading(false);
}
@ -68,17 +70,17 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
return (
<div className="space-y-6">
<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">
Select an approved message template for your broadcast.
{t('chooseTemplate.subtitle')}
</p>
</div>
{templates.length === 0 ? (
<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" />
<p className="text-sm text-muted-foreground">No templates available.</p>
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p>
<p className="text-sm text-muted-foreground">{t('chooseTemplate.noTemplates')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('chooseTemplate.createFirst')}</p>
</div>
) : (
<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">
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
Back
{t('back')}
</Button>
<Button
onClick={onNext}
disabled={!selectedTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
{t('next')}
<ArrowRight className="h-4 w-4" />
</Button>
</div>

View File

@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { createClient } from '@/lib/supabase/client';
import { CustomField, Tag } from '@/types';
import { Button } from '@/components/ui/button';
@ -14,6 +14,7 @@ import {
ArrowLeft,
X,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
type CustomFieldOperator = 'is' | 'is_not' | 'contains';
@ -39,50 +40,51 @@ interface Step2Props {
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({
audience,
onUpdate,
onNext,
onBack,
}: 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 [customFields, setCustomFields] = useState<CustomField[]>([]);
const [loadingTags, setLoadingTags] = useState(false);
@ -249,14 +251,14 @@ export function Step2SelectAudience({
return (
<div className="space-y-6">
<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">
Choose who will receive this broadcast.
{t('selectAudience.subtitle')}
</p>
</div>
<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 Icon = option.icon;
return (
@ -305,12 +307,12 @@ export function Step2SelectAudience({
{audience.type === 'tags' && (
<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 ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground">
No tags found. Create tags in Settings.
{t('selectAudience.noTagsFound')}
</p>
) : (
<div className="flex flex-wrap gap-2">
@ -341,12 +343,12 @@ export function Step2SelectAudience({
{audience.type === 'custom_field' && (
<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 ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : customFields.length === 0 ? (
<p className="text-xs text-muted-foreground">
No custom fields defined. Create one in Settings → Custom Fields.
{t('selectAudience.errorLoadFields')}
</p>
) : (
<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 })}
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) => (
<option key={f.id} value={f.id}>
{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"
>
{OPERATOR_OPTIONS.map((op) => (
{OPERATOR_OPTIONS.map((op: { value: CustomFieldOperator; label: string }) => (
<option key={op.value} value={op.value}>
{op.label}
</option>
@ -381,7 +383,7 @@ export function Step2SelectAudience({
type="text"
value={audience.customField?.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"
/>
</div>
@ -394,12 +396,11 @@ export function Step2SelectAudience({
<div className="mb-3 flex items-center gap-2">
<X className="h-4 w-4 text-red-400" />
<p className="text-sm font-medium text-foreground">
Exclude contacts with these tags
{t('selectAudience.excludeTags')}
</p>
<span className="text-xs text-muted-foreground">(optional)</span>
</div>
{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">
{tags.map((tag) => {
@ -456,14 +457,14 @@ export function Step2SelectAudience({
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
{t('back')}
</Button>
<Button
onClick={onNext}
disabled={!isValid}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next
{t('next')}
<ArrowRight className="h-4 w-4" />
</Button>
</div>

View File

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

View File

@ -15,6 +15,7 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { ArrowLeft, Send, Loader2, Users, Save } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface AudienceConfig {
type: string;
@ -45,6 +46,7 @@ export function Step4ScheduleSend({
isProcessing,
progress,
}: Step4Props) {
const t = useTranslations('Broadcasts.wizard');
const [showConfirm, setShowConfirm] = useState(false);
const [estimatedReach, setEstimatedReach] = useState<number>(0);
const [loadingReach, setLoadingReach] = useState(true);
@ -83,43 +85,43 @@ export function Step4ScheduleSend({
const audienceLabel =
audience.type === 'all'
? 'All Contacts'
? t('scheduleSend.audienceAll')
: audience.type === 'tags'
? `Tags (${audience.tagIds?.length ?? 0} selected)`
? t('scheduleSend.audienceTags')
: audience.type === 'csv'
? 'CSV Upload'
: 'Custom';
? t('scheduleSend.audienceCsv')
: t('scheduleSend.audienceField');
return (
<div className="space-y-6">
<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">
Name your broadcast, review the details, and send.
{t('scheduleSend.subtitle')}
</p>
</div>
{/* Broadcast Name */}
<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
value={name}
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"
/>
</div>
{/* Summary Card */}
<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>
<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>
</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>
</div>
<div>
@ -148,7 +150,7 @@ export function Step4ScheduleSend({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<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>
<span className="text-xs font-medium text-primary">{progress}%</span>
</div>
@ -169,7 +171,7 @@ export function Step4ScheduleSend({
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
{t('back')}
</Button>
<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"
>
<Save className="h-4 w-4" />
Save as Draft
{t('scheduleSend.saveDraft')}
</Button>
)}
@ -195,7 +197,7 @@ export function Step4ScheduleSend({
}
>
<Send className="h-4 w-4" />
Send Broadcast
{t('scheduleSend.sendNow')}
</DialogTrigger>
<DialogContent className="border-border bg-popover sm:max-w-md">
<DialogHeader>
@ -214,7 +216,7 @@ export function Step4ScheduleSend({
onClick={() => setShowConfirm(false)}
className="border-border text-muted-foreground"
>
Cancel
{t('cancel')}
</Button>
<Button
onClick={() => {
@ -224,7 +226,7 @@ export function Step4ScheduleSend({
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
<Send className="h-4 w-4" />
Confirm & Send
{t('scheduleSend.sendNow')}
</Button>
</DialogFooter>
</DialogContent>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,8 @@ import { BarChart3 } from 'lucide-react'
import type { ComponentType } from 'react'
import { cn } from '@/lib/utils'
import { useTranslations } from 'next-intl'
/**
* Shared empty-state panel for charts that can't render meaningfully
* without a minimum amount of data. Kept minimal and uniform so the
@ -9,7 +11,7 @@ import { cn } from '@/lib/utils'
* different widget.
*/
export function EmptyState({
title = 'Not enough data yet',
title,
hint,
icon: Icon = BarChart3,
className,
@ -19,6 +21,9 @@ export function EmptyState({
icon?: ComponentType<{ className?: string }>
className?: string
}) {
const t = useTranslations('Dashboard.emptyState')
const defaultTitle = t('title')
return (
<div
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">
<Icon className="h-5 w-5" />
</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>}
</div>
)

View File

@ -13,13 +13,16 @@ interface PipelineDonutProps {
currency: string
}
import { useTranslations } from 'next-intl'
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
const t = useTranslations('Dashboard.pipelineDonut')
return (
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<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">
Open deals by stage
{t('description')}
</p>
</header>
@ -29,8 +32,8 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
) : data.stages.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No open deals yet"
hint="Create deals in Pipelines to see stage breakdowns here."
title={t('noOpenDeals')}
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="text-muted-foreground tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
{t('dealCount', { count: s.dealCount })}
</span>
<span className="w-20 text-right text-muted-foreground tabular-nums">
{formatCurrencyShort(s.totalValue, currency)}
@ -67,6 +70,7 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
// them for a cleaner look.
// ------------------------------------------------------------
function Donut({ data, currency }: { data: PipelineDonutData; currency: string }) {
const t = useTranslations('Dashboard.pipelineDonut')
const size = 200
const r = 80
const ringWidth = 18
@ -96,7 +100,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
return (
<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 */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
{segments.map((seg) => (
@ -116,7 +120,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
Total
{t('total')}
</text>
<text
x={cx}

View File

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

View File

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

View File

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

View File

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

View File

@ -35,6 +35,7 @@ import { ValidationPanel } from "./validation-panel";
import { NODE_META, nodeColors, type NodeType } from "./shared";
import { cn } from "@/lib/utils";
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.
@ -59,6 +60,8 @@ interface Props {
}
export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
const t = useTranslations("Flows.builder");
// Read the persisted choice in the useState initializer. Safe even
// though this is a client component because the parent page only
// 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"}
onClick={() => choose("canvas")}
icon={<GitFork className="h-3.5 w-3.5" />}
label="Canvas"
label={t("canvasView")}
/>
<SegButton
active={effectiveView === "list"}
onClick={() => choose("list")}
icon={<List className="h-3.5 w-3.5" />}
label="List"
label={t("listView")}
/>
</div>
<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
key={t}
key={t_type}
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
>
<span
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>
))}
</div>

View File

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

View File

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

View File

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

View File

@ -18,12 +18,14 @@
*/
import { CircleAlert, CircleCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { ValidationIssue } from "@/lib/flows/validate";
import { useFlowEditor } from "./flow-editor-state";
export function ValidationPanel() {
const { issues, requestFlash } = useFlowEditor();
const t = useTranslations("Flows.validation");
if (issues.length === 0) {
// Slate-950 base + emerald accents so the panel stays readable when
@ -32,7 +34,7 @@ export function ValidationPanel() {
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">
<CircleCheck className="h-4 w-4 shrink-0" />
No issues. Ready to activate.
{t("noIssues")}
</div>
);
}
@ -51,12 +53,11 @@ export function ValidationPanel() {
) : (
<CircleAlert className="h-4 w-4 text-amber-400" />
)}
{errors.length} error{errors.length === 1 ? "" : "s"},{" "}
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
{t("summary", { errorCount: errors.length, warningCount: warnings.length })}
</div>
<div className="flex flex-col gap-1">
{issues.map((i, ix) => (
<IssueLine key={ix} issue={i} onJump={requestFlash} />
<IssueLine key={ix} issue={i} onJump={requestFlash} t={t} />
))}
</div>
</div>
@ -72,9 +73,11 @@ export function ValidationPanel() {
export function IssueLine({
issue,
onJump,
t,
}: {
issue: ValidationIssue;
onJump?: (key: string) => void;
t?: ReturnType<typeof useTranslations>;
}) {
const tone =
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",
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}
</button>

View File

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

View File

@ -19,12 +19,16 @@ import {
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { format } from "date-fns";
import { useTranslations } from "next-intl";
interface ContactSidebarProps {
contact: Contact | null;
}
export function ContactSidebar({ contact }: ContactSidebarProps) {
const tSidebar = useTranslations("Inbox.sidebar");
const tThread = useTranslations("Inbox.messageThread");
const { accountId } = useAuth();
const [copied, setCopied] = useState(false);
const [deals, setDeals] = useState<Deal[]>([]);
@ -118,7 +122,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
if (!contact) {
return (
<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>
);
}
@ -181,11 +185,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div>
<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" />
Tags
{tSidebar("tags")}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{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) => (
<span
@ -210,11 +214,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div>
<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" />
Active Deals
{tSidebar("deals")}
</div>
<div className="mt-2 space-y-2">
{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) => (
<div
@ -254,14 +258,14 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div>
<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" />
Notes
{tSidebar("notes")}
</div>
<div className="mt-2">
<div className="flex gap-2">
<textarea
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder="Add a note..."
placeholder={tSidebar("addNotePlaceholder")}
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"
/>

View File

@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
import type { Conversation, ConversationStatus, Tag } from "@/types";
import { Search, ChevronDown, X } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
@ -41,15 +42,9 @@ const STATUS_COLORS: Record<ConversationStatus, string> = {
closed: "bg-muted-foreground",
};
type InboxFilter = ConversationStatus | "all" | "unread";
const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = [
{ label: "All", value: "all" },
{ label: "Unread", value: "unread" },
{ label: "Open", value: "open" },
{ label: "Pending", value: "pending" },
{ label: "Closed", value: "closed" },
];
type InboxFilter = ConversationStatus | "all" | "unread";
export function ConversationList({
activeConversationId,
@ -58,6 +53,16 @@ export function ConversationList({
onConversationsLoaded,
resyncToken = 0,
}: 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 [filter, setFilter] = useState<InboxFilter>("all");
const [loading, setLoading] = useState(true);
@ -226,7 +231,7 @@ export function ConversationList({
<Input
value={search}
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"
/>
</div>
@ -234,7 +239,7 @@ export function ConversationList({
<div className="flex flex-wrap items-center gap-1">
<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">
{activeFilter?.label ?? "All"}
{activeFilter?.label ?? t("filterAll")}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
@ -268,7 +273,7 @@ export function ConversationList({
: "text-muted-foreground hover:text-foreground"
)}
>
Tags
{t("tags")}
{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">
{selectedTagIds.length}
@ -310,7 +315,7 @@ export function ConversationList({
: "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" />
</DropdownMenuTrigger>
<DropdownMenuContent
@ -326,7 +331,7 @@ export function ConversationList({
: "text-popover-foreground"
)}
>
All companies
{t("allCompanies")}
</DropdownMenuItem>
{companies.map((co) => (
<DropdownMenuItem
@ -361,7 +366,7 @@ export function ConversationList({
className="h-1.5 w-1.5 shrink-0 rounded-full"
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" />
</button>
);
@ -379,7 +384,7 @@ export function ConversationList({
onClick={clearContactFilters}
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"
>
Clear all
{t("clearAll")}
</button>
</div>
)}
@ -398,7 +403,7 @@ export function ConversationList({
</div>
) : filtered.length === 0 ? (
<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 className="flex flex-col">
@ -408,6 +413,7 @@ export function ConversationList({
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
t={t}
/>
))}
</div>
@ -421,15 +427,17 @@ interface ConversationItemProps {
conversation: Conversation;
isActive: boolean;
onSelect: (conversation: Conversation) => void;
t: ReturnType<typeof useTranslations>;
}
function ConversationItem({
conversation,
isActive,
onSelect,
t,
}: ConversationItemProps) {
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 handleClick = useCallback(() => {
@ -473,7 +481,7 @@ function ConversationItem({
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className="truncate text-xs text-muted-foreground">
{conversation.last_message_text || "No messages yet"}
{conversation.last_message_text || t("noMessagesYet")}
</p>
<div className="flex shrink-0 items-center gap-1.5">
{conversation.unread_count > 0 && (

View File

@ -10,6 +10,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import type { Message } from "@/types";
import { useTranslations } from "next-intl";
// 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.
@ -33,6 +34,8 @@ export function MessageActions({
onReact,
children,
}: MessageActionsProps) {
const t = useTranslations("Inbox.actions");
// Touch devices have no hover. Long-press fires `contextmenu`; we capture
// it, suppress the native menu, and pin the toolbar open until the user
// interacts elsewhere.
@ -50,14 +53,14 @@ export function MessageActions({
const handleCopy = async () => {
const text = message.content_text ?? "";
if (!text) {
toast.error("Nothing to copy");
toast.error(t("nothingToCopy"));
return;
}
try {
await navigator.clipboard.writeText(text);
toast.success("Copied");
toast.success(t("copied"));
} catch {
toast.error("Copy failed");
toast.error(t("copyFailed"));
}
setTouchOpen(false);
};
@ -104,7 +107,7 @@ export function MessageActions({
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
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" />
</PopoverTrigger>
@ -118,7 +121,7 @@ export function MessageActions({
type="button"
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"
aria-label={`React with ${e}`}
aria-label={t("reactWith", { emoji: e })}
>
{e}
</button>
@ -129,7 +132,7 @@ export function MessageActions({
type="button"
onClick={handleReply}
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" />
</button>
@ -137,7 +140,7 @@ export function MessageActions({
type="button"
onClick={handleCopy}
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" />
</button>

View File

@ -18,6 +18,7 @@ import {
import { format } from "date-fns";
import { ReplyQuote } from "./reply-quote";
import { MessageReactions } from "./message-reactions";
import { useTranslations } from "next-intl";
interface MessageBubbleProps {
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 (
<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" />
<span>{label} unavailable</span>
<span>{t("unavailable", { label })}</span>
</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) {
case "text":
return (
@ -132,7 +133,7 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? (
<MediaImage url={message.media_url} alt="Shared image" />
) : (
<MediaUnavailable label="Image" />
<MediaUnavailable label={t("photo")} t={t} />
)}
{message.content_text && (
<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"
/>
) : (
<MediaUnavailable label="Video" />
<MediaUnavailable label={t("video")} t={t} />
)}
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -168,14 +169,14 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? (
<audio src={message.media_url} controls className="max-w-60" />
) : (
<MediaUnavailable label="Audio" />
<MediaUnavailable label={t("audio")} t={t} />
)}
</div>
);
case "document":
if (!message.media_url) {
return <MediaUnavailable label={message.content_text || "Document"} />;
return <MediaUnavailable label={message.content_text || t("document")} t={t} />;
}
return (
<a
@ -186,7 +187,7 @@ function MessageContent({ message }: { message: Message }) {
>
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate">
{message.content_text || "Document"}
{message.content_text || t("document")}
</span>
</a>
);
@ -196,7 +197,7 @@ function MessageContent({ message }: { message: Message }) {
<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">
<LayoutTemplate className="h-3 w-3" />
Template
{t("template")}
</span>
{message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -210,7 +211,7 @@ function MessageContent({ message }: { message: Message }) {
return (
<div className="flex items-center gap-2 text-sm">
<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>
);
@ -224,10 +225,10 @@ function MessageContent({ message }: { message: Message }) {
<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">
<CornerDownLeft className="h-3 w-3" />
Button reply
{t("buttonReply")}
</span>
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Interactive reply]"}
{message.content_text || t("interactiveReply")}
</p>
</div>
);
@ -236,7 +237,7 @@ function MessageContent({ message }: { message: Message }) {
default:
return (
<p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Unsupported message type]"}
{message.content_text || t("unsupported")}
</p>
);
}
@ -249,6 +250,8 @@ export function MessageBubble({
currentUserId,
onToggleReaction,
}: MessageBubbleProps) {
const t = useTranslations("Inbox.bubble");
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
const time = format(new Date(message.created_at), "HH:mm");
@ -276,7 +279,7 @@ export function MessageBubble({
onPrimary={isAgent}
/>
)}
<MessageContent message={message} />
<MessageContent message={message} t={t} />
<div
className={cn(
"mt-1 flex items-center gap-1",
@ -290,10 +293,10 @@ export function MessageBubble({
{message.ai_generated && (
<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"
title="Sent automatically by the AI assistant"
title={t("aiBadgeTitle")}
>
<Sparkles className="h-2.5 w-2.5" />
AI
{t("aiBadge")}
</span>
)}
<span

View File

@ -37,6 +37,7 @@ import {
MEDIA_MAX_BYTES_BY_KIND,
} from "@/lib/storage/upload-media";
import { ReplyQuote } from "./reply-quote";
import { useTranslations } from "next-intl";
/** Media content types an agent can send from the composer. */
export type ComposerMediaKind = "image" | "video" | "document" | "audio";
@ -121,6 +122,8 @@ export function MessageComposer({
replyTo,
onClearReply,
}: MessageComposerProps) {
const t = useTranslations("Inbox.composer");
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const [drafting, setDrafting] = useState(false);
@ -437,7 +440,7 @@ export function MessageComposer({
{sessionExpired && (
<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">
24-hour session expired. Use a template to re-engage.
{t("sessionExpiredHint")}
</p>
<Button
variant="ghost"
@ -446,7 +449,7 @@ export function MessageComposer({
onClick={onOpenTemplates}
>
<LayoutTemplate className="mr-1 h-3 w-3" />
Templates
{t("templates")}
</Button>
</div>
)}
@ -491,27 +494,27 @@ export function MessageComposer({
onCaptionChange={setCaption}
onDiscard={discardDraft}
onSend={sendDraft}
t={t}
/>
) : recording ? (
// 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">
<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">
Recording… {formatDuration(recordSeconds)} /{" "}
{formatDuration(MAX_RECORDING_SECONDS)}
{t("recording", { current: formatDuration(recordSeconds), max: formatDuration(MAX_RECORDING_SECONDS) })}
</span>
<button
type="button"
onClick={cancelRecording}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
>
Cancel
{t("cancel")}
</button>
<Button
size="sm"
onClick={stopRecording}
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" />
</Button>
@ -524,10 +527,10 @@ export function MessageComposer({
disabled={inputsDisabled || busy}
title={
readOnly
? "Read-only — your role can't send messages"
? t("readOnlyTitle")
: inputsDisabled
? 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"
>
@ -540,19 +543,19 @@ export function MessageComposer({
<DropdownMenuContent align="start" className="border-border bg-popover">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
<ImageIcon className="mr-2 h-4 w-4" />
Photo
{t("photo")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
<Video className="mr-2 h-4 w-4" />
Video
{t("video")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" />
Document
{t("document")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void startRecording()}>
<Mic className="mr-2 h-4 w-4" />
Voice note
{t("voiceNote")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@ -562,7 +565,7 @@ export function MessageComposer({
size="sm"
canAct={!readOnly}
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"
onClick={onOpenTemplates}
>
@ -575,7 +578,7 @@ export function MessageComposer({
canAct={!readOnly}
gateReason="send messages"
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"
onClick={handleDraft}
>
@ -593,17 +596,17 @@ export function MessageComposer({
onKeyDown={handleKeyDown}
placeholder={
readOnly
? "Read-only — viewers can browse but not reply"
? t("readOnlyPlaceholder")
: sessionExpired
? "Session expired - use a template"
: "Type a message... (Shift+Enter for new line)"
? t("sessionExpiredPlaceholder")
: t("typeMessagePlaceholder")
}
disabled={sessionExpired || readOnly}
rows={1}
// Textarea keeps its own inline title — the GatedButton
// wrapping pattern doesn't apply to non-button inputs.
// 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(
"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"
@ -628,7 +631,7 @@ export function MessageComposer({
under the textarea left edge. */}
{!draft && !recording && (
<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>
)}
</div>
@ -648,6 +651,7 @@ function MediaDraftPreview({
onCaptionChange,
onDiscard,
onSend,
t,
}: {
draft: MediaDraft;
busy: boolean;
@ -655,6 +659,7 @@ function MediaDraftPreview({
onCaptionChange: (caption: string) => void;
onDiscard: () => void;
onSend: () => void;
t: ReturnType<typeof useTranslations>;
}) {
return (
<div className="rounded-xl border border-border bg-muted/40 p-3">
@ -684,7 +689,7 @@ function MediaDraftPreview({
<button
type="button"
onClick={onDiscard}
aria-label="Remove attachment"
aria-label={t("removeAttachment")}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
@ -703,7 +708,7 @@ function MediaDraftPreview({
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"
/>
)}

View File

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

View File

@ -3,6 +3,7 @@
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Message } from "@/types";
import { useTranslations } from "next-intl";
interface ReplyQuoteProps {
/** Sender label of the quoted message: "You" for our own messages,
@ -26,6 +27,7 @@ export function ReplyQuote({
onDismiss,
onPrimary = false,
}: ReplyQuoteProps) {
const t = useTranslations("Inbox.replyQuote");
const isChip = !!onDismiss;
return (
<div
@ -63,7 +65,7 @@ export function ReplyQuote({
<button
type="button"
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"
>
<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. */
export function buildReplyPreview(message: Message): string {
export function buildReplyPreview(message: Message, t: ReturnType<typeof useTranslations>): string {
if (message.content_text) return message.content_text;
switch (message.content_type) {
case "image":
return "[Image]";
return t("photo");
case "video":
return "[Video]";
return t("video");
case "audio":
return "[Audio]";
return t("audio");
case "document":
return "[Document]";
return t("document");
case "location":
return "[Location]";
return t("location");
case "template":
return "[Template]";
return t("template");
default:
return "[Message]";
return t("message");
}
}

View File

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

View File

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

View File

@ -5,6 +5,8 @@ import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
import { cn } from "@/lib/utils";
import { useTranslations } from "next-intl";
/**
* Light/dark mode toggle — a single icon button that flips the app
* 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.
*/
export function ModeToggle({ className }: { className?: string }) {
const t = useTranslations("ModeToggle");
const { mode, toggleMode } = useTheme();
const goingTo = mode === "dark" ? "light" : "dark";
const switchLabel = t("switchMode", { mode: goingTo });
return (
<button
type="button"
onClick={toggleMode}
aria-label={`Switch to ${goingTo} mode`}
title={`Switch to ${goingTo} mode`}
aria-label={switchLabel}
title={switchLabel}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className,

View File

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

View File

@ -3,6 +3,7 @@
import type { Deal, PipelineStage } from "@/types";
import { Calendar, Check, X } from "lucide-react";
import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface DealCardProps {
deal: Deal;
@ -26,7 +27,8 @@ function initials(name?: string, fallback?: string) {
}
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;
return (
@ -59,13 +61,13 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
{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">
<Check className="h-3 w-3" />
Won
{t("won")}
</span>
)}
{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">
<X className="h-3 w-3" />
Lost
{t("lost")}
</span>
)}
</div>

View File

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

View File

@ -19,6 +19,7 @@ import {
} from "@/components/ui/tooltip";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface PipelineAnalyticsProps {
stages: PipelineStage[];
@ -46,6 +47,7 @@ function computeStageProbability(
}
export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
const t = useTranslations("Pipelines.analytics");
const { defaultCurrency } = useAuth();
const sortedStages = useMemo(
() => [...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">
<Metric
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
label="Total Deals"
label={t("totalDeals")}
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
icon={<DollarSign className="h-4 w-4 text-primary" />}
label="Pipeline Value"
label={t("pipelineValue")}
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
icon={<Target className="h-4 w-4 text-blue-400" />}
label="Avg Deal Size"
label={t("avgDealSize")}
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
icon={<TrendingUp className="h-4 w-4 text-purple-400" />}
label="Weighted Value"
label={t("weightedValue")}
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
icon={<Trophy className="h-4 w-4 text-primary" />}
label="Won This Month"
label={t("wonThisMonth")}
value={String(stats.wonThisMonth)}
tooltip="Deals marked as Won since the first day of the current month."
tooltip={t("wonThisMonthTooltip")}
t={t}
/>
<Metric
icon={<XCircle className="h-4 w-4 text-red-400" />}
label="Lost This Month"
label={t("lostThisMonth")}
value={String(stats.lostThisMonth)}
tooltip="Deals marked as Lost since the first day of the current month."
tooltip={t("lostThisMonthTooltip")}
t={t}
/>
</div>
</TooltipProvider>
@ -140,11 +148,14 @@ function Metric({
label,
value,
tooltip,
t,
}: {
icon: React.ReactNode;
label: string;
value: string;
tooltip: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any;
}) {
return (
<div className="rounded-lg bg-muted/50 p-3">
@ -156,7 +167,7 @@ function Metric({
render={
<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"
/>
}

View File

@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { formatCurrency } from "@/lib/currency";
import { useTranslations } from "next-intl";
interface PipelineBoardProps {
stages: PipelineStage[];
@ -200,6 +201,7 @@ function StageColumn({
onAddDeal: (stageId: string) => void;
onEditDeal: (deal: Deal) => void;
}) {
const t = useTranslations("Pipelines.board");
const { setNodeRef, isOver } = useDroppable({ id: stage.id });
return (
@ -237,7 +239,7 @@ function StageColumn({
>
{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">
Drop a deal here
{t("dropDealHere")}
</div>
) : (
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"
>
<Plus className="mr-1 h-3 w-3" />
Add Deal
{t("addDeal")}
</Button>
</div>
);

View File

@ -35,6 +35,7 @@ import {
AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
const STAGE_COLORS = [
"#3b82f6",
@ -68,6 +69,7 @@ export function PipelineSettings({
onStagesChanged,
onCreateNewPipeline,
}: PipelineSettingsProps) {
const t = useTranslations("Pipelines.settings");
const supabase = createClient();
const [name, setName] = useState(pipeline.name);
@ -127,14 +129,14 @@ export function PipelineSettings({
setSaving(false);
if (renameRes.error || stagesRes.error) {
toast.error("Failed to save pipeline");
toast.error(t("toastFailedSave"));
return;
}
onOpenChange(false);
onPipelinesChanged();
onStagesChanged();
toast.success("Pipeline saved");
toast.success(t("toastSaved"));
}
async function handleAddStage() {
@ -151,7 +153,7 @@ export function PipelineSettings({
.select()
.single();
if (error || !data) {
toast.error("Failed to add stage");
toast.error(t("toastFailedAddStage"));
return;
}
setLocalStages([...localStages, data as PipelineStage]);
@ -166,7 +168,7 @@ export function PipelineSettings({
.select("id", { count: "exact", head: true })
.eq("stage_id", stageId);
if (count && count > 0) {
toast.error("Move or delete deals in this stage first");
toast.error(t("toastMoveOrDeleteDeals"));
return;
}
const { error } = await supabase
@ -174,7 +176,7 @@ export function PipelineSettings({
.delete()
.eq("id", stageId);
if (error) {
toast.error("Failed to delete stage");
toast.error(t("toastFailedDeleteStage"));
return;
}
setLocalStages(localStages.filter((s) => s.id !== stageId));
@ -189,19 +191,19 @@ export function PipelineSettings({
.eq("id", pipeline.id);
setDeleting(false);
if (error) {
toast.error("Failed to delete pipeline");
toast.error(t("toastFailedDeletePipeline"));
return;
}
onOpenChange(false);
onPipelinesChanged();
toast.success("Pipeline deleted");
toast.success(t("toastDeleted"));
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle>
<DialogTitle className="text-popover-foreground">{t("managePipeline")}</DialogTitle>
</DialogHeader>
{showDeleteConfirm ? (
@ -210,11 +212,10 @@ export function PipelineSettings({
<AlertTriangle className="h-5 w-5 shrink-0 text-red-400" />
<div>
<p className="text-sm font-medium text-red-400">
Delete Pipeline
{t("deletePipeline")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
This will archive all deals in this pipeline. This cannot be
undone.
{t("deletePipelineDesc")}
</p>
</div>
</div>
@ -224,14 +225,14 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
{t("cancel")}
</Button>
<Button
onClick={handleDeletePipeline}
disabled={deleting}
className="bg-red-600 text-white hover:bg-red-700"
>
{deleting ? "Deleting..." : "Delete Pipeline"}
{deleting ? t("deleting") : t("deletePipelineBtn")}
</Button>
</div>
</div>
@ -239,7 +240,7 @@ export function PipelineSettings({
<>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label className="text-muted-foreground">Pipeline Name</Label>
<Label className="text-muted-foreground">{t("pipelineName")}</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
@ -248,7 +249,7 @@ export function PipelineSettings({
</div>
<div className="grid gap-2">
<Label className="text-muted-foreground">Stages</Label>
<Label className="text-muted-foreground">{t("stages")}</Label>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@ -275,6 +276,7 @@ export function PipelineSettings({
}}
onRemove={() => handleRemoveStage(stage.id)}
colors={STAGE_COLORS}
t={t}
/>
))}
</div>
@ -304,7 +306,7 @@ export function PipelineSettings({
<Input
value={newStageName}
onChange={(e) => setNewStageName(e.target.value)}
placeholder="New stage name"
placeholder={t("newStageNamePlaceholder")}
className="border-border bg-muted text-sm text-foreground"
onKeyDown={(e) => {
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"
>
<Plus className="mr-1 h-3 w-3" />
Add
{t("add")}
</Button>
</div>
</div>
@ -329,7 +331,7 @@ export function PipelineSettings({
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
>
<Plus className="mr-1 h-3 w-3" />
Create a new pipeline
{t("createNewPipeline")}
</Button>
</div>
@ -339,21 +341,21 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(true)}
className="mr-auto bg-red-600 hover:bg-red-700"
>
Delete Pipeline
{t("deletePipeline")}
</Button>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
{t("cancel")}
</Button>
<Button
onClick={handleSave}
disabled={saving || !name.trim()}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{saving ? "Saving..." : "Save Changes"}
{saving ? t("saving") : t("saveChanges")}
</Button>
</DialogFooter>
</>
@ -369,12 +371,15 @@ function SortableStageRow({
onColorChange,
onRemove,
colors,
t,
}: {
stage: PipelineStage;
onNameChange: (v: string) => void;
onColorChange: (v: string) => void;
onRemove: () => void;
colors: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stage.id });
@ -396,11 +401,11 @@ function SortableStageRow({
{...attributes}
{...listeners}
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" />
</button>
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} />
<ColorSwatch value={stage.color} onChange={onColorChange} colors={colors} t={t} />
<Input
value={stage.name}
onChange={(e) => onNameChange(e.target.value)}
@ -422,10 +427,13 @@ function ColorSwatch({
value,
onChange,
colors,
t,
}: {
value: string;
onChange: (v: string) => void;
colors: string[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
t: any;
}) {
const [open, setOpen] = useState(false);
return (
@ -435,7 +443,7 @@ function ColorSwatch({
onClick={() => setOpen((v) => !v)}
className="h-4 w-4 rounded-full border border-border"
style={{ backgroundColor: value }}
aria-label="Change color"
aria-label={t("changeColor")}
/>
{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 { AccountMember } from '@/types';
import { fetchAccountMembers, memberLabel } from '@/lib/account/members';
import { useTranslations } from 'next-intl';
const MASKED_KEY = '••••••••••••••••';
@ -50,6 +51,7 @@ const KEY_PLACEHOLDER: Record<AiProvider, string> = {
export function AiConfig() {
const { accountId, accountRole, profileLoading } = useAuth();
const canEdit = accountRole ? canEditSettings(accountRole) : false;
const t = useTranslations('Settings.aiConfig');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -86,7 +88,7 @@ export function AiConfig() {
const res = await fetch('/api/ai/config');
const data = await res.json();
if (!res.ok) {
toast.error(data.error ?? 'Failed to load AI configuration');
toast.error(data.error ?? t('loadFailed'));
return;
}
if (data.configured) {
@ -106,7 +108,7 @@ export function AiConfig() {
setEmbeddingsKeyEdited(false);
}
} catch {
toast.error('Failed to load AI configuration');
toast.error(t('loadFailed'));
} finally {
setLoading(false);
}
@ -164,10 +166,10 @@ export function AiConfig() {
}),
});
const data = await res.json();
if (res.ok) toast.success('Key works — the provider responded.');
else toast.error(data.error ?? 'The provider rejected the request.');
if (res.ok) toast.success(t('testSuccess'));
else toast.error(data.error ?? t('testRejected'));
} catch {
toast.error('Could not reach the provider.');
toast.error(t('testNetworkError'));
} finally {
setTesting(false);
}
@ -175,11 +177,11 @@ export function AiConfig() {
const handleSave = async () => {
if (!model.trim()) {
toast.error('Enter a model name.');
toast.error(t('missingModel'));
return;
}
if (!configured && !keyEdited) {
toast.error('Enter your API key.');
toast.error(t('missingApiKey'));
return;
}
setSaving(true);
@ -191,13 +193,13 @@ export function AiConfig() {
});
const data = await res.json();
if (res.ok) {
toast.success('AI assistant saved.');
toast.success(t('saveSuccess'));
await fetchConfig();
} else {
toast.error(data.error ?? 'Failed to save.');
toast.error(data.error ?? t('saveFailed'));
}
} catch {
toast.error('Failed to save.');
toast.error(t('saveFailed'));
} finally {
setSaving(false);
}
@ -208,7 +210,7 @@ export function AiConfig() {
try {
const res = await fetch('/api/ai/config', { method: 'DELETE' });
if (res.ok) {
toast.success('AI configuration removed.');
toast.success(t('removeSuccess'));
setConfigured(false);
setHasStoredKey(false);
setApiKey('');
@ -219,10 +221,10 @@ export function AiConfig() {
setHandoffAgentId('');
} else {
const data = await res.json();
toast.error(data.error ?? 'Failed to remove.');
toast.error(data.error ?? t('removeFailed'));
}
} catch {
toast.error('Failed to remove.');
toast.error(t('removeFailed'));
} finally {
setRemoving(false);
}
@ -231,7 +233,8 @@ export function AiConfig() {
if (loading || profileLoading) {
return (
<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>
);
}
@ -241,13 +244,13 @@ export function AiConfig() {
return (
<div>
<SettingsPanelHead
title="Agent setup"
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."
title={t('title')}
description={t('description')}
/>
{!canEdit && (
<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>
)}
@ -255,17 +258,16 @@ export function AiConfig() {
<Card>
<CardHeader>
<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>
<CardDescription>
Your key is encrypted at rest (AES-256-GCM) and never shown again
after saving.
{t('encryptionNotice')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Provider</Label>
<Label>{t('provider')}</Label>
<Select
value={provider}
onValueChange={(v) => handleProviderChange(v as AiProvider)}
@ -284,7 +286,7 @@ export function AiConfig() {
</div>
<div className="space-y-2">
<Label htmlFor="ai-model">Model</Label>
<Label htmlFor="ai-model">{t('model')}</Label>
<Input
id="ai-model"
value={model}
@ -296,7 +298,7 @@ export function AiConfig() {
</div>
<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="relative flex-1">
<Input
@ -340,16 +342,16 @@ export function AiConfig() {
) : (
<CheckCircle2 className="mr-2 h-4 w-4" />
)}
Test key
{t('testKey')}
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ai-embeddings-key">
Embeddings key{' '}
{t('embeddingsKey')}{' '}
<span className="font-normal text-muted-foreground">
(optional — enables semantic knowledge-base search)
{t('optionalSemanticSearch')}
</span>
</Label>
<Input
@ -371,11 +373,9 @@ export function AiConfig() {
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
An OpenAI key used only to embed your knowledge base
(text-embedding-3-small)
{provider === 'openai' ? ' — can be the same key as above' : ''}.
Leave blank to use keyword search instead. Clear it to turn
semantic search off.
{t('embeddingsHint', {
sameKeyText: provider === 'openai' ? t('sameKeyText') : '',
})}
</p>
</div>
</CardContent>
@ -383,21 +383,19 @@ export function AiConfig() {
<Card>
<CardHeader>
<CardTitle className="text-base">Behaviour</CardTitle>
<CardTitle className="text-base">{t('behaviour')}</CardTitle>
<CardDescription>
Tell the assistant about your business — products, tone, what it
may and may not promise. This context feeds both drafts and
auto-replies.
{t('behaviourDesc')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="ai-prompt">Business context & instructions</Label>
<Label htmlFor="ai-prompt">{t('businessContext')}</Label>
<Textarea
id="ai-prompt"
value={systemPrompt}
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}
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>
<p className="text-sm font-medium text-foreground">
Enable AI assistant
{t('enableAssistant')}
</p>
<p className="text-xs text-muted-foreground">
Master switch. Turns on the “Draft with AI” button in the
inbox.
{t('enableAssistantDesc')}
</p>
</div>
<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>
<p className="text-sm font-medium text-foreground">
Auto-reply to inbound messages
{t('autoReply')}
</p>
<p className="text-xs text-muted-foreground">
The bot answers new inbound messages automatically (only when
no flow handles them and no agent is assigned). Hands off to a
human when it can’t help.
{t('autoReplyDesc')}
</p>
</div>
<Switch
@ -440,9 +435,9 @@ export function AiConfig() {
<div className="flex items-center justify-between gap-4">
<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">
After this many bot replies in one thread, the bot goes quiet.
{t('maxAutoRepliesDesc')}
</p>
</div>
<Input
@ -462,10 +457,9 @@ export function AiConfig() {
</div>
<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">
When the bot can’t help — or hits the reply cap — it pauses and
routes the chat here, with a short note on what happened.
{t('handoffToDesc')}
</p>
<Select
value={handoffAgentId || HANDOFF_QUEUE}
@ -479,7 +473,7 @@ export function AiConfig() {
</SelectTrigger>
<SelectContent>
<SelectItem value={HANDOFF_QUEUE}>
Unassigned queue (any agent can pick it up)
{t('handoffQueue')}
</SelectItem>
{members.map((m) => (
<SelectItem key={m.user_id} value={m.user_id}>
@ -515,7 +509,7 @@ export function AiConfig() {
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Remove
{t('remove')}
</Button>
) : (
<span />
@ -523,7 +517,7 @@ export function AiConfig() {
<Button onClick={handleSave} disabled={disabled}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
{t('save')}
</Button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,25 +24,25 @@ export const ROLE_META: Record<
> = {
owner: {
icon: Crown,
label: 'Owner',
label: 'owner',
variant: 'owner',
className: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
},
admin: {
icon: Shield,
label: 'Admin',
label: 'admin',
variant: 'admin',
className: 'border-primary/40 bg-primary/10 text-primary',
},
agent: {
icon: UserCog,
label: 'Agent',
label: 'agent',
variant: 'muted',
className: 'border-border bg-muted text-muted-foreground',
},
viewer: {
icon: UserIcon,
label: 'Viewer',
label: 'viewer',
variant: 'muted',
// 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.

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
import {
@ -30,6 +31,7 @@ export function SettingsRail({
onSelect: (section: SettingsSection) => void;
hints?: Partial<Record<SettingsSection, ReactNode>>;
}) {
const t = useTranslations('Settings');
const activeRef = useRef<HTMLButtonElement>(null);
// When horizontal (mobile), keep the active chip in view. On desktop
@ -64,7 +66,7 @@ export function SettingsRail({
>
{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">
{label}
{t(`groups.${group}`)}
</div>
) : null}
{items.map((s) => {
@ -87,7 +89,7 @@ export function SettingsRail({
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1">{meta.label}</span>
<span className="flex-1">{t(`sections.${s}`)}</span>
{hints?.[s] != null ? (
<span
className={cn(

View File

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

View File

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

View File

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

View File

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