mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
commit
2311b7ea35
1661
messages/en.json
1661
messages/en.json
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}`
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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'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} />)
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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,10 +823,15 @@ function TriggerCard({
|
||||
<TagSelect
|
||||
value={(config.tag_id as string) ?? ""}
|
||||
onChange={(v) => onConfigChange({ ...config, tag_id: v })}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === "time_based" && (
|
||||
<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) ?? ""}
|
||||
@ -823,6 +840,10 @@ function TriggerCard({
|
||||
}
|
||||
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 "closed". No configuration needed.
|
||||
{t("config.closeConversationHint", { defaultValue: "Sets the conversation status to \"closed\". No configuration needed." })}
|
||||
</p>
|
||||
)
|
||||
default:
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) => (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -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'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
|
||||
|
||||
@ -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'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'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're
|
||||
returned by WhatsApp when a customer taps; you usually
|
||||
don'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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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 : '';
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 && (
|
||||
<>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 <key></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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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'll be signed out of this account
|
||||
and given a fresh personal account on their next sign-in. Their
|
||||
login isn'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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 "{tagToDelete?.name}"? 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('subscribedSince', {
|
||||
date: config.registered_at
|
||||
? new Date(config.registered_at).toLocaleString()
|
||||
: 'unknown'}
|
||||
. Click <strong>Verify with Meta</strong> if events
|
||||
stop arriving.
|
||||
</>
|
||||
: t('unknownDate'),
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
) : lastRegistrationError ? (
|
||||
<>
|
||||
Last attempt failed with:{' '}
|
||||
{t('lastAttemptFailed')}
|
||||
<span className="text-red-300">
|
||||
"{lastRegistrationError}"
|
||||
</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 "My Apps" and then "Create App"</li>
|
||||
<li>Select "Business" 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 "Add Product"</li>
|
||||
<li>Find "WhatsApp" and click "Set Up"</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 > 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 > 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 > Configuration</li>
|
||||
<li>Click "Edit" 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 "messages" 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>
|
||||
|
||||
@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user