- 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/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 510f25c..6cedbfb 100644
--- a/src/components/inbox/message-bubble.tsx
+++ b/src/components/inbox/message-bubble.tsx
@@ -17,6 +17,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;
@@ -44,11 +45,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 })}
);
}
@@ -116,7 +117,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 (
@@ -131,7 +132,7 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? (
) : (
-
+
)}
{message.content_text && (
@@ -151,7 +152,7 @@ function MessageContent({ message }: { message: Message }) {
className="max-h-64 max-w-60 rounded-lg"
/>
) : (
-
+
)}
{message.content_text && (
@@ -167,14 +168,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")}
);
@@ -195,7 +196,7 @@ function MessageContent({ message }: { message: Message }) {
- Template
+ {t("template")}
{message.content_text && (
@@ -209,7 +210,7 @@ function MessageContent({ message }: { message: Message }) {
return (
- {message.content_text || "Location shared"}
+ {message.content_text || t("locationShared")}
);
@@ -223,10 +224,10 @@ function MessageContent({ message }: { message: Message }) {
- Button reply
+ {t("buttonReply")}
- {message.content_text || "[Interactive reply]"}
+ {message.content_text || t("interactiveReply")}
);
@@ -235,7 +236,7 @@ function MessageContent({ message }: { message: Message }) {
default:
return (
- {message.content_text || "[Unsupported message type]"}
+ {message.content_text || t("unsupported")}
);
}
@@ -248,6 +249,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");
@@ -275,7 +278,7 @@ export function MessageBubble({
onPrimary={isAgent}
/>
)}
-
+
- 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 a27f35f..bc9ed35 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,
@@ -108,10 +109,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");
}
@@ -165,6 +166,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);
@@ -234,17 +239,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
@@ -687,7 +692,7 @@ export function MessageThread({
setReplyTo({
id: msg.id,
authorLabel: authorLabelFor(msg),
- preview: buildReplyPreview(msg),
+ preview: buildReplyPreview(msg, tQuote),
});
},
[authorLabelFor],
@@ -788,10 +793,10 @@ export function MessageThread({
- Select a conversation
+ {t("selectConversation")}
- Choose a conversation from the left to start messaging
+ {t("selectConversationHint")}
);
@@ -805,8 +810,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
@@ -828,7 +833,7 @@ export function MessageThread({
@@ -866,10 +871,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",
@@ -893,8 +898,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",
)}
@@ -911,7 +916,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}`)}
))}
@@ -948,7 +953,7 @@ export function MessageThread({
>
{profiles.length === 0 ? (
- No teammates available
+ {t("noTeammates")}
) : (
profiles.map((p) => {
@@ -974,7 +979,7 @@ export function MessageThread({
/>
{p.full_name}
- {p.user_id === user?.id ? " (me)" : ""}
+ {p.user_id === user?.id ? t("me") : ""}
{isSelected && }
@@ -988,7 +993,7 @@ export function MessageThread({
onClick={() => handleAssignChange(null)}
className="text-sm text-muted-foreground"
>
- Unassign
+ {t("unassign")}
>
)}
@@ -1005,9 +1010,9 @@ export function MessageThread({
) : messages.length === 0 ? (
-
No messages yet
+
{t("noMessagesYet")}
- Send a template to start the conversation
+ {t("sendTemplateHint")}
) : (
@@ -1017,7 +1022,7 @@ export function MessageThread({
{/* Date separator */}
- {formatDateSeparator(group.date)}
+ {formatDateSeparator(group.date, t)}
{/* Messages */}
@@ -1028,8 +1033,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 bd16bca..39d7e9d 100644
--- a/src/components/settings/ai-config.tsx
+++ b/src/components/settings/ai-config.tsx
@@ -28,6 +28,7 @@ import { SettingsPanelHead } from './settings-panel-head';
import { AiKnowledgeCard } from './ai-knowledge';
import { AI_PROVIDER_DEFAULT_MODEL } from '@/lib/ai/defaults';
import type { AiProvider } from '@/lib/ai/types';
+import { useTranslations } from 'next-intl';
const MASKED_KEY = '••••••••••••••••';
@@ -44,6 +45,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);
@@ -77,7 +79,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) {
@@ -96,7 +98,7 @@ export function AiConfig() {
setEmbeddingsKeyEdited(false);
}
} catch {
- toast.error('Failed to load AI configuration');
+ toast.error(t('loadFailed'));
} finally {
setLoading(false);
}
@@ -149,10 +151,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);
}
@@ -160,11 +162,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);
@@ -176,13 +178,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);
}
@@ -193,7 +195,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('');
@@ -203,10 +205,10 @@ export function AiConfig() {
setSystemPrompt('');
} 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);
}
@@ -215,7 +217,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. */}
);
}
@@ -225,13 +228,13 @@ export function AiConfig() {
return (
{!canEdit && (
- Only admins and owners can change the AI configuration.
+ {t('adminOnlyConfig')}
)}
@@ -239,17 +242,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)}
@@ -268,7 +270,7 @@ export function AiConfig() {
-
Model
+
{t('model')}
-
API key
+
{t('apiKey')}
@@ -367,21 +367,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')}
diff --git a/src/components/settings/ai-knowledge.tsx b/src/components/settings/ai-knowledge.tsx
index 8e196b1..9a54b2a 100644
--- a/src/components/settings/ai-knowledge.tsx
+++ b/src/components/settings/ai-knowledge.tsx
@@ -14,6 +14,7 @@ import {
CardTitle,
CardDescription,
} from '@/components/ui/card';
+import { useTranslations } from 'next-intl';
interface DocSummary {
id: string;
@@ -41,6 +42,7 @@ export function AiKnowledgeCard({
const [saving, setSaving] = useState(false);
const [reindexing, setReindexing] = useState(false);
const loadedAccountIdRef = useRef(null);
+ const t = useTranslations('Settings.aiKnowledge');
const fetchDocs = useCallback(async () => {
setLoading(true);
@@ -48,9 +50,9 @@ export function AiKnowledgeCard({
const res = await fetch('/api/ai/knowledge');
const data = await res.json();
if (res.ok) setDocs(data.documents ?? []);
- else toast.error(data.error ?? 'Failed to load knowledge base');
+ else toast.error(data.error ?? t('loadFailed'));
} catch {
- toast.error('Failed to load knowledge base');
+ toast.error(t('loadFailed'));
} finally {
setLoading(false);
}
@@ -73,14 +75,14 @@ export function AiKnowledgeCard({
const res = await fetch(`/api/ai/knowledge/${id}`);
const data = await res.json();
if (!res.ok) {
- toast.error(data.error ?? 'Failed to open document');
+ toast.error(data.error ?? t('openFailed'));
return;
}
setEditing(id);
setTitle(data.title ?? '');
setContent(data.content ?? '');
} catch {
- toast.error('Failed to open document');
+ toast.error(t('openFailed'));
}
};
@@ -92,7 +94,7 @@ export function AiKnowledgeCard({
const save = async () => {
if (!title.trim() || !content.trim()) {
- toast.error('Title and content are required.');
+ toast.error(t('titleContentRequired'));
return;
}
setSaving(true);
@@ -110,14 +112,14 @@ export function AiKnowledgeCard({
if (res.ok) {
// A 200 with `warning` means saved but indexing degraded.
if (data.warning) toast.warning(data.warning);
- else toast.success(isNew ? 'Document added.' : 'Document updated.');
+ else toast.success(isNew ? t('saveSuccessNew') : t('saveSuccessUpdate'));
cancelEdit();
await fetchDocs();
} else {
- toast.error(data.error ?? 'Failed to save.');
+ toast.error(data.error ?? t('saveFailed'));
}
} catch {
- toast.error('Failed to save.');
+ toast.error(t('saveFailed'));
} finally {
setSaving(false);
}
@@ -127,14 +129,14 @@ export function AiKnowledgeCard({
try {
const res = await fetch(`/api/ai/knowledge/${id}`, { method: 'DELETE' });
if (res.ok) {
- toast.success('Document removed.');
+ toast.success(t('removeSuccess'));
setDocs((d) => d.filter((x) => x.id !== id));
} else {
const data = await res.json();
- toast.error(data.error ?? 'Failed to remove.');
+ toast.error(data.error ?? t('removeFailed'));
}
} catch {
- toast.error('Failed to remove.');
+ toast.error(t('removeFailed'));
}
};
@@ -144,12 +146,12 @@ export function AiKnowledgeCard({
const res = await fetch('/api/ai/knowledge/reindex', { method: 'POST' });
const data = await res.json();
if (res.ok && data.success) {
- toast.success(`Reindexed ${data.reindexed} document(s).`);
+ toast.success(t('reindexSuccess', { count: data.reindexed }));
} else {
- toast.error(data.error ?? 'Reindex failed.');
+ toast.error(data.error ?? t('reindexFailed'));
}
} catch {
- toast.error('Reindex failed.');
+ toast.error(t('reindexFailed'));
} finally {
setReindexing(false);
}
@@ -159,27 +161,24 @@ export function AiKnowledgeCard({
- Knowledge base
+ {t('title')}
- Add FAQs, policies, or product details. The assistant retrieves the
- relevant pieces when drafting and auto-replying, so it can answer
- instead of handing off.
- {hasEmbeddingsKey
- ? ' Semantic search is on (embeddings key set).'
- : ' Using keyword search — add an embeddings key above for semantic search.'}
+ {t('description', {
+ searchType: hasEmbeddingsKey ? t('semanticSearchOn') : t('keywordSearchOn')
+ })}
{loading ? (
- Loading…
+ {t('loading')}
) : (
<>
{docs.length === 0 && editing === null && (
- No documents yet.
+ {t('noDocs')}
)}
@@ -223,33 +222,33 @@ export function AiKnowledgeCard({
{editing !== null ? (
- Title
+ {t('editDocTitle')}
setTitle(e.target.value)}
- placeholder="e.g. Returns & refunds policy"
+ placeholder={t('editDocTitlePlaceholder')}
disabled={saving}
/>
- Content
+ {t('editDocContent')}
- Cancel
+ {t('cancel')}
{saving && }
- Save document
+ {t('saveDoc')}
@@ -257,7 +256,7 @@ export function AiKnowledgeCard({
canEdit && (
- Add document
+ {t('addDoc')}
{hasEmbeddingsKey && docs.length > 0 && (
{reindexing ? (
) : (
)}
- Reindex
+ {t('reindex')}
)}
diff --git a/src/components/settings/api-keys-settings.tsx b/src/components/settings/api-keys-settings.tsx
index 0efe9d3..22812af 100644
--- a/src/components/settings/api-keys-settings.tsx
+++ b/src/components/settings/api-keys-settings.tsx
@@ -40,6 +40,7 @@ import {
SCOPE_DESCRIPTIONS,
type ApiScope,
} from '@/lib/api-keys/scopes';
+import { useTranslations } from 'next-intl';
import { SettingsPanelHead } from './settings-panel-head';
interface ApiKey {
@@ -70,6 +71,7 @@ function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' {
export function ApiKeysSettings() {
const { canEditSettings } = useAuth();
+ const t = useTranslations('Settings.apiKeys');
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
@@ -81,14 +83,14 @@ export function ApiKeysSettings() {
const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
- toast.error(payload.error || 'Failed to load API keys');
+ toast.error(payload.error || t('loadFailed'));
return;
}
const data = (await res.json()) as { keys: ApiKey[] };
setKeys(data.keys);
} catch (err) {
console.error('[ApiKeysSettings] load error:', err);
- toast.error('Could not reach the server');
+ toast.error(t('networkError'));
} finally {
setLoading(false);
}
@@ -106,10 +108,10 @@ export function ApiKeysSettings() {
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
- toast.error(payload.error || 'Failed to revoke key');
+ toast.error(payload.error || t('revokeFailed'));
return;
}
- toast.success(`Revoked "${key.name}"`);
+ toast.success(t('revokeSuccess', { name: key.name }));
// Reflect the revoke locally without a refetch.
setKeys((prev) =>
prev.map((k) =>
@@ -118,7 +120,7 @@ export function ApiKeysSettings() {
);
} catch (err) {
console.error('[ApiKeysSettings] revoke error:', err);
- toast.error('Could not reach the server');
+ toast.error(t('networkError'));
} finally {
setRevoking(null);
}
@@ -135,20 +137,18 @@ export function ApiKeysSettings() {
return (
- Keys authenticate the public REST API (
- /api/v1) so you can build your own
- automations. Send them as{' '}
- Authorization: Bearer <key>.
- >
+ t.rich('description', {
+ apiCode: (chunks: React.ReactNode) => {chunks},
+ headerCode: (chunks: React.ReactNode) => {chunks}
+ })
}
action={
setCreateOpen(true)}>
- New API key
+ {t('newApiKey')}
}
@@ -159,16 +159,19 @@ export function ApiKeysSettings() {
- No API keys yet.
+ {t('noApiKeys')}
{canEditSettings ? (
- Click New API key to
- create one.
+ {t.rich('createOneHint', {
+ bold: (chunks: React.ReactNode) => (
+ {chunks}
+ ),
+ })}
) : (
- Ask an admin to create one.
+ {t('askAdminHint')}
)}
@@ -198,12 +201,12 @@ export function ApiKeysSettings() {
{status === 'revoked' && (
- Revoked
+ {t('revoked')}
)}
{status === 'expired' && (
- Expired
+ {t('expired')}
)}
@@ -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() {
-
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",
},
};