diff --git a/messages/en.json b/messages/en.json
index 939d20a..79d156c 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -124,5 +124,130 @@
"emptyState": {
"title": "Not enough data yet"
}
+ },
+ "Inbox": {
+ "page": {
+ "whatsappNotConnected": "WhatsApp® is not connected. Go to Settings to connect your account."
+ },
+ "conversationList": {
+ "searchPlaceholder": "Search conversations...",
+ "filterAll": "All",
+ "filterUnread": "Unread",
+ "filterOpen": "Open",
+ "filterPending": "Pending",
+ "filterClosed": "Closed",
+ "tags": "Tags",
+ "company": "Company",
+ "allCompanies": "All companies",
+ "clearAll": "Clear all",
+ "noConversations": "No conversations found",
+ "noMessagesYet": "No messages yet",
+ "unknown": "Unknown"
+ },
+ "messageThread": {
+ "backToConversations": "Back to conversations",
+ "hideContactPanel": "Hide contact panel",
+ "showContactPanel": "Show contact panel",
+ "hideContact": "Hide contact",
+ "showContact": "Show contact",
+ "refresh": "Refresh",
+ "refreshConversation": "Refresh conversation",
+ "status": "Status",
+ "statusOpen": "Open",
+ "statusPending": "Pending",
+ "statusClosed": "Closed",
+ "assign": "Assign",
+ "assigned": "Assigned",
+ "unassign": "Unassign",
+ "noTeammates": "No teammates available",
+ "me": " (me)",
+ "noMessagesYet": "No messages yet",
+ "sendTemplateHint": "Send a template to start the conversation",
+ "selectConversation": "Select a conversation",
+ "selectConversationHint": "Choose a conversation from the left to start messaging",
+ "today": "Today",
+ "yesterday": "Yesterday"
+ },
+ "sessionTimer": {
+ "expired": "Expired",
+ "xhRemaining": "{hours}h remaining",
+ "xmRemaining": "{minutes}m remaining",
+ "noCustomerMessages": "No customer messages"
+ },
+ "composer": {
+ "draftHint": "Tap the ✨ to draft a reply with AI — you can edit it before sending",
+ "sessionExpiredHint": "24-hour session expired. Use a template to re-engage.",
+ "templates": "Templates",
+ "readOnlyPlaceholder": "Read-only — viewers can browse but not reply",
+ "sessionExpiredPlaceholder": "Session expired - use a template",
+ "typeMessagePlaceholder": "Type a message... (Shift+Enter for new line)",
+ "readOnlyTitle": "Read-only — your role can't send messages",
+ "recording": "Recording… {current} / {max}",
+ "cancel": "Cancel",
+ "stopAndAttach": "Stop and attach",
+ "attachMedia": "Attach media",
+ "photo": "Photo",
+ "video": "Video",
+ "document": "Document",
+ "voiceNote": "Voice note",
+ "sendTemplate": "Send template",
+ "draftWithAI": "Draft a reply with AI",
+ "addCaption": "Add a caption…",
+ "removeAttachment": "Remove attachment"
+ },
+ "bubble": {
+ "photo": "Photo",
+ "video": "Video",
+ "audio": "Audio",
+ "unavailable": "{label} unavailable",
+ "document": "Document",
+ "locationShared": "Location shared",
+ "template": "Template",
+ "buttonReply": "Button reply",
+ "interactiveReply": "[Interactive reply]",
+ "unsupported": "[Unsupported message type]"
+ },
+ "actions": {
+ "reply": "Reply",
+ "copyText": "Copy text",
+ "delete": "Delete",
+ "react": "React",
+ "reactWith": "React with {emoji}",
+ "nothingToCopy": "Nothing to copy",
+ "copied": "Copied",
+ "copyFailed": "Copy failed"
+ },
+ "replyQuote": {
+ "photo": "[Photo]",
+ "video": "[Video]",
+ "document": "[Document]",
+ "audio": "[Audio]",
+ "voiceMessage": "[Voice message]",
+ "location": "[Location]",
+ "template": "[Template]",
+ "message": "[Message]",
+ "cancelReply": "Cancel reply"
+ },
+ "templatePicker": {
+ "sendTemplate": "Send template",
+ "fillPlaceholders": "Fill in the placeholders to render this template. Meta requires every variable to be set.",
+ "pickTemplate": "Pick an approved WhatsApp template to send to this contact.",
+ "noApprovedTemplates": "No approved templates",
+ "noApprovedTemplatesHint": "Approve a template in Meta WhatsApp Manager, then sync it from Settings → Templates.",
+ "preview": "Preview",
+ "headerValuePlaceholder": "Value for the header variable",
+ "bodyValuePlaceholder": "Value for {val}",
+ "urlSuffixValuePlaceholder": "URL suffix value",
+ "finalUrl": "Final URL: {url}",
+ "back": "Back",
+ "cancel": "Cancel",
+ "send": "Send template"
+ },
+ "sidebar": {
+ "contactInfo": "Contact info",
+ "tags": "Tags",
+ "notes": "Notes",
+ "deals": "Deals"
+ }
}
}
diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx
index 70480ec..b32e74b 100644
--- a/src/app/(dashboard)/inbox/page.tsx
+++ b/src/app/(dashboard)/inbox/page.tsx
@@ -2,6 +2,7 @@
import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation";
+import { useTranslations } from "next-intl";
import { createClient } from "@/lib/supabase/client";
import {
CONVERSATION_SELECT,
@@ -21,6 +22,7 @@ import { cn } from "@/lib/utils";
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
export default function InboxPage() {
+ const t = useTranslations("Inbox.page");
const router = useRouter();
const searchParams = useSearchParams();
/**
@@ -556,7 +558,7 @@ export default function InboxPage() {
- WhatsApp® is not connected. Go to Settings to connect your account.
+ {t("whatsappNotConnected")}
)}
diff --git a/src/components/inbox/contact-sidebar.tsx b/src/components/inbox/contact-sidebar.tsx
index cbaa4ee..8249839 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,7 +185,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
- Tags
+ {tSidebar("tags")}
{tags.length === 0 ? (
@@ -210,7 +214,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
- Active Deals
+ {tSidebar("deals")}
{deals.length === 0 ? (
@@ -254,7 +258,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
- Notes
+ {tSidebar("notes")}
diff --git a/src/components/inbox/conversation-list.tsx b/src/components/inbox/conversation-list.tsx
index d3dafd2..8706e80 100644
--- a/src/components/inbox/conversation-list.tsx
+++ b/src/components/inbox/conversation-list.tsx
@@ -11,6 +11,7 @@ import { cn } from "@/lib/utils";
import type { Conversation, ConversationStatus, Tag } from "@/types";
import { Search, ChevronDown, X } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
+import { useTranslations } from "next-intl";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
@@ -41,15 +42,9 @@ const STATUS_COLORS: Record = {
closed: "bg-muted-foreground",
};
-type InboxFilter = ConversationStatus | "all" | "unread";
-const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = [
- { label: "All", value: "all" },
- { label: "Unread", value: "unread" },
- { label: "Open", value: "open" },
- { label: "Pending", value: "pending" },
- { label: "Closed", value: "closed" },
-];
+
+type InboxFilter = ConversationStatus | "all" | "unread";
export function ConversationList({
activeConversationId,
@@ -58,6 +53,16 @@ export function ConversationList({
onConversationsLoaded,
resyncToken = 0,
}: ConversationListProps) {
+ const t = useTranslations("Inbox.conversationList");
+
+ const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = useMemo(() => [
+ { label: t("filterAll"), value: "all" },
+ { label: t("filterUnread"), value: "unread" },
+ { label: t("filterOpen"), value: "open" },
+ { label: t("filterPending"), value: "pending" },
+ { label: t("filterClosed"), value: "closed" },
+ ], [t]);
+
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("all");
const [loading, setLoading] = useState(true);
@@ -226,7 +231,7 @@ export function ConversationList({
@@ -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..310710f 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,11 @@ export function MessageThread({
contactPanelOpen,
onToggleContactPanel,
}: MessageThreadProps) {
+ const t = useTranslations("Inbox.messageThread");
+ const tTimer = useTranslations("Inbox.sessionTimer");
+ const tComposer = useTranslations("Inbox.composer");
+ const tQuote = useTranslations("Inbox.replyQuote");
+
const { user } = useAuth();
const { getPresence, getRow, now } = usePresence();
const [loading, setLoading] = useState(false);
@@ -234,17 +240,17 @@ export function MessageThread({
const expired = hoursSince >= 24;
if (expired) {
- return { expired: true, remaining: "Expired" };
+ return { expired: true, remaining: tTimer("expired") };
}
const hoursLeft = 24 - hoursSince;
const remaining =
hoursLeft >= 1
- ? `${Math.floor(hoursLeft)}h remaining`
- : `${Math.floor(hoursLeft * 60)}m remaining`;
+ ? tTimer("xhRemaining", { hours: Math.floor(hoursLeft) })
+ : tTimer("xmRemaining", { minutes: Math.floor(hoursLeft * 60) });
return { expired, remaining };
- }, [messages]);
+ }, [messages, tTimer]);
// Store latest callback in a ref so fetchMessages doesn't need to
// depend on `onMessagesLoaded` — otherwise parent re-renders cause
@@ -687,7 +693,7 @@ export function MessageThread({
setReplyTo({
id: msg.id,
authorLabel: authorLabelFor(msg),
- preview: buildReplyPreview(msg),
+ preview: buildReplyPreview(msg, tQuote),
});
},
[authorLabelFor],
@@ -788,10 +794,10 @@ export function MessageThread({
- Select a conversation
+ {t("selectConversation")}
- Choose a conversation from the left to start messaging
+ {t("selectConversationHint")}
);
@@ -805,8 +811,8 @@ export function MessageThread({
const assignedAgentId = conversation.assigned_agent_id ?? null;
const currentAssignee = profiles.find((p) => p.user_id === assignedAgentId);
const assignLabel = assignedAgentId
- ? (currentAssignee?.full_name ?? "Assigned")
- : "Assign";
+ ? (currentAssignee?.full_name ?? t("assigned"))
+ : t("assign");
return (
// `min-w-0` is load-bearing: the page already puts min-w-0 on the
@@ -828,7 +834,7 @@ export function MessageThread({
@@ -866,10 +872,10 @@ export function MessageThread({
type="button"
onClick={onToggleContactPanel}
aria-label={
- contactPanelOpen ? "Hide contact panel" : "Show contact panel"
+ contactPanelOpen ? t("hideContactPanel") : t("showContactPanel")
}
+ title={contactPanelOpen ? t("hideContact") : t("showContact")}
aria-pressed={contactPanelOpen}
- title={contactPanelOpen ? "Hide contact" : "Show contact"}
className={cn(
"hidden h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground lg:inline-flex",
contactPanelOpen ? "text-primary" : "text-muted-foreground",
@@ -893,8 +899,8 @@ export function MessageThread({
type="button"
onClick={handleRefreshClick}
disabled={isRefreshing}
- aria-label="Refresh conversation"
- title="Refresh"
+ aria-label={t("refreshConversation")}
+ title={t("refresh")}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-60",
)}
@@ -911,7 +917,7 @@ export function MessageThread({
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
currentStatus?.color ?? "text-muted-foreground"
)}>
- {currentStatus?.label ?? "Status"}
+ {currentStatus ? t(`status${currentStatus.label}`) : t("status")}
handleStatusChange(opt.value)}
className={cn("text-sm", opt.color)}
>
- {opt.label}
+ {t(`status${opt.label}`)}
))}
@@ -948,7 +954,7 @@ export function MessageThread({
>
{profiles.length === 0 ? (
- No teammates available
+ {t("noTeammates")}
) : (
profiles.map((p) => {
@@ -974,7 +980,7 @@ export function MessageThread({
/>
{p.full_name}
- {p.user_id === user?.id ? " (me)" : ""}
+ {p.user_id === user?.id ? t("me") : ""}
{isSelected && }
@@ -988,7 +994,7 @@ export function MessageThread({
onClick={() => handleAssignChange(null)}
className="text-sm text-muted-foreground"
>
- Unassign
+ {t("unassign")}
>
)}
@@ -1005,9 +1011,9 @@ export function MessageThread({
) : messages.length === 0 ? (
-
No messages yet
+
{t("noMessagesYet")}
- Send a template to start the conversation
+ {t("sendTemplateHint")}
) : (
@@ -1017,7 +1023,7 @@ export function MessageThread({
{/* Date separator */}
- {formatDateSeparator(group.date)}
+ {formatDateSeparator(group.date, t)}
{/* Messages */}
@@ -1028,8 +1034,11 @@ export function MessageThread({
: null;
const reply = parent
? {
- authorLabel: authorLabelFor(parent),
- preview: buildReplyPreview(parent),
+ authorLabel:
+ parent.sender_type === "agent" || parent.sender_type === "bot"
+ ? t("me")
+ : contact?.name || contact?.phone || "Unknown",
+ preview: buildReplyPreview(parent, tQuote),
}
: null;
const msgReactions = reactionsByMessageId.get(msg.id);
diff --git a/src/components/inbox/reply-quote.tsx b/src/components/inbox/reply-quote.tsx
index c7dadd3..20dd195 100644
--- a/src/components/inbox/reply-quote.tsx
+++ b/src/components/inbox/reply-quote.tsx
@@ -3,6 +3,7 @@
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Message } from "@/types";
+import { useTranslations } from "next-intl";
interface ReplyQuoteProps {
/** Sender label of the quoted message: "You" for our own messages,
@@ -26,6 +27,7 @@ export function ReplyQuote({
onDismiss,
onPrimary = false,
}: ReplyQuoteProps) {
+ const t = useTranslations("Inbox.replyQuote");
const isChip = !!onDismiss;
return (
@@ -74,22 +76,22 @@ export function ReplyQuote({
}
/** Build the one-line preview text shown inside a reply quote. */
-export function buildReplyPreview(message: Message): string {
+export function buildReplyPreview(message: Message, t: ReturnType): string {
if (message.content_text) return message.content_text;
switch (message.content_type) {
case "image":
- return "[Image]";
+ return t("photo");
case "video":
- return "[Video]";
+ return t("video");
case "audio":
- return "[Audio]";
+ return t("audio");
case "document":
- return "[Document]";
+ return t("document");
case "location":
- return "[Location]";
+ return t("location");
case "template":
- return "[Template]";
+ return t("template");
default:
- return "[Message]";
+ return t("message");
}
}
diff --git a/src/components/inbox/template-picker.tsx b/src/components/inbox/template-picker.tsx
index 9a0b754..4715763 100644
--- a/src/components/inbox/template-picker.tsx
+++ b/src/components/inbox/template-picker.tsx
@@ -22,6 +22,7 @@ import {
Loader2,
} from "lucide-react";
import { extractVariableIndices } from "@/lib/whatsapp/template-validators";
+import { useTranslations } from "next-intl";
export interface TemplateSendValues {
body: string[];
@@ -78,6 +79,8 @@ export function TemplatePicker({
onOpenChange,
onSelect,
}: TemplatePickerProps) {
+ const t = useTranslations("Inbox.templatePicker");
+
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState(null);
@@ -190,12 +193,12 @@ export function TemplatePicker({
- {selected ? selected.name : "Send template"}
+ {selected ? selected.name : t("sendTemplate")}
{selected
- ? "Fill in the placeholders to render this template. Meta requires every variable to be set."
- : "Pick an approved WhatsApp template to send to this contact."}
+ ? t("fillPlaceholders")
+ : t("pickTemplate")}
@@ -207,10 +210,9 @@ export function TemplatePicker({
) : templates.length === 0 ? (
-
No approved templates
+
{t("noApprovedTemplates")}
- Approve a template in Meta WhatsApp Manager, then sync it
- from Settings → Templates.
+ {t("noApprovedTemplatesHint")}
) : (
@@ -249,7 +251,7 @@ export function TemplatePicker({
) : (
-
Preview
+
{t("preview")}
{renderBodyPreview(selected.body_text, params)}
@@ -267,7 +269,7 @@ export function TemplatePicker({
setHeaderText(e.target.value)}
- placeholder="Value for the header variable"
+ placeholder={t("headerValuePlaceholder")}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
@@ -282,7 +284,7 @@ export function TemplatePicker({
next[i] = e.target.value;
setParams(next);
}}
- placeholder={`Value for {{${v}}}`}
+ placeholder={t("bodyValuePlaceholder", { val: `{{${v}}}` })}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
@@ -300,11 +302,11 @@ export function TemplatePicker({
[slot.index]: e.target.value,
}))
}
- placeholder="URL suffix value"
+ placeholder={t("urlSuffixValuePlaceholder")}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
- Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
+ {t("finalUrl", { url: slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}") })}
))}
@@ -320,14 +322,14 @@ export function TemplatePicker({
className="border-border text-popover-foreground hover:bg-muted"
>
- Back
+ {t("back")}
- Send template
+ {t("send")}
>
) : (
@@ -336,7 +338,7 @@ export function TemplatePicker({
onClick={() => handleOpenChange(false)}
className="border-border text-popover-foreground hover:bg-muted"
>
- Cancel
+ {t("cancel")}
)}