feat(i18n): translate inbox module and components

- Add Inbox namespace to en.json dictionary
- Localize InboxPage and WhatsApp connection banners
- Translate ConversationList, search placeholders, and filters
- Localize MessageThread, session timers, and agent assignment dropdowns
- Translate MessageComposer, attachment menus, and AI draft hints
- Update MessageBubble, MessageActions, ReplyQuote, and TemplatePicker
- Verify strict typing for next-intl hooks across all inbox files
This commit is contained in:
Ivan Mercedes 2026-07-07 15:09:24 -04:00
parent afa136245b
commit 57a914d547
10 changed files with 278 additions and 115 deletions

View File

@ -124,5 +124,130 @@
"emptyState": { "emptyState": {
"title": "Not enough data yet" "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"
}
} }
} }

View File

@ -2,6 +2,7 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { import {
CONVERSATION_SELECT, CONVERSATION_SELECT,
@ -21,6 +22,7 @@ import { cn } from "@/lib/utils";
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open"; const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
export default function InboxPage() { export default function InboxPage() {
const t = useTranslations("Inbox.page");
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
/** /**
@ -556,7 +558,7 @@ export default function InboxPage() {
<div className="flex shrink-0 items-center justify-center gap-2 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2"> <div className="flex shrink-0 items-center justify-center gap-2 border-b border-amber-500/20 bg-amber-500/10 px-4 py-2">
<WifiOff className="h-4 w-4 text-amber-400" /> <WifiOff className="h-4 w-4 text-amber-400" />
<p className="text-xs text-amber-400"> <p className="text-xs text-amber-400">
WhatsApp® is not connected. Go to Settings to connect your account. {t("whatsappNotConnected")}
</p> </p>
</div> </div>
)} )}

View File

@ -19,12 +19,16 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { format } from "date-fns"; import { format } from "date-fns";
import { useTranslations } from "next-intl";
interface ContactSidebarProps { interface ContactSidebarProps {
contact: Contact | null; contact: Contact | null;
} }
export function ContactSidebar({ contact }: ContactSidebarProps) { export function ContactSidebar({ contact }: ContactSidebarProps) {
const tSidebar = useTranslations("Inbox.sidebar");
const tThread = useTranslations("Inbox.messageThread");
const { accountId } = useAuth(); const { accountId } = useAuth();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [deals, setDeals] = useState<Deal[]>([]); const [deals, setDeals] = useState<Deal[]>([]);
@ -118,7 +122,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
if (!contact) { if (!contact) {
return ( return (
<div className="flex h-full w-70 items-center justify-center border-l border-border bg-card"> <div className="flex h-full w-70 items-center justify-center border-l border-border bg-card">
<p className="text-sm text-muted-foreground">Select a conversation</p> <p className="text-sm text-muted-foreground">{tThread("selectConversation")}</p>
</div> </div>
); );
} }
@ -181,7 +185,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div> <div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<TagIcon className="h-3 w-3" /> <TagIcon className="h-3 w-3" />
Tags {tSidebar("tags")}
</div> </div>
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-2 flex flex-wrap gap-1">
{tags.length === 0 ? ( {tags.length === 0 ? (
@ -210,7 +214,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div> <div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<DollarSign className="h-3 w-3" /> <DollarSign className="h-3 w-3" />
Active Deals {tSidebar("deals")}
</div> </div>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{deals.length === 0 ? ( {deals.length === 0 ? (
@ -254,7 +258,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div> <div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<StickyNote className="h-3 w-3" /> <StickyNote className="h-3 w-3" />
Notes {tSidebar("notes")}
</div> </div>
<div className="mt-2"> <div className="mt-2">
<div className="flex gap-2"> <div className="flex gap-2">

View File

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

View File

@ -10,6 +10,7 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import type { Message } from "@/types"; import type { Message } from "@/types";
import { useTranslations } from "next-intl";
// WhatsApp's own quick-reaction bar starts with these six. Picking the same // 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. // set keeps the affordance familiar without pulling in a 300KB emoji library.
@ -33,6 +34,8 @@ export function MessageActions({
onReact, onReact,
children, children,
}: MessageActionsProps) { }: MessageActionsProps) {
const t = useTranslations("Inbox.actions");
// Touch devices have no hover. Long-press fires `contextmenu`; we capture // Touch devices have no hover. Long-press fires `contextmenu`; we capture
// it, suppress the native menu, and pin the toolbar open until the user // it, suppress the native menu, and pin the toolbar open until the user
// interacts elsewhere. // interacts elsewhere.
@ -50,14 +53,14 @@ export function MessageActions({
const handleCopy = async () => { const handleCopy = async () => {
const text = message.content_text ?? ""; const text = message.content_text ?? "";
if (!text) { if (!text) {
toast.error("Nothing to copy"); toast.error(t("nothingToCopy"));
return; return;
} }
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
toast.success("Copied"); toast.success(t("copied"));
} catch { } catch {
toast.error("Copy failed"); toast.error(t("copyFailed"));
} }
setTouchOpen(false); setTouchOpen(false);
}; };
@ -104,7 +107,7 @@ export function MessageActions({
<Popover open={pickerOpen} onOpenChange={setPickerOpen}> <Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger <PopoverTrigger
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground" className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="React" aria-label={t("react")}
> >
<SmilePlus className="h-3.5 w-3.5" /> <SmilePlus className="h-3.5 w-3.5" />
</PopoverTrigger> </PopoverTrigger>
@ -118,7 +121,7 @@ export function MessageActions({
type="button" type="button"
onClick={() => handlePickEmoji(e)} 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" 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} {e}
</button> </button>
@ -129,7 +132,7 @@ export function MessageActions({
type="button" type="button"
onClick={handleReply} onClick={handleReply}
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground" className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Reply" aria-label={t("reply")}
> >
<CornerUpLeft className="h-3.5 w-3.5" /> <CornerUpLeft className="h-3.5 w-3.5" />
</button> </button>
@ -137,7 +140,7 @@ export function MessageActions({
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground" className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy" aria-label={t("copyText")}
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>

View File

@ -17,6 +17,7 @@ import {
import { format } from "date-fns"; import { format } from "date-fns";
import { ReplyQuote } from "./reply-quote"; import { ReplyQuote } from "./reply-quote";
import { MessageReactions } from "./message-reactions"; import { MessageReactions } from "./message-reactions";
import { useTranslations } from "next-intl";
interface MessageBubbleProps { interface MessageBubbleProps {
message: Message; 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<typeof useTranslations> }) {
return ( return (
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" /> <ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{label} unavailable</span> <span>{t("unavailable", { label })}</span>
</div> </div>
); );
} }
@ -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<typeof useTranslations> }) {
switch (message.content_type) { switch (message.content_type) {
case "text": case "text":
return ( return (
@ -131,7 +132,7 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? ( {message.media_url ? (
<MediaImage url={message.media_url} alt="Shared image" /> <MediaImage url={message.media_url} alt="Shared image" />
) : ( ) : (
<MediaUnavailable label="Image" /> <MediaUnavailable label={t("photo")} t={t} />
)} )}
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -151,7 +152,7 @@ function MessageContent({ message }: { message: Message }) {
className="max-h-64 max-w-60 rounded-lg" className="max-h-64 max-w-60 rounded-lg"
/> />
) : ( ) : (
<MediaUnavailable label="Video" /> <MediaUnavailable label={t("video")} t={t} />
)} )}
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -167,14 +168,14 @@ function MessageContent({ message }: { message: Message }) {
{message.media_url ? ( {message.media_url ? (
<audio src={message.media_url} controls className="max-w-60" /> <audio src={message.media_url} controls className="max-w-60" />
) : ( ) : (
<MediaUnavailable label="Audio" /> <MediaUnavailable label={t("audio")} t={t} />
)} )}
</div> </div>
); );
case "document": case "document":
if (!message.media_url) { if (!message.media_url) {
return <MediaUnavailable label={message.content_text || "Document"} />; return <MediaUnavailable label={message.content_text || t("document")} t={t} />;
} }
return ( return (
<a <a
@ -185,7 +186,7 @@ function MessageContent({ message }: { message: Message }) {
> >
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" /> <FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate"> <span className="truncate">
{message.content_text || "Document"} {message.content_text || t("document")}
</span> </span>
</a> </a>
); );
@ -195,7 +196,7 @@ function MessageContent({ message }: { message: Message }) {
<div> <div>
<span className="mb-1 inline-flex items-center gap-1 rounded bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary"> <span className="mb-1 inline-flex items-center gap-1 rounded bg-primary/20 px-1.5 py-0.5 text-[10px] font-medium text-primary">
<LayoutTemplate className="h-3 w-3" /> <LayoutTemplate className="h-3 w-3" />
Template {t("template")}
</span> </span>
{message.content_text && ( {message.content_text && (
<p className="mt-1 whitespace-pre-wrap break-words text-sm"> <p className="mt-1 whitespace-pre-wrap break-words text-sm">
@ -209,7 +210,7 @@ function MessageContent({ message }: { message: Message }) {
return ( return (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 shrink-0 text-muted-foreground" /> <MapPin className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{message.content_text || "Location shared"}</span> <span>{message.content_text || t("locationShared")}</span>
</div> </div>
); );
@ -223,10 +224,10 @@ function MessageContent({ message }: { message: Message }) {
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<CornerDownLeft className="h-3 w-3" /> <CornerDownLeft className="h-3 w-3" />
Button reply {t("buttonReply")}
</span> </span>
<p className="whitespace-pre-wrap break-words text-sm"> <p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Interactive reply]"} {message.content_text || t("interactiveReply")}
</p> </p>
</div> </div>
); );
@ -235,7 +236,7 @@ function MessageContent({ message }: { message: Message }) {
default: default:
return ( return (
<p className="whitespace-pre-wrap break-words text-sm"> <p className="whitespace-pre-wrap break-words text-sm">
{message.content_text || "[Unsupported message type]"} {message.content_text || t("unsupported")}
</p> </p>
); );
} }
@ -248,6 +249,8 @@ export function MessageBubble({
currentUserId, currentUserId,
onToggleReaction, onToggleReaction,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const t = useTranslations("Inbox.bubble");
const isAgent = message.sender_type === "agent" || message.sender_type === "bot"; const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
const time = format(new Date(message.created_at), "HH:mm"); const time = format(new Date(message.created_at), "HH:mm");
@ -275,7 +278,7 @@ export function MessageBubble({
onPrimary={isAgent} onPrimary={isAgent}
/> />
)} )}
<MessageContent message={message} /> <MessageContent message={message} t={t} />
<div <div
className={cn( className={cn(
"mt-1 flex items-center gap-1", "mt-1 flex items-center gap-1",

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Message } from "@/types"; import type { Message } from "@/types";
import { useTranslations } from "next-intl";
interface ReplyQuoteProps { interface ReplyQuoteProps {
/** Sender label of the quoted message: "You" for our own messages, /** Sender label of the quoted message: "You" for our own messages,
@ -26,6 +27,7 @@ export function ReplyQuote({
onDismiss, onDismiss,
onPrimary = false, onPrimary = false,
}: ReplyQuoteProps) { }: ReplyQuoteProps) {
const t = useTranslations("Inbox.replyQuote");
const isChip = !!onDismiss; const isChip = !!onDismiss;
return ( return (
<div <div
@ -63,7 +65,7 @@ export function ReplyQuote({
<button <button
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
aria-label="Cancel reply" aria-label={t("cancelReply")}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground" className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
@ -74,22 +76,22 @@ export function ReplyQuote({
} }
/** Build the one-line preview text shown inside a reply quote. */ /** Build the one-line preview text shown inside a reply quote. */
export function buildReplyPreview(message: Message): string { export function buildReplyPreview(message: Message, t: ReturnType<typeof useTranslations>): string {
if (message.content_text) return message.content_text; if (message.content_text) return message.content_text;
switch (message.content_type) { switch (message.content_type) {
case "image": case "image":
return "[Image]"; return t("photo");
case "video": case "video":
return "[Video]"; return t("video");
case "audio": case "audio":
return "[Audio]"; return t("audio");
case "document": case "document":
return "[Document]"; return t("document");
case "location": case "location":
return "[Location]"; return t("location");
case "template": case "template":
return "[Template]"; return t("template");
default: default:
return "[Message]"; return t("message");
} }
} }

View File

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