- Variable key (stored in flow_runs.vars; alphanumeric + underscore)
+ {t("varKeyLabel")}
- Interpolate in downstream prompts and handoff notes with{" "}
+ {t("varKeyHelp")}{" "}
{"{{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 (
onUpdateConfig({ note: v })}
rows={2}
@@ -201,8 +208,7 @@ export function NodeConfigForm({
case "end":
return (
- Terminal node. When the runner reaches this node the run is marked
- complete. No config needed.
+ {t("endNodeHelp")}
);
}
@@ -224,12 +230,14 @@ function SendButtonsForm({
currentKey,
onUpdateConfig,
showAdvanced,
+ t,
}: {
cfg: SendButtonsCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record) => void;
showAdvanced: boolean;
+ t: ReturnType;
}) {
const buttons = cfg.buttons ?? [];
const updateButton = (
@@ -257,20 +265,20 @@ function SendButtonsForm({
return (
<>
onUpdateConfig({ text: v })}
rows={3}
/>
onUpdateConfig({ footer_text: v })}
/>
- Buttons (1–3) — each one routes to a different next node
+ {t("buttonsHelp")}
@@ -299,7 +307,7 @@ function SendButtonsForm({
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")}
/>
- Add button
+ {t("addButton")}
)}
@@ -362,12 +370,14 @@ function SendListForm({
currentKey,
onUpdateConfig,
showAdvanced,
+ t,
}: {
cfg: SendListCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record
) => void;
showAdvanced: boolean;
+ t: ReturnType;
}) {
const sections = cfg.sections ?? [];
const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0);
@@ -453,12 +463,12 @@ function SendListForm({
/>
onUpdateConfig({ button_label: v })}
/>
onUpdateConfig({ footer_text: v })}
/>
@@ -466,7 +476,7 @@ function SendListForm({
- Rows (1–10 total across all sections)
+ {t("rowsHelp")}
{sections.map((section, sIdx) => (
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")}
/>
- Add row
+ {t("addRow")}
)}
@@ -566,7 +576,7 @@ function SendListForm({
{sections.length < 10 && (
- Add section
+ {t("addSection")}
)}
@@ -598,11 +608,13 @@ function ConditionForm({
allNodes,
currentKey,
onUpdateConfig,
+ t,
}: {
cfg: ConditionCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record) => void;
+ t: ReturnType;
}) {
const tags = useUserTags();
@@ -614,7 +626,7 @@ function ConditionForm({
<>
- If
+ {t("ifLabel")}
@@ -625,19 +637,19 @@ function ConditionForm({
- Captured variable
- Contact has tag
- Contact field
+ {t("capturedVariable")}
+ {t("contactHasTag")}
+ {t("contactField")}
{subject === "var"
- ? "var name"
+ ? t("varName")
: subject === "tag"
- ? "Tag"
- : "Field"}
+ ? t("tagLabel")
+ : t("fieldLabel")}
{subject === "tag" && tags.length > 0 ? (
onUpdateConfig({ subject_key: v })}
>
-
+
name
@@ -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({
)}
>
- Operator
+ {t("operatorLabel")}
@@ -701,16 +713,16 @@ function ConditionForm({
- is present
- is absent
- equals
- contains
+ {t("isPresent")}
+ {t("isAbsent")}
+ {t("equals")}
+ {t("contains")}
{showValue && (
- Value
+ {t("valueLabel")}
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")}
/>
onUpdateConfig({ false_next: v })}
- label="If false → advance to"
+ label={t("ifFalseAdvance")}
/>
>
@@ -755,11 +767,13 @@ function SetTagForm({
allNodes,
currentKey,
onUpdateConfig,
+ t,
}: {
cfg: SetTagCfg;
allNodes: BuilderNode[];
currentKey: string;
onUpdateConfig: (patch: Record) => void;
+ t: ReturnType;
}) {
const tags = useUserTags();
@@ -767,7 +781,7 @@ function SetTagForm({
<>
- Action
+ {t("actionLabel")}
@@ -778,13 +792,13 @@ function SetTagForm({
- Add tag
- Remove tag
+ {t("addTag")}
+ {t("removeTag")}
-
Tag
+
{t("tagLabel")}
{tags.length > 0 ? (
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) => void;
+ t: ReturnType;
}) {
const fileInputRef = useRef(null);
const [uploading, setUploading] = useState(false);
@@ -930,7 +946,7 @@ function SendMediaForm({
return (
<>
- Media type
+ {t("mediaTypeLabel")}
{
@@ -948,17 +964,17 @@ function SendMediaForm({
- Image (PNG, JPEG, WebP)
- Video (MP4, 3GP)
+ {t("imageLabel")}
+ {t("videoLabel")}
- Document (PDF, Word, Excel, PowerPoint, TXT)
+ {t("documentLabel")}
-
File
+
{t("fileLabel")}
{cfg.media_url ? (
@@ -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}
>
@@ -991,12 +1007,12 @@ function SendMediaForm({
{uploading ? (
<>
- Uploading…
+ {t("uploading")}
>
) : (
<>
- Click to upload (max 16 MB)
+ {t("clickToUpload")}
>
)}
@@ -1016,7 +1032,7 @@ function SendMediaForm({
onUpdateConfig({ caption: v })}
rows={2}
@@ -1025,12 +1041,12 @@ function SendMediaForm({
{isDocument && (
- Filename shown to the customer (documents only)
+ {t("filenameLabel")}
onUpdateConfig({ filename: e.target.value })}
- placeholder="invoice.pdf"
+ placeholder={t("filenamePlaceholder")}
className="bg-muted text-xs"
/>
@@ -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")}
/>
>
);
diff --git a/src/components/flows/shared.tsx b/src/components/flows/shared.tsx
index cab3f97..66a4f96 100644
--- a/src/components/flows/shared.tsx
+++ b/src/components/flows/shared.tsx
@@ -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 | 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 : '';
diff --git a/src/components/flows/validation-panel.tsx b/src/components/flows/validation-panel.tsx
index 75812ac..f10c8f1 100644
--- a/src/components/flows/validation-panel.tsx
+++ b/src/components/flows/validation-panel.tsx
@@ -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 (
- No issues. Ready to activate.
+ {t("noIssues")}
);
}
@@ -51,12 +53,11 @@ export function ValidationPanel() {
) : (
)}
- {errors.length} error{errors.length === 1 ? "" : "s"},{" "}
- {warnings.length} warning{warnings.length === 1 ? "" : "s"}
+ {t("summary", { errorCount: errors.length, warningCount: warnings.length })}
{issues.map((i, ix) => (
-
+
))}
@@ -72,9 +73,11 @@ export function ValidationPanel() {
export function IssueLine({
issue,
onJump,
+ t,
}: {
issue: ValidationIssue;
onJump?: (key: string) => void;
+ t?: ReturnType
;
}) {
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}
diff --git a/src/components/inbox/ai-thread-banner.tsx b/src/components/inbox/ai-thread-banner.tsx
index a45c7b7..9436115 100644
--- a/src/components/inbox/ai-thread-banner.tsx
+++ b/src/components/inbox/ai-thread-banner.tsx
@@ -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(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 (
-
AI assistant is paused here
+
{t("pausedTitle")}
{handoffSummary && (
{handoffSummary}
@@ -148,7 +150,7 @@ export function AiThreadBanner({
)}
toggle(false)} busy={busy} icon={Undo2}>
- Resume AI
+ {t("resume")}
);
@@ -163,11 +165,11 @@ export function AiThreadBanner({
- AI assistant is replying automatically
+ {t("activeText")}
toggle(true)} busy={busy} icon={Hand}>
- Take over
+ {t("takeOver")}
);
diff --git a/src/components/inbox/contact-sidebar.tsx b/src/components/inbox/contact-sidebar.tsx
index cbaa4ee..4db0cfe 100644
--- a/src/components/inbox/contact-sidebar.tsx
+++ b/src/components/inbox/contact-sidebar.tsx
@@ -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([]);
@@ -118,7 +122,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
if (!contact) {
return (
-
Select a conversation
+
{tThread("selectConversation")}
);
}
@@ -181,11 +185,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
- Tags
+ {tSidebar("tags")}
{tags.length === 0 ? (
-
No tags
+
{tSidebar("noTags")}
) : (
tags.map((tag) => (
- Active Deals
+ {tSidebar("deals")}
{deals.length === 0 ? (
-
No deals
+
{tSidebar("noDeals")}
) : (
deals.map((deal) => (
- Notes
+ {tSidebar("notes")}
@@ -234,7 +239,7 @@ export function ConversationList({
- {activeFilter?.label ?? "All"}
+ {activeFilter?.label ?? t("filterAll")}
- Tags
+ {t("tags")}
{selectedTagIds.length > 0 && (
{selectedTagIds.length}
@@ -310,7 +315,7 @@ export function ConversationList({
: "text-muted-foreground hover:text-foreground"
)}
>
- {selectedCompany ?? "Company"}
+ {selectedCompany ?? t("company")}
- All companies
+ {t("allCompanies")}
{companies.map((co) => (
- {tag?.name ?? "Tag"}
+ {tag?.name ?? t("tags")}
);
@@ -379,7 +384,7 @@ export function ConversationList({
onClick={clearContactFilters}
className="px-1 text-[11px] text-muted-foreground hover:text-foreground"
>
- Clear all
+ {t("clearAll")}
)}
@@ -398,7 +403,7 @@ export function ConversationList({
) : filtered.length === 0 ? (
-
No conversations found
+
{t("noConversations")}
) : (
@@ -408,6 +413,7 @@ export function ConversationList({
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
+ t={t}
/>
))}
@@ -421,15 +427,17 @@ interface ConversationItemProps {
conversation: Conversation;
isActive: boolean;
onSelect: (conversation: Conversation) => void;
+ t: ReturnType
;
}
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({
- {conversation.last_message_text || "No messages yet"}
+ {conversation.last_message_text || t("noMessagesYet")}
{conversation.unread_count > 0 && (
diff --git a/src/components/inbox/message-actions.tsx b/src/components/inbox/message-actions.tsx
index fcd4ed2..240d03b 100644
--- a/src/components/inbox/message-actions.tsx
+++ b/src/components/inbox/message-actions.tsx
@@ -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({
@@ -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}
@@ -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")}
>
@@ -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")}
>
diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx
index 20a674b..80b8a9d 100644
--- a/src/components/inbox/message-bubble.tsx
+++ b/src/components/inbox/message-bubble.tsx
@@ -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 }) {
return (
- {label} unavailable
+ {t("unavailable", { label })}
);
}
@@ -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 }) {
switch (message.content_type) {
case "text":
return (
@@ -132,7 +133,7 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? (
) : (
-
+
)}
{message.content_text && (
@@ -152,7 +153,7 @@ function MessageContent({ message }: { message: Message }) {
className="max-h-64 max-w-60 rounded-lg"
/>
) : (
-
+
)}
{message.content_text && (
@@ -168,14 +169,14 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? (
) : (
-
+
)}
);
case "document":
if (!message.media_url) {
- return
;
+ return
;
}
return (
- {message.content_text || "Document"}
+ {message.content_text || t("document")}
);
@@ -196,7 +197,7 @@ function MessageContent({ message }: { message: Message }) {
- Template
+ {t("template")}
{message.content_text && (
@@ -210,7 +211,7 @@ function MessageContent({ message }: { message: Message }) {
return (
- {message.content_text || "Location shared"}
+ {message.content_text || t("locationShared")}
);
@@ -224,10 +225,10 @@ function MessageContent({ message }: { message: Message }) {
- Button reply
+ {t("buttonReply")}
- {message.content_text || "[Interactive reply]"}
+ {message.content_text || t("interactiveReply")}
);
@@ -236,7 +237,7 @@ function MessageContent({ message }: { message: Message }) {
default:
return (
- {message.content_text || "[Unsupported message type]"}
+ {message.content_text || t("unsupported")}
);
}
@@ -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}
/>
)}
-
+
- AI
+ {t("aiBadge")}
)}
- 24-hour session expired. Use a template to re-engage.
+ {t("sessionExpiredHint")}
- Templates
+ {t("templates")}
)}
@@ -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.
- Recording… {formatDuration(recordSeconds)} /{" "}
- {formatDuration(MAX_RECORDING_SECONDS)}
+ {t("recording", { current: formatDuration(recordSeconds), max: formatDuration(MAX_RECORDING_SECONDS) })}
- Cancel
+ {t("cancel")}
@@ -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({
imageInputRef.current?.click()}>
- Photo
+ {t("photo")}
videoInputRef.current?.click()}>
- Video
+ {t("video")}
documentInputRef.current?.click()}>
- Document
+ {t("document")}
void startRecording()}>
- Voice note
+ {t("voiceNote")}
@@ -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 && (
- Tap the ✨ to draft a reply with AI — you can edit it before sending
+ {t("draftHint")}
)}
@@ -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
;
}) {
return (
@@ -684,7 +689,7 @@ function MediaDraftPreview({
@@ -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"
/>
)}
diff --git a/src/components/inbox/message-thread.tsx b/src/components/inbox/message-thread.tsx
index ccd0003..78c3376 100644
--- a/src/components/inbox/message-thread.tsx
+++ b/src/components/inbox/message-thread.tsx
@@ -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): 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({
- Select a conversation
+ {t("selectConversation")}
- Choose a conversation from the left to start messaging
+ {t("selectConversationHint")}
);
@@ -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({
@@ -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")}
handleStatusChange(opt.value)}
className={cn("text-sm", opt.color)}
>
- {opt.label}
+ {t(`status${opt.label}`)}
))}
@@ -949,7 +954,7 @@ export function MessageThread({
>
{profiles.length === 0 ? (
- No teammates available
+ {t("noTeammates")}
) : (
profiles.map((p) => {
@@ -975,7 +980,7 @@ export function MessageThread({
/>
{p.full_name}
- {p.user_id === user?.id ? " (me)" : ""}
+ {p.user_id === user?.id ? t("me") : ""}
{isSelected && }
@@ -989,7 +994,7 @@ export function MessageThread({
onClick={() => handleAssignChange(null)}
className="text-sm text-muted-foreground"
>
- Unassign
+ {t("unassign")}
>
)}
@@ -1006,9 +1011,9 @@ export function MessageThread({
) : messages.length === 0 ? (
-
No messages yet
+
{t("noMessagesYet")}
- Send a template to start the conversation
+ {t("sendTemplateHint")}
) : (
@@ -1018,7 +1023,7 @@ export function MessageThread({
{/* Date separator */}
- {formatDateSeparator(group.date)}
+ {formatDateSeparator(group.date, t)}
{/* 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);
diff --git a/src/components/inbox/reply-quote.tsx b/src/components/inbox/reply-quote.tsx
index c7dadd3..20dd195 100644
--- a/src/components/inbox/reply-quote.tsx
+++ b/src/components/inbox/reply-quote.tsx
@@ -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 (
@@ -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): 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");
}
}
diff --git a/src/components/inbox/template-picker.tsx b/src/components/inbox/template-picker.tsx
index 9a0b754..4715763 100644
--- a/src/components/inbox/template-picker.tsx
+++ b/src/components/inbox/template-picker.tsx
@@ -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([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(null);
@@ -190,12 +193,12 @@ export function TemplatePicker({
- {selected ? selected.name : "Send template"}
+ {selected ? selected.name : t("sendTemplate")}
{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")}
@@ -207,10 +210,9 @@ export function TemplatePicker({
) : templates.length === 0 ? (
-
No approved templates
+
{t("noApprovedTemplates")}
- Approve a template in Meta WhatsApp Manager, then sync it
- from Settings → Templates.
+ {t("noApprovedTemplatesHint")}
) : (
@@ -249,7 +251,7 @@ export function TemplatePicker({
) : (
-
Preview
+
{t("preview")}
{renderBodyPreview(selected.body_text, params)}
@@ -267,7 +269,7 @@ export function TemplatePicker({
setHeaderText(e.target.value)}
- placeholder="Value for the header variable"
+ placeholder={t("headerValuePlaceholder")}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
@@ -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"
/>
@@ -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"
/>
- Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
+ {t("finalUrl", { url: slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}") })}
))}
@@ -320,14 +322,14 @@ export function TemplatePicker({
className="border-border text-popover-foreground hover:bg-muted"
>
- Back
+ {t("back")}
- Send template
+ {t("send")}
>
) : (
@@ -336,7 +338,7 @@ export function TemplatePicker({
onClick={() => handleOpenChange(false)}
className="border-border text-popover-foreground hover:bg-muted"
>
- Cancel
+ {t("cancel")}
)}
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
index 45d615b..18ac0b1 100644
--- a/src/components/layout/header.tsx
+++ b/src/components/layout/header.tsx
@@ -19,22 +19,22 @@ import {
import { ModeToggle } from "@/components/layout/mode-toggle";
const pageTitles: Record = {
- "/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) {
- {title}
+ {t(titleKey as string)}
@@ -76,13 +79,13 @@ export function Header({ onOpenSidebar }: HeaderProps) {
{profile?.avatar_url ? (
) : null}
@@ -90,7 +93,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
- {profile?.full_name ?? "User"}
+ {profile?.full_name ?? t("defaultUser")}
- {profile?.full_name ?? "User"}
+ {profile?.full_name ?? t("defaultUser")}
{profile?.email ?? ""}
@@ -116,7 +119,7 @@ export function Header({ onOpenSidebar }: HeaderProps) {
}
>
- Profile
+ {t("menuProfile")}
- Settings
+ {t("menuSettings")}
- Sign out
+ {t("menuSignOut")}
diff --git a/src/components/layout/mode-toggle.tsx b/src/components/layout/mode-toggle.tsx
index 273dc10..acf7bfc 100644
--- a/src/components/layout/mode-toggle.tsx
+++ b/src/components/layout/mode-toggle.tsx
@@ -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 (
= {
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. */}
- CRM Template for WhatsApp
+ {t("title")}
@@ -233,18 +236,18 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)}
>
- {item.label}
+ {t(item.labelKey as string)}
{item.beta && (
- Beta
+ {t("beta")}
)}
{showUnreadDot && (
@@ -253,7 +256,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)}
{showNotificationBadge && (
{unreadNotifications > 9 ? "9+" : unreadNotifications}
@@ -282,7 +285,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
)}
>
- {item.label}
+ {t(item.labelKey as string)}
);
@@ -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}`}
>
- {meta.label}
+ {t(meta.labelKey as string)}
);
})()
@@ -333,7 +336,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
{profile?.avatar_url ? (
) : null}
@@ -344,7 +347,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
- {profile?.full_name ?? "User"}
+ {profile?.full_name ?? t("defaultUser")}
{profile?.email ?? ""}
@@ -367,7 +370,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
}
>
- Profile
+ {t("menuProfile")}
- Settings
+ {t("menuSettings")}
- Sign out
+ {t("menuSignOut")}
diff --git a/src/components/pipelines/deal-card.tsx b/src/components/pipelines/deal-card.tsx
index 2f1c271..1dd8335 100644
--- a/src/components/pipelines/deal-card.tsx
+++ b/src/components/pipelines/deal-card.tsx
@@ -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" && (
- Won
+ {t("won")}
)}
{deal.status === "lost" && (
- Lost
+ {t("lost")}
)}
diff --git a/src/components/pipelines/deal-form.tsx b/src/components/pipelines/deal-form.tsx
index 01c0bfe..c0c25a0 100644
--- a/src/components/pipelines/deal-form.tsx
+++ b/src/components/pipelines/deal-form.tsx
@@ -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({
- {deal ? "Edit Deal" : "New Deal"}
+ {deal ? t("editDeal") : t("newDeal")}
- Title
+ {t("title")}
setTitle(e.target.value)}
- placeholder="Deal title"
+ placeholder={t("titlePlaceholder")}
className="border-border bg-muted text-foreground"
/>
- Contact
+ {t("contact")}
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"
>
- Select a contact
+ {t("selectContact")}
{contacts.map((c) => (
{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"
>
- Link to Conversation
+ {t("linkToConversation")}
)}
-
Value
+
{t("value")}
- Currency
+ {t("currency")}
setCurrency(e.target.value)}
@@ -324,7 +326,7 @@ export function DealForm({
-
Expected Close Date
+
{t("expectedCloseDate")}
- Stage
+ {t("stage")}
setStageId(e.target.value)}
@@ -349,13 +351,13 @@ export function DealForm({
- Assigned To
+ {t("assignedTo")}
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"
>
- Unassigned
+ {t("unassigned")}
{profiles.map((p) => (
{p.full_name || p.email}
@@ -365,11 +367,11 @@ export function DealForm({
- Notes
+ {t("notes")}
@@ -377,7 +379,7 @@ export function DealForm({
{deal && (
- Status
+ {t("status")}
- Mark as Won
+ {t("markAsWon")}
>
)}
@@ -406,7 +408,7 @@ export function DealForm({
) : (
<>
- Mark as Lost
+ {t("markAsLost")}
>
)}
@@ -419,7 +421,7 @@ export function DealForm({
disabled={!!statusAction}
className="w-full text-muted-foreground hover:text-foreground"
>
- Reopen deal
+ {t("reopenDeal")}
)}
@@ -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")}
- {saving ? "Saving..." : deal ? "Save Changes" : "Create Deal"}
+ {saving ? t("saving") : deal ? t("saveChanges") : t("createDeal")}
{deal &&
(confirmDelete ? (
-
Delete this deal?
+
{t("deletePrompt")}
- Cancel
+ {t("cancel")}
- {deleting ? "Deleting..." : "Confirm"}
+ {deleting ? t("deleting") : t("confirm")}
@@ -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"
>
- Delete Deal
+ {t("deleteDeal")}
))}
diff --git a/src/components/pipelines/pipeline-analytics.tsx b/src/components/pipelines/pipeline-analytics.tsx
index 5e371df..8fa90fd 100644
--- a/src/components/pipelines/pipeline-analytics.tsx
+++ b/src/components/pipelines/pipeline-analytics.tsx
@@ -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) {
}
- 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}
/>
}
- 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}
/>
}
- 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}
/>
}
- 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}
/>
}
- 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}
/>
}
- 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}
/>
@@ -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 (
@@ -156,7 +167,7 @@ function Metric({
render={
}
diff --git a/src/components/pipelines/pipeline-board.tsx b/src/components/pipelines/pipeline-board.tsx
index 6477207..06ffdb0 100644
--- a/src/components/pipelines/pipeline-board.tsx
+++ b/src/components/pipelines/pipeline-board.tsx
@@ -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 ? (
- Drop a deal here
+ {t("dropDealHere")}
) : (
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"
>
- Add Deal
+ {t("addDeal")}
);
diff --git a/src/components/pipelines/pipeline-settings.tsx b/src/components/pipelines/pipeline-settings.tsx
index 47036b8..0b34723 100644
--- a/src/components/pipelines/pipeline-settings.tsx
+++ b/src/components/pipelines/pipeline-settings.tsx
@@ -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 (
- Manage Pipeline
+ {t("managePipeline")}
{showDeleteConfirm ? (
@@ -210,11 +212,10 @@ export function PipelineSettings({
- Delete Pipeline
+ {t("deletePipeline")}
- This will archive all deals in this pipeline. This cannot be
- undone.
+ {t("deletePipelineDesc")}
@@ -224,14 +225,14 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
- Cancel
+ {t("cancel")}
- {deleting ? "Deleting..." : "Delete Pipeline"}
+ {deleting ? t("deleting") : t("deletePipelineBtn")}
@@ -239,7 +240,7 @@ export function PipelineSettings({
<>
@@ -329,7 +331,7 @@ export function PipelineSettings({
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
>
- Create a new pipeline
+ {t("createNewPipeline")}
@@ -339,21 +341,21 @@ export function PipelineSettings({
onClick={() => setShowDeleteConfirm(true)}
className="mr-auto bg-red-600 hover:bg-red-700"
>
- Delete Pipeline
+ {t("deletePipeline")}
onOpenChange(false)}
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
- Cancel
+ {t("cancel")}
- {saving ? "Saving..." : "Save Changes"}
+ {saving ? t("saving") : t("saveChanges")}
>
@@ -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")}
>
-
+
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 && (
<>
diff --git a/src/components/settings/ai-config.tsx b/src/components/settings/ai-config.tsx
index 7e609e3..6028090 100644
--- a/src/components/settings/ai-config.tsx
+++ b/src/components/settings/ai-config.tsx
@@ -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 = {
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 (
- Loading…
+ {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. */}
);
}
@@ -241,13 +244,13 @@ export function AiConfig() {
return (
{!canEdit && (
- Only admins and owners can change the AI configuration.
+ {t('adminOnlyConfig')}
)}
@@ -255,17 +258,16 @@ export function AiConfig() {
- Provider & key
+ {t('providerAndKey')}
- Your key is encrypted at rest (AES-256-GCM) and never shown again
- after saving.
+ {t('encryptionNotice')}
- Provider
+ {t('provider')}
handleProviderChange(v as AiProvider)}
@@ -284,7 +286,7 @@ export function AiConfig() {
-
Model
+
{t('model')}
-
API key
+
{t('apiKey')}
@@ -383,21 +383,19 @@ export function AiConfig() {
- Behaviour
+ {t('behaviour')}
- 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')}
-
Business context & instructions
+
{t('businessContext')}
@@ -213,7 +216,7 @@ export function ApiKeysSettings() {
{k.scopes.length === 0 ? (
- No scopes
+ {t('noScopes')}
) : (
k.scopes.map((s) => (
@@ -227,13 +230,13 @@ export function ApiKeysSettings() {
)}
- 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) })}`
: ''}
@@ -252,7 +255,7 @@ export function ApiKeysSettings() {
) : (
)}
- Revoke
+ {t('revoke')}
)}
@@ -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
([]);
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({
<>
- Copy your API key
+ {t('copyTitle')}
- 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')}
-
API key
+
{t('apiKeyLabel')}
- Copy
+ {t('copy')}
@@ -387,7 +390,7 @@ function CreateKeyDialog({
onOpenChange(false);
}}
>
- Done
+ {t('done')}
>
@@ -395,30 +398,29 @@ function CreateKeyDialog({
<>
- New API key
+ {t('newKeyTitle')}
- Name it after the integration that will use it, and grant only
- the scopes it needs.
+ {t('newKeyDesc')}
- Name
+ {t('nameLabel')}
setName(e.target.value)}
/>
-
Scopes
+
{t('scopesLabel')}
{API_SCOPES.map((scope) => (
- A key with no scopes can still call{' '}
- GET /api/v1/me to verify
- it works.
+ {t.rich('scopesHint', {
+ code: (chunks: React.ReactNode) => (
+ {chunks}
+ ),
+ })}
@@ -460,16 +464,16 @@ function CreateKeyDialog({
}}
className="border-border text-muted-foreground hover:bg-muted"
>
- Cancel
+ {t('cancel')}
{submitting ? (
<>
- Creating…
+ {t('creating')}
>
) : (
- 'Create key'
+ t('createKey')
)}
diff --git a/src/components/settings/appearance-panel.tsx b/src/components/settings/appearance-panel.tsx
index 0a83bd7..c9cf60a 100644
--- a/src/components/settings/appearance-panel.tsx
+++ b/src/components/settings/appearance-panel.tsx
@@ -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 (
- Mode
+ {t("mode")}
- Accent color
+ {t("accentColor")}
- {THEMES.map((t) => (
+ {THEMES.map((tObj) => (
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)}
/>
))}
@@ -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 && (
- Active
+ {t("active")}
)}
@@ -133,12 +137,13 @@ function ThemeCard({
isActive: boolean;
onPick: () => void;
}) {
+ const t = useTranslations("Settings.appearance");
return (
- Active
+ {t("active")}
)}
diff --git a/src/components/settings/custom-fields-settings.tsx b/src/components/settings/custom-fields-settings.tsx
index 48232f5..c78c677 100644
--- a/src/components/settings/custom-fields-settings.tsx
+++ b/src/components/settings/custom-fields-settings.tsx
@@ -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 (
- Custom fields
+ {t('fieldsTitle')}
- Admin
+ {t('adminRole')}
- Extra contact fields (e.g. ZIP code, lead source). They appear on
- every contact and in the “Update Contact Field” automation action.
+ {t('fieldsDesc')}
diff --git a/src/components/settings/deals-settings.tsx b/src/components/settings/deals-settings.tsx
index 1e8d1a4..f85a92a 100644
--- a/src/components/settings/deals-settings.tsx
+++ b/src/components/settings/deals-settings.tsx
@@ -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 (
- Default currency
+ {t("defaultCurrency")}
- 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")}
-
Currency
+
{t("currencyLabel")}
setSelected(e.target.value)}
@@ -102,7 +102,7 @@ export function DealsSettings() {
{!canEditSettings && (
- Only account admins can change the default currency.
+ {t("adminOnlyHint")}
)}
@@ -116,10 +116,10 @@ export function DealsSettings() {
{saving ? (
<>
- Saving...
+ {t("saving")}
>
) : (
- "Save"
+ t("save")
)}
)}
diff --git a/src/components/settings/fields-and-tags-panel.tsx b/src/components/settings/fields-and-tags-panel.tsx
index 3e7f276..cf482a3 100644
--- a/src/components/settings/fields-and-tags-panel.tsx
+++ b/src/components/settings/fields-and-tags-panel.tsx
@@ -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 (
{canEditSettings ? : null}
diff --git a/src/components/settings/invite-member-dialog.tsx b/src/components/settings/invite-member-dialog.tsx
index a767a6d..b211491 100644
--- a/src/components/settings/invite-member-dialog.tsx
+++ b/src/components/settings/invite-member-dialog.tsx
@@ -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 = {
- 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('agent');
const [expiry, setExpiry] = useState('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({
- Invite created
+ {t('inviteCreated')}
- Share this link with your new teammate. They'll be able
- to sign up (or sign in) and join the account as{' '}
- {result.role}
- . The link is valid for{' '}
-
- {result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
-
- .
+ {t.rich('inviteCreatedDesc', {
+ role: tRoles(result.role),
+ days: result.expiresInDays,
+ bold: (chunks: React.ReactNode) => {chunks}
+ })}
-
Invite link
+
{t('inviteLink')}
- Copy
+ {t('copy')}
@@ -231,11 +223,9 @@ export function InviteMemberDialog({
intro, amber-200 for the body. */}
- Save this link now.
+ {t('saveLinkNow')}
{' '}
- 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')}
{/* Anchor styled with `buttonVariants` rather than wrapping
@@ -254,7 +244,7 @@ export function InviteMemberDialog({
})}
>
- Send via WhatsApp
+ {t('sendViaWhatsApp')}
@@ -263,23 +253,22 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
- Done
+ {t('done')}
>
) : (
<>
- Invite a teammate
+ {t('dialogTitle')}
- Generate a one-time invite link. Share it via WhatsApp,
- Slack, or any channel you like — no email service required.
+ {t('dialogDesc')}
-
Role
+
{t('roleLabel')}
v && setRole(v as InviteRole)}
@@ -288,18 +277,18 @@ export function InviteMemberDialog({
- Admin
- Agent
- Viewer
+ {tRoles('admin')}
+ {tRoles('agent')}
+ {tRoles('viewer')}
- {ROLE_DESCRIPTIONS[role]}
+ {tRoles(`${role}Hint` as 'adminHint' | 'agentHint' | 'viewerHint')}
-
Link valid for
+
{t('validForLabel')}
v && setExpiry(v)}
@@ -310,7 +299,7 @@ export function InviteMemberDialog({
{EXPIRY_OPTIONS.map((opt) => (
- {opt.label}
+ {t(opt.labelKey as Parameters[0])}
))}
@@ -319,19 +308,18 @@ export function InviteMemberDialog({
@@ -342,7 +330,7 @@ export function InviteMemberDialog({
onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
- Cancel
+ {t('cancel')}
- Creating...
+ {t('creating')}
>
) : (
- 'Generate link'
+ t('generateLink')
)}
diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx
index 840e06f..6aa296f 100644
--- a/src/components/settings/members-tab.tsx
+++ b/src/components/settings/members-tab.tsx
@@ -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 {
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 (
setInviteOpen(true)}>
- Invite member
+ {t('inviteMember')}
}
@@ -302,18 +304,18 @@ export function MembersTab() {
- {counts.online} online
+ {counts.online} {t('online')}
- {counts.away} away
+ {counts.away} {t('away')}
- {counts.offline} offline
+ {counts.offline} {t('offline')}
- · {members.length} member{members.length === 1 ? '' : 's'}
+ · {t('memberCount', { count: members.length })}
);
@@ -381,11 +383,11 @@ export function MembersTab() {
- {member.full_name || 'Unnamed'}
+ {member.full_name || t('unnamed')}
{isSelf && (
- You
+ {t('you')}
)}
@@ -400,7 +402,7 @@ export function MembersTab() {
{/* Joined date stays desktop-only. The mobile row's
vertical density makes the joined date noise. */}
- Joined {fmtDate(member.joined_at)}
+ {t('joined', { date: fmtDate(member.joined_at) })}
{/* Actions cluster. On mobile this is its own row
@@ -431,7 +433,7 @@ export function MembersTab() {
{EDITABLE_ROLES.map((r) => (
- {r.label}
+ {tRoles(r.value)}
))}
@@ -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}`}
>
- {roleMeta.label}
+ {tRoles(member.role)}
)}
@@ -477,7 +479,7 @@ export function MembersTab() {
- Pending invitations
+ {t('pendingInvitations')}
{invitations.length}
@@ -490,9 +492,7 @@ export function MembersTab() {
looking for a button) keeps it from feeling like a bug. */}
{invitations.length > 0 ? (
- 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')}
) : null}
@@ -501,11 +501,10 @@ export function MembersTab() {
- No pending invitations.
+ {t('noPendingTitle')}
- Click Invite member {' '}
- above to generate a shareable link.
+ {t.rich('noPendingDesc', { bold: (chunks) => {chunks} })}
@@ -524,17 +523,17 @@ export function MembersTab() {
- {inv.label || 'Untitled invite'}
+ {inv.label || t('untitledInvite')}
- {inviteRoleMeta.label}
+ {tRoles(inv.role)}
- Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}
+ {t('created', { date: fmtDate(inv.created_at) })} · {fmtExpiresIn(inv.expires_at, t)}
@@ -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"
>
- Revoke
+ {t('revoke')}
);
@@ -577,16 +576,13 @@ export function MembersTab() {
- Remove member
+ {t('removeDialogTitle')}
- Remove{' '}
-
- {removingMember?.full_name || 'this teammate'}
- {' '}
- 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) => {chunks}
+ })}
@@ -595,7 +591,7 @@ export function MembersTab() {
onClick={() => setRemovingMember(null)}
className="border-border text-muted-foreground hover:bg-muted"
>
- Cancel
+ {t('cancel')}
- Removing...
+ {t('removing')}
>
) : (
- 'Remove member'
+ t('removeBtn')
)}
diff --git a/src/components/settings/password-form.tsx b/src/components/settings/password-form.tsx
index 6f6513b..3112555 100644
--- a/src/components/settings/password-form.tsx
+++ b/src/components/settings/password-form.tsx
@@ -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() {
- Password
+ {t('passwordTitle')}
- Use at least {MIN_PASSWORD} characters. You will stay signed in on
- this device after changing it.
+ {t('passwordDesc', { min: MIN_PASSWORD })}
@@ -97,7 +98,7 @@ export function PasswordForm() {
- Current password
+ {t('currentPassword')}
- New password
+ {t('newPassword')}
- Confirm new password
+ {t('confirmPassword')}
- Updating…
+ {t('updating')}
>
) : (
- 'Update password'
+ t('updatePassword')
)}
diff --git a/src/components/settings/profile-form.tsx b/src/components/settings/profile-form.tsx
index c6f0eae..4663cd0 100644
--- a/src/components/settings/profile-form.tsx
+++ b/src/components/settings/profile-form.tsx
@@ -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
(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 (
@@ -239,7 +241,7 @@ export function ProfileForm() {
disabled={saving}
>
- {currentAvatar ? 'Change photo' : 'Upload photo'}
+ {currentAvatar ? t('changePhoto') : t('uploadPhoto')}
{currentAvatar && (
- Remove
+ {t('remove')}
)}
- PNG, JPG, WebP, or GIF. Up to 2 MB.
+ {t('photoHint')}
@@ -262,7 +264,7 @@ export function ProfileForm() {
{/* Name */}
- Display name
+ {t('displayName')}
- Email
+ {t('email')}
- Check the inbox for {profile?.email} and{' '}
- {email} — both need to confirm before the
- change takes effect.
+ {t.rich('emailChangeHint', {
+ oldEmail: profile?.email || '',
+ newEmail: email,
+ bold: (chunks: React.ReactNode) => {chunks}
+ })}
)}
@@ -303,21 +307,21 @@ export function ProfileForm() {
{/* Read-only block */}
- Account details
+ {t('accountDetails')}
-
Role
+ {t('role')}
{profile?.role ?? 'user'}
-
Joined
+ {t('joined')}
{joined}
-
User ID
+
{t('userId')}
{user?.id ?? '—'}
@@ -328,7 +332,7 @@ export function ProfileForm() {
{!profile && (
- Loading your profile…
+ {t('loading')}
)}
@@ -340,10 +344,10 @@ export function ProfileForm() {
{saving ? (
<>
- Saving…
+ {t('saving')}
>
) : (
- 'Save changes'
+ t('saveChanges')
)}
diff --git a/src/components/settings/role-meta.ts b/src/components/settings/role-meta.ts
index 04a3fa5..e6b5c24 100644
--- a/src/components/settings/role-meta.ts
+++ b/src/components/settings/role-meta.ts
@@ -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.
diff --git a/src/components/settings/security-panel.tsx b/src/components/settings/security-panel.tsx
index 948274a..d3477a6 100644
--- a/src/components/settings/security-panel.tsx
+++ b/src/components/settings/security-panel.tsx
@@ -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 (
diff --git a/src/components/settings/sessions-card.tsx b/src/components/settings/sessions-card.tsx
index 6d63898..ebc6d5c 100644
--- a/src/components/settings/sessions-card.tsx
+++ b/src/components/settings/sessions-card.tsx
@@ -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() {
- Active sessions
+ {t('sessionsTitle')}
- 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')}
@@ -67,7 +68,7 @@ export function SessionsCard() {
onClick={() => setOpen(true)}
>
- Sign out of all devices
+ {t('signOutAll')}
@@ -75,11 +76,9 @@ export function SessionsCard() {
- Sign out everywhere?
+ {t('signOutConfirmTitle')}
- 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')}
@@ -89,16 +88,16 @@ export function SessionsCard() {
onClick={() => setOpen(false)}
disabled={signingOut}
>
- Cancel
+ {t('cancel')}
{signingOut ? (
<>
- Signing out…
+ {t('signingOut')}
>
) : (
- 'Sign out everywhere'
+ t('signOutEverywhere')
)}
diff --git a/src/components/settings/settings-overview.tsx b/src/components/settings/settings-overview.tsx
index 2b61cf8..ce992f8 100644
--- a/src/components/settings/settings-overview.tsx
+++ b/src/components/settings/settings-overview.tsx
@@ -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(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 ? (
<>
- Connected
+ {t('connected')}
>
) : (
<>
- Needs reconnecting
+ {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 ? (
- {roleMeta.label}
+ {tRoles(accountRole!)}
) : null}
@@ -267,12 +269,12 @@ export function SettingsOverview({
- {meta.label}
+ {tSections(section)}
{loading ? (
<>
- Loading…
+ {t('loading')}
>
) : (
subtitle
diff --git a/src/components/settings/settings-rail.tsx b/src/components/settings/settings-rail.tsx
index 384ff46..1aa5041 100644
--- a/src/components/settings/settings-rail.tsx
+++ b/src/components/settings/settings-rail.tsx
@@ -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>;
}) {
+ const t = useTranslations('Settings');
const activeRef = useRef(null);
// When horizontal (mobile), keep the active chip in view. On desktop
@@ -64,7 +66,7 @@ export function SettingsRail({
>
{label ? (
- {label}
+ {t(`groups.${group}`)}
) : null}
{items.map((s) => {
@@ -87,7 +89,7 @@ export function SettingsRail({
)}
>
- {meta.label}
+ {t(`sections.${s}`)}
{hints?.[s] != null ? (
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() {
- Tags
+ {t('tagsTitle')}
- Colour-coded labels for grouping and filtering contacts.
+ {t('tagsDesc')}
@@ -186,7 +188,7 @@ export function TagManager() {
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"
>
@@ -196,14 +198,14 @@ export function TagManager() {
) : (
- No tags yet — create your first one below.
+ {t('noTags')}
)}
{/* Inline create row */}
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[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[0])}
/>
))}
@@ -242,7 +244,7 @@ export function TagManager() {
) : (
)}
- Add tag
+ {t('addTag')}
>
@@ -253,10 +255,9 @@ export function TagManager() {
- Delete tag
+ {t('deleteTag')}
- Delete the tag "{tagToDelete?.name}"? This removes it
- from all contacts and cannot be undone.
+ {tagToDelete ? t('deleteConfirm', { name: tagToDelete.name }) : null}
@@ -265,7 +266,7 @@ export function TagManager() {
onClick={() => setDeleteDialogOpen(false)}
disabled={deleting}
>
- Cancel
+ {t('cancel')}
- Deleting...
+ {t('deleting')}
>
) : (
- 'Delete tag'
+ t('deleteTag')
)}
diff --git a/src/components/settings/template-manager.tsx b/src/components/settings/template-manager.tsx
index 0d6ab27..827cb12 100644
--- a/src/components/settings/template-manager.tsx
+++ b/src/components/settings/template-manager.tsx
@@ -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 (
- {syncing ? 'Syncing…' : 'Sync from Meta'}
+ {syncing ? t('syncing') : t('syncFromMeta')}
- New Template
+ {t('newTemplate')}
}
@@ -508,9 +508,9 @@ export function TemplateManager() {
{templates.length === 0 ? (
- No templates yet.
+ {t('noTemplates')}
- Create your first message template to get started.
+ {t('createFirst')}
@@ -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"
>
- Edit
+ {t('edit')}
)}
{(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"
>
- Resubmit
+ {t('resubmit')}
)}
@@ -641,32 +641,27 @@ export function TemplateManager() {
- {editingId ? 'Edit Message Template' : 'New Message Template'}
+ {editingId ? t('dialogEditTitle') : t('dialogNewTitle')}
{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')}
{form.category === 'Authentication' && (
-
- AUTHENTICATION templates have a fixed body + OTP button shape
- that needs a different builder. Create them in Meta WhatsApp
- Manager for now and use Sync from Meta to
- bring them in.
-
+
{t.rich('authWarning', { bold: (chunks) => {chunks} })}
)}
-
Template Name
+
{t('templateName')}
setForm({ ...form, name: e.target.value })}
disabled={editingId !== null}
@@ -674,14 +669,14 @@ export function TemplateManager() {
/>
{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')}
- Category
+ {t('category')}
@@ -709,7 +704,7 @@ export function TemplateManager() {
-
Language
+
{t('language')}
- {editingId
- ? 'Language is fixed once a template exists on Meta.'
- : (
- <>
- Must match the exact code on Meta — en_US{' '}
- and en are distinct.
- >
- )}
+ {editingId ? (
+ t('langFixed')
+ ) : (
+ {t.rich('langHint', { code: (chunks) => {chunks} })}
+ )}
- Header
+ {t('header')}
@@ -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')}
))}
@@ -778,7 +776,7 @@ export function TemplateManager() {
setForm({ ...form, header_content: e.target.value })
@@ -789,8 +787,8 @@ export function TemplateManager() {
{headerVarCount > 0 && (
setForm({ ...form, header_sample: e.target.value })
@@ -828,15 +826,15 @@ export function TemplateManager() {
) : (
)}
- Upload image
+ {t('uploadImage')}
- JPEG or PNG, ≤5 MB
+ {t('uploadHint')}
)}
setForm({ ...form, header_media_url: e.target.value })
@@ -853,21 +851,21 @@ export function TemplateManager() {
)}
{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')}
)}
-
Body Text
+
{t('bodyText')}
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"
/>
- Use {`{{1}}`}, {`{{2}}`} for variables (must be contiguous
- starting at {`{{1}}`}).
+ {t('bodyHint')}
{bodyVarCount > 0 && (
- Sample values (Meta uses these to review your template)
+ {t('sampleValues')}
{form.body_samples.map((val, i) => {
const inputId = `template-body-sample-${i}`;
@@ -892,8 +889,8 @@ export function TemplateManager() {
{
const next = [...form.body_samples];
@@ -909,9 +906,9 @@ export function TemplateManager() {
-
Footer (optional)
+
{t('footer')}
setForm({ ...form, footer_text: e.target.value })
@@ -923,7 +920,7 @@ export function TemplateManager() {
-
Buttons (optional)
+
{t('buttons')}
- Add Button
+ {t('addButton')}
{form.buttons.length === 0 ? (
- Up to {TEMPLATE_LIMITS.maxButtonsTotal} buttons. QUICK_REPLY
- buttons must come before URL / phone / copy-code buttons.
+ {t('buttonsLimit', { max: TEMPLATE_LIMITS.maxButtonsTotal })}
) : (
@@ -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')}
- URL
+ {t('btnUrl')}
- Phone
+ {t('btnPhone')}
- Copy Code
+ {t('btnCopyCode')}
@@ -1011,7 +1007,7 @@ export function TemplateManager() {
{btn.type === 'URL' && (
updateButton(i, { url: e.target.value })
@@ -1020,7 +1016,7 @@ export function TemplateManager() {
/>
{extractVariableIndices(btn.url).length > 0 && (
updateButton(i, { example: e.target.value })
@@ -1032,7 +1028,7 @@ export function TemplateManager() {
)}
{btn.type === 'PHONE_NUMBER' && (
updateButton(i, { phone_number: e.target.value })
@@ -1042,7 +1038,7 @@ export function TemplateManager() {
)}
{btn.type === 'COPY_CODE' && (
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')}
- {editingId ? 'Saving…' : 'Submitting…'}
+ {editingId ? t('saving') : t('submitting')}
>
) : editingId ? (
- 'Save & Resubmit'
+ t('saveResubmit')
) : (
- 'Submit for Approval'
+ t('submitApproval')
)}
@@ -1096,11 +1092,11 @@ export function TemplateManager() {
>
- Delete template?
+ {t('deleteDialogTitle')}
{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 || '' })}
@@ -1110,7 +1106,7 @@ export function TemplateManager() {
disabled={deletingId !== null}
className="border-border text-muted-foreground hover:bg-muted"
>
- Cancel
+ {t('cancel')}
- Deleting…
+ {t('deleting')}
>
) : (
- 'Delete'
+ t('delete')
)}
diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx
index 713670d..85b7a3b 100644
--- a/src/components/settings/whatsapp-config.tsx
+++ b/src/components/settings/whatsapp-config.tsx
@@ -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 (
@@ -388,8 +390,8 @@ export function WhatsAppConfig() {
return (
{/* Main config form */}
@@ -415,12 +417,12 @@ export function WhatsAppConfig() {
{resetting ? (
<>
- Resetting...
+ {t('resetting')}
>
) : (
<>
- Reset Configuration
+ {t('resetConfig')}
>
)}
@@ -438,14 +440,14 @@ export function WhatsAppConfig() {
)}
- {connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'}
+ {connectionStatus === 'connected' ? t('credentialsValid') : t('notConnected')}
{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')}
@@ -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')}
)}
- Verify with Meta
+ {t('verifyWithMeta')}
{isRegistered ? (
- <>
- Subscribed since{' '}
- {config.registered_at
- ? new Date(config.registered_at).toLocaleString()
- : 'unknown'}
- . Click Verify with Meta if events
- stop arriving.
- >
+
) : lastRegistrationError ? (
<>
- Last attempt failed with:{' '}
+ {t('lastAttemptFailed')}
"{lastRegistrationError}"
- . 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')}>
)}
{registrationProbe && (
- Diagnostic — last run: {' '}
+ {t('diagnosticLastRun')}
- {registrationProbe.live ? 'live' : 'not live'}
+ {registrationProbe.live ? t('live') : t('notLive')}
@@ -560,14 +557,14 @@ export function WhatsAppConfig() {
{/* API Credentials */}
- API Credentials
+ {t('apiCredentialsTitle')}
- Enter your Meta WhatsApp Business API credentials.
+ {t('apiCredentialsDesc')}
-
Phone Number ID
+
{t('phoneNumberId')}
-
WhatsApp Business Account ID
+
{t('wabaId')}
-
Permanent Access Token
+
{t('accessToken')}
{
setAccessToken(e.target.value);
@@ -615,34 +612,34 @@ export function WhatsAppConfig() {
{config && !tokenEdited && (
- Token is hidden for security. Re-enter it to update configuration.
+ {t('tokenHidden')}
)}
-
Webhook Verify Token
+
{t('webhookVerifyToken')}
setVerifyToken(e.target.value)}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
- A custom string you create. Must match the token you set in Meta webhook settings.
+ {t('webhookVerifyTokenHint')}
@@ -672,14 +656,14 @@ export function WhatsAppConfig() {
{/* Webhook URL */}
- Webhook Configuration
+ {t('webhookTitle')}
- Use this URL as your webhook callback in the Meta App Dashboard.
+ {t('webhookDesc')}
-
Webhook Callback URL
+
{t('webhookUrl')}
- Saving...
+ {t('saving')}
>
) : (
- 'Save Configuration'
+ t('saveConfig')
)}
- Testing...
+ {t('testing')}
>
) : (
<>
- Test API Connection
+ {t('testConnection')}
>
)}
@@ -743,12 +727,12 @@ export function WhatsAppConfig() {
{resetting ? (
<>
- Resetting...
+ {t('resetting')}
>
) : (
<>
- Reset Configuration
+ {t('resetConfig')}
>
)}
@@ -760,9 +744,9 @@ export function WhatsAppConfig() {
- Setup Instructions
+ {t('setupInstructions')}
- Follow these steps to connect your WhatsApp Business API.
+ {t('setupInstructionsDesc')}
@@ -771,15 +755,15 @@ export function WhatsAppConfig() {
1
- Create a Meta App
+ {t('step1')}
- Go to developers.facebook.com
- Click "My Apps" and then "Create App"
- Select "Business" as the app type
- Fill in app details and create
+
+ {t('step1_2')}
+ {t('step1_3')}
+ {t('step1_4')}
@@ -788,14 +772,14 @@ export function WhatsAppConfig() {
2
- Add WhatsApp Product
+ {t('step2')}
- In your app dashboard, click "Add Product"
- Find "WhatsApp" and click "Set Up"
- Follow the setup wizard to link your business
+ {t('step2_1')}
+ {t('step2_2')}
+ {t('step2_3')}
@@ -804,15 +788,15 @@ export function WhatsAppConfig() {
3
- Get API Credentials
+ {t('step3')}
- Go to WhatsApp > API Setup
- Copy your Phone Number ID
- Copy your WhatsApp Business Account ID
- Generate a Permanent Access Token from Business Settings > System Users
+ {t('step3_1')}
+
+
+
@@ -821,16 +805,16 @@ export function WhatsAppConfig() {
4
- Configure Webhooks
+ {t('step4')}
- Go to WhatsApp > Configuration
- Click "Edit" on the Webhook section
- Paste the Webhook Callback URL from above
- Enter the same Verify Token you set here
- Subscribe to "messages" webhook field
+ {t('step4_1')}
+ {t('step4_2')}
+
+
+ {t('step4_5')}
@@ -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"
>
- Meta WhatsApp API Documentation
+ {t('metaDocs')}
diff --git a/src/lib/broadcast-status.ts b/src/lib/broadcast-status.ts
index f16b8fe..22cae03 100644
--- a/src/lib/broadcast-status.ts
+++ b/src/lib/broadcast-status.ts
@@ -25,51 +25,51 @@ export interface StatusDisplay {
export const broadcastStatusConfig: Record
= {
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 = {
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",
},
};