mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
afa136245b
commit
57a914d547
125
messages/en.json
125
messages/en.json
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user