diff --git a/console/public/locales/en.json b/console/public/locales/en.json index f178c847..6d9d6485 100644 --- a/console/public/locales/en.json +++ b/console/public/locales/en.json @@ -560,7 +560,7 @@ "onboarding_project-getting-started_journey": "Journeys automate your user flows with scheduled or event-based messages.", "onboarding_project-getting-started_campaign": "Campaigns handle one-time sends, or plug into journeys to make them even smarter.", "open_rate": "Open Rate", - "opened_at": "Opened At", + "opened_at": "Read At", "options": "Options", "organizations": "Organizations", "organization_data": "Organization Data", @@ -704,7 +704,6 @@ "status": "Status", "state": "State", "static": "Static", - "status": "Status", "step_date": "Step Date", "sticky": "Sticky Note", "sticky_desc": "Add a sticky note that will be visible in the journey editor.", @@ -924,5 +923,37 @@ "emptyTitle": "No images yet", "emptyDescription": "Drag and drop images here, or click Upload to get started.", "imageCount": "{{count}} image(s)" - } + }, + "all": "All", + "creating": "Creating...", + "from_address": "From address", + "from_number": "From number", + "inbox_body_placeholder": "Write your message...", + "inbox_channel_help": "Choose how the message is delivered.", + "inbox_from_address_help": "Optional. Overrides the default sender address.", + "inbox_from_number_help": "Optional. Overrides the default sender number.", + "inbox_message_archived": "Message archived", + "inbox_message_create_failed": "Failed to create message", + "inbox_message_created": "Message created", + "inbox_message_opened": "Message marked as read", + "inbox_message_scheduled": "Message scheduled", + "inbox_message_update_failed": "Failed to update message", + "inbox_tags_help": "Comma separated. Duplicates are removed.", + "inbox_tags_placeholder": "billing, onboarding", + "inbox_title_placeholder": "Welcome to Lunogram", + "mark_opened": "Mark as read", + "messages": "messages", + "new_inbox_message": "New inbox message", + "new_message": "New message", + "next_page": "Next page", + "no_inbox_messages": "No inbox messages", + "no_inbox_messages_description": "There are no messages in this inbox yet. Create a new one to get started.", + "open_menu": "Open menu", + "opened": "Read", + "previous_page": "Previous page", + "scheduled_at": "Scheduled at", + "search_inbox": "Search inbox...", + "sender": "Sender", + "sms": "SMS", + "unread": "Unread" } diff --git a/console/src/components/inbox-detail-table.tsx b/console/src/components/inbox-detail-table.tsx new file mode 100644 index 00000000..ab3df6e1 --- /dev/null +++ b/console/src/components/inbox-detail-table.tsx @@ -0,0 +1,980 @@ +import { useCallback, useContext, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import type { TFunction } from "i18next" +import { + Archive, + ArchiveRestore, + Bell, + CalendarClock, + Check, + ChevronLeft, + ChevronRight, + EyeOff, + Inbox, + Info, + Mail, + MessageSquare, + MoreHorizontal, + Plus, + Search, +} from "lucide-react" +import { toast } from "sonner" +import { ProjectContext } from "../contexts" +import { PreferencesContext } from "@/contexts/PreferencesContext" +import { useResolver } from "../hooks" +import { formatDate, getPageNumbers } from "../utils" +import oapiClient from "../oapi/client" +import type { components } from "../oapi/management.generated" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { SenderIdentityCombobox } from "@/components/sender-identity-combobox" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Skeleton } from "@/components/ui/skeleton" +import { Textarea } from "@/components/ui/textarea" +import { DateTimeEdit } from "@/components/ui/datetime-edit" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +type InboxMessage = components["schemas"]["InboxMessage"] +type InboxStatus = "all" | "unread" | "opened" | "archived" +type InboxChannel = components["schemas"]["Channel"] + +interface InboxDetailTableProps { + subjectId: string + subjectType: "users" | "organizations" +} + +const limit = 15 + +export default function InboxDetailTable({ subjectId, subjectType }: InboxDetailTableProps) { + const { t } = useTranslation() + const [project] = useContext(ProjectContext) + const [preferences] = useContext(PreferencesContext) + const [page, setPage] = useState(1) + const [status, setStatus] = useState("all") + const [searchQuery, setSearchQuery] = useState("") + const [debouncedQuery, setDebouncedQuery] = useState("") + const [isCreateOpen, setIsCreateOpen] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const [channel, setChannel] = useState("inbox") + const [senderIdentityId, setSenderIdentityId] = useState("") + const [title, setTitle] = useState("") + const [body, setBody] = useState("") + const [tags, setTags] = useState("") + const [scheduledAt, setScheduledAt] = useState("") + const searchTimeoutRef = useRef>() + + const handleSearch = (value: string) => { + setSearchQuery(value) + setPage(1) + clearTimeout(searchTimeoutRef.current) + searchTimeoutRef.current = setTimeout(() => { + setDebouncedQuery(value) + }, 300) + } + + const resetForm = () => { + setChannel("inbox") + setSenderIdentityId("") + setTitle("") + setBody("") + setTags("") + setScheduledAt("") + } + + const [result, , reload] = useResolver( + useCallback(async () => { + const params = { + limit, + offset: (page - 1) * limit, + search: debouncedQuery || undefined, + status: status === "all" ? undefined : status, + include_archived: status === "all" ? true : undefined, + include_scheduled: true, + } + + if (subjectType === "users") { + const { data, error } = await oapiClient.GET( + "/api/admin/projects/{projectID}/subjects/users/{userID}/inbox", + { + params: { + path: { projectID: project.id, userID: subjectId }, + query: params, + }, + }, + ) + if (error) throw error + return data ?? { results: [], total: 0 } + } + + const { data, error } = await oapiClient.GET( + "/api/admin/projects/{projectID}/subjects/organizations/{organizationID}/inbox", + { + params: { + path: { projectID: project.id, organizationID: subjectId }, + query: params, + }, + }, + ) + if (error) throw error + return data ?? { results: [], total: 0 } + }, [page, project.id, debouncedQuery, status, subjectId, subjectType]), + ) + + const createMessage = async () => { + if (!title.trim()) return + if ((channel === "email" || channel === "sms") && !senderIdentityId) return + + setIsCreating(true) + try { + const dedupedTags = Array.from( + new Set( + tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + ), + ) + + const payload = { + channel, + sender_identity_id: channel === "push" || channel === "inbox" ? undefined : senderIdentityId, + content: { + title: title.trim(), + body: body.trim() || undefined, + }, + tags: dedupedTags, + scheduled_at: scheduledAt || undefined, + } + + if (subjectType === "users") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/users/{userID}/inbox", + { + params: { path: { projectID: project.id, userID: subjectId } }, + body: payload, + }, + ) + if (error) throw error + } else { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/organizations/{organizationID}/inbox", + { + params: { path: { projectID: project.id, organizationID: subjectId } }, + body: payload, + }, + ) + if (error) throw error + } + + toast.success(t("inbox_message_created", "Inbox message created")) + resetForm() + setIsCreateOpen(false) + await reload() + } catch { + toast.error(t("inbox_message_create_failed", "Failed to create inbox message")) + } finally { + setIsCreating(false) + } + } + + const updateMessage = async ( + message: InboxMessage, + event: "opened" | "archived" | "scheduled" | "unarchived" | "unread", + newScheduledAt?: string, + ) => { + try { + if (subjectType === "users") { + if (event === "scheduled") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/users/{userID}/inbox/{messageID}/schedule", + { + params: { + path: { + projectID: project.id, + userID: subjectId, + messageID: message.id, + }, + }, + body: { scheduled_at: newScheduledAt ?? "" }, + }, + ) + if (error) throw error + } else if (event === "opened") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/users/{userID}/inbox/{messageID}/open", + { + params: { + path: { + projectID: project.id, + userID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } else if (event === "archived") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/users/{userID}/inbox/{messageID}/archive", + { + params: { + path: { + projectID: project.id, + userID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } else if (event === "unarchived") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/users/{userID}/inbox/{messageID}/unarchive", + { + params: { + path: { + projectID: project.id, + userID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } else if (event === "unread") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/users/{userID}/inbox/{messageID}/unread", + { + params: { + path: { + projectID: project.id, + userID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } + } else { + if (event === "scheduled") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/organizations/{organizationID}/inbox/{messageID}/schedule", + { + params: { + path: { + projectID: project.id, + organizationID: subjectId, + messageID: message.id, + }, + }, + body: { scheduled_at: newScheduledAt ?? "" }, + }, + ) + if (error) throw error + } else if (event === "opened") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/organizations/{organizationID}/inbox/{messageID}/open", + { + params: { + path: { + projectID: project.id, + organizationID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } else if (event === "archived") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/organizations/{organizationID}/inbox/{messageID}/archive", + { + params: { + path: { + projectID: project.id, + organizationID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } else if (event === "unarchived") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/organizations/{organizationID}/inbox/{messageID}/unarchive", + { + params: { + path: { + projectID: project.id, + organizationID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } else if (event === "unread") { + const { error } = await oapiClient.POST( + "/api/admin/projects/{projectID}/subjects/organizations/{organizationID}/inbox/{messageID}/unread", + { + params: { + path: { + projectID: project.id, + organizationID: subjectId, + messageID: message.id, + }, + }, + }, + ) + if (error) throw error + } + } + + toast.success( + event === "scheduled" + ? t("inbox_message_scheduled", "Message schedule updated") + : event === "archived" + ? t("inbox_message_archived", "Message archived") + : event === "unarchived" + ? t("inbox_message_unarchived", "Message unarchived") + : event === "unread" + ? t("inbox_message_unread", "Message marked unread") + : t("inbox_message_opened", "Message marked opened"), + ) + await reload() + } catch { + toast.error(t("inbox_message_update_failed", "Failed to update inbox message")) + } + } + + const total = result?.total ?? 0 + const totalPages = result ? Math.ceil(total / limit) : 0 + const hasPrevPage = page > 1 + const hasNextPage = page < totalPages + + return ( +
+ {/* Search + Status + Create */} +
+
+ +
+
+ + + +
+
+ + {/* Inbox Table */} +
+ + + + {t("message", "Message")} + {t("channel", "Channel")} + {t("status", "Status")} + {t("tags", "Tags")} + {t("scheduled", "Scheduled")} + + + + + {result === null ? ( + Array.from({ length: 5 }).map((_, index) => ( + + +
+ + +
+
+ + + + + + + + + + + + + + + +
+ )) + ) : result.results.length === 0 ? ( + + +
+
+
+

+ {t("no_inbox_messages", "No inbox messages yet")} +

+

+ {t( + "no_inbox_messages_description", + "Inbox messages will appear here when they are created", + )} +

+
+
+
+ ) : ( + result.results.map((message) => { + const visible = isMessageVisible(message) + const channelMeta = getChannelMeta(message.channel, t) + const ChannelIcon = channelMeta.icon + const messageTitle = + typeof message.content?.title === "string" + ? message.content.title + : "" + const messageBody = + typeof message.content?.body === "string" + ? message.content.body + : "" + + return ( + + +
+
{messageTitle}
+ {messageBody && ( +
+ {messageBody} +
+ )} +
+
+ + + + + {statusBadge(message, t)} + + {message.tags.length > 0 ? ( +
+ {message.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( + + )} +
+ + {new Date(message.scheduled_at) > new Date() ? ( + + updateMessage(message, "scheduled", newIso) + } + > + + + + ) : ( + + + )} + + + + + + + + {!message.opened_at && + !message.archived_at && + visible && ( + + updateMessage( + message, + "opened", + ).catch(console.error) + } + > + + )} + {!message.archived_at && visible && ( + + updateMessage( + message, + "archived", + ).catch(console.error) + } + > + + )} + {message.archived_at && visible && ( + + updateMessage( + message, + "unarchived", + ).catch(console.error) + } + > + + )} + {message.opened_at && + !message.archived_at && + visible && ( + + updateMessage( + message, + "unread", + ).catch(console.error) + } + > + + )} + + + +
+ ) + }) + )} +
+
+ + {/* Pagination */} +
+

+ {total} {t("messages", "messages")} +

+ {totalPages > 1 && ( +
+ + + {getPageNumbers(page, totalPages).map((pageNum, idx) => + pageNum === "..." ? ( + + ... + + ) : ( + + ), + )} + + +
+ )} +
+
+ + {/* Create Inbox Message Dialog */} + { + setIsCreateOpen(open) + if (!open) resetForm() + }} + > + + + {t("new_inbox_message", "New inbox message")} + + {t( + "new_inbox_message_description", + "Create a message that appears in this subject inbox.", + )} + + +
+
+
+ + +

+ {t( + "inbox_channel_help", + "Delivery channel the message represents.", + )} +

+
+
+ {channel === "push" || channel === "inbox" ? ( + <> + +
+
+ + ) : ( + <> + + +

+ {channel === "email" + ? t( + "inbox_from_address_help", + "Verified sender to use as the from address.", + ) + : t( + "inbox_from_number_help", + "Verified sender number to use.", + )} +

+ + )} +
+
+ +
+ + setTitle(event.target.value)} + placeholder={ + channel === "email" + ? t("inbox_subject_placeholder", "Message subject") + : t("inbox_title_placeholder", "Message title") + } + /> +
+ +
+ +