diff --git a/apps/dashboard/DESIGN-GUIDE.md b/apps/dashboard/DESIGN-GUIDE.md index 1b6f5157d..f5700d108 100644 --- a/apps/dashboard/DESIGN-GUIDE.md +++ b/apps/dashboard/DESIGN-GUIDE.md @@ -742,7 +742,6 @@ Reference surfaces: - `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]` - `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails` - `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts` -- `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox` - `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates` - `apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes` @@ -803,21 +802,7 @@ Keep: - specialized editor layout systems if no design-components equivalent exists -### 5.4 `/projects/[projectId]/email-outbox` - -Use: - -- section cards: `DesignCard` (preferred for visual consistency with other email screens) -- filters: `DesignSelectorDropdown`, `DesignInput` -- status badges: `DesignBadge` -- action buttons/menus: `DesignButton`, `DesignMenu` -- data grid/list table: `DataGrid` + `useDataSource` + `createDefaultDataGridState` - -Avoid: - -- mixed badge systems (`Badge` in some places, custom badges elsewhere) - -### 5.5 `/projects/[projectId]/email-templates` +### 5.4 `/projects/[projectId]/email-templates` Use: @@ -830,7 +815,7 @@ Avoid: - inline repeated glass class blocks for each template card -### 5.6 `/projects/[projectId]/email-templates/[templateId]` +### 5.5 `/projects/[projectId]/email-templates/[templateId]` Use: @@ -838,7 +823,7 @@ Use: - top actions: `DesignButton` - state tags: `DesignBadge` where needed -### 5.7 `/projects/[projectId]/email-themes` +### 5.6 `/projects/[projectId]/email-themes` Use: @@ -852,7 +837,7 @@ Avoid: - custom `ViewportSelector` if `DesignPillToggle` supports the same behavior -### 5.8 `/projects/[projectId]/email-themes/[themeId]` +### 5.7 `/projects/[projectId]/email-themes/[themeId]` Use: diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx deleted file mode 100644 index a21bdde83..000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page-client.tsx +++ /dev/null @@ -1,835 +0,0 @@ -"use client"; - -import { SettingCard } from "@/components/settings"; -import { ActionDialog, Badge, Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SimpleTooltip, Switch, Typography, useToast } from "@/components/ui"; -import { DataGrid, DataGridToolbar, useDataGridUrlState, useDataSource, type DataGridColumnDef, type DataGridDataSource } from "@hexclave/dashboard-ui-components"; -import { cn } from "@/lib/utils"; -import { DotsThreeIcon, PauseIcon, PlayIcon, XCircleIcon } from "@phosphor-icons/react"; -import { AdminEmailOutbox, AdminEmailOutboxSimpleStatus, AdminEmailOutboxStatus } from "@hexclave/next"; -import { fromNow } from "@hexclave/shared/dist/utils/dates"; -import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { PageLayout } from "../page-layout"; -import { useAdminApp } from "../use-admin-app"; - -const STATUS_LABELS: Record = { - "paused": "Paused", - "preparing": "Preparing", - "rendering": "Rendering", - "render-error": "Render Error", - "scheduled": "Scheduled", - "queued": "Queued", - "sending": "Sending", - "server-error": "Server Error", - "skipped": "Skipped", - "bounced": "Bounced", - "delivery-delayed": "Delivery Delayed", - "sent": "Sent", - "opened": "Opened", - "clicked": "Clicked", - "marked-as-spam": "Marked as Spam", -}; - -const SIMPLE_STATUS_LABELS: Record = { - "in-progress": "In Progress", - "ok": "Completed", - "error": "Error", -}; - -function getStatusBadgeVariant(simpleStatus: AdminEmailOutboxSimpleStatus): "default" | "secondary" | "destructive" | "outline" { - switch (simpleStatus) { - case "ok": { - return "secondary"; - } - case "error": { - return "destructive"; - } - case "in-progress": { - return "default"; - } - default: { - return "default"; - } - } -} - -function getRecipientDisplay(email: AdminEmailOutbox): string { - const to = email.to; - if (to.type === "user-primary-email") { - return `User: ${to.userId.slice(0, 8)}...`; - } else if (to.type === "user-custom-emails") { - return to.emails.join(", ") || `User: ${to.userId.slice(0, 8)}...`; - } else { - return to.emails.join(", ") || "No recipients"; - } -} - -// Helper to check if email is paused (avoids type narrowing issues) -function isEmailPaused(email: AdminEmailOutbox): boolean { - // Cast to string to avoid TypeScript complaining about exhaustive type narrowing - return (email.status as string) === "paused"; -} - -// Helper to check if we can pause - works with any email type -function canPauseEmail(email: AdminEmailOutbox): boolean { - const pausableStatuses = ["preparing", "rendering", "scheduled", "queued", "render-error", "server-error"]; - return !isEmailPaused(email) && pausableStatuses.includes(email.status); -} - -// Helper to check if we can cancel - works with any email type -function canCancelEmail(email: AdminEmailOutbox): boolean { - const cancellableStatuses = ["paused", "preparing", "rendering", "scheduled", "queued", "render-error", "server-error"]; - return cancellableStatuses.includes(email.status); -} - -function EmailActions({ - email, - onRefresh, -}: { - email: AdminEmailOutbox, - onRefresh: () => Promise, -}) { - const hexclaveAdminApp = useAdminApp(); - const { toast } = useToast(); - const [cancelDialogOpen, setCancelDialogOpen] = useState(false); - - const canPause = canPauseEmail(email); - const canUnpause = isEmailPaused(email); - const canCancel = canCancelEmail(email); - - const handlePause = () => { - runAsynchronouslyWithAlert(async () => { - await hexclaveAdminApp.pauseOutboxEmail(email.id); - toast({ - title: "Email paused", - description: "The email has been paused and will not be sent until unpaused.", - variant: "success", - }); - await onRefresh(); - }); - }; - - const handleUnpause = () => { - runAsynchronouslyWithAlert(async () => { - await hexclaveAdminApp.unpauseOutboxEmail(email.id); - toast({ - title: "Email unpaused", - description: "The email will continue processing.", - variant: "success", - }); - await onRefresh(); - }); - }; - - const handleCancel = async () => { - await hexclaveAdminApp.cancelOutboxEmail(email.id); - toast({ - title: "Email cancelled", - description: "The email has been cancelled and will not be sent.", - variant: "success", - }); - setCancelDialogOpen(false); - await onRefresh(); - }; - - if (!canPause && !canUnpause && !canCancel) { - return null; - } - - return ( - <> - - - - - - {canPause && ( - - - Pause - - )} - {canUnpause && ( - - - Unpause - - )} - {(canPause || canUnpause) && canCancel && } - {canCancel && ( - setCancelDialogOpen(true)} - className="text-destructive focus:text-destructive" - > - - Cancel - - )} - - - - setCancelDialogOpen(false)} - title="Cancel Email" - cancelButton - okButton={{ - label: "Cancel Email", - onClick: handleCancel, - props: { variant: "destructive" }, - }} - > - - Are you sure you want to cancel this email? This action cannot be undone. - - - - ); -} - -const EDITABLE_STATUSES: AdminEmailOutboxStatus[] = [ - "paused", "preparing", "rendering", "render-error", "scheduled", "queued", "server-error", -]; - -function isEditable(email: AdminEmailOutbox): boolean { - return EDITABLE_STATUSES.includes(email.status); -} - -// Helper type to extract optional properties from the discriminated union for display -type EmailDisplayData = { - // Rendering - startedRenderingAt?: Date, - renderedAt?: Date, - subject?: string, - isTransactional?: boolean, - isHighPriority?: boolean, - renderError?: string, - // Sending - startedSendingAt?: Date, - deliveredAt?: Date, - serverError?: string, - errorAt?: Date, - // Skipped - skippedAt?: Date, - skippedReason?: string, - skippedDetails?: Record, - // Tracking - canHaveDeliveryInfo?: boolean, - bouncedAt?: Date, - deliveryDelayedAt?: Date, - openedAt?: Date, - clickedAt?: Date, - markedAsSpamAt?: Date, -}; - -// Extract display data from any email type -function getEmailDisplayData(email: AdminEmailOutbox): EmailDisplayData { - // Cast to any to access properties that may not exist on all variants - // This is safe because we're just extracting values for display - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = email as any; - return { - startedRenderingAt: e.startedRenderingAt, - renderedAt: e.renderedAt, - subject: e.subject, - isTransactional: e.isTransactional, - isHighPriority: e.isHighPriority, - renderError: e.renderError, - startedSendingAt: e.startedSendingAt, - deliveredAt: e.deliveredAt, - serverError: e.serverError, - errorAt: e.errorAt, - skippedAt: e.skippedAt, - skippedReason: e.skippedReason, - skippedDetails: e.skippedDetails, - canHaveDeliveryInfo: e.canHaveDeliveryInfo, - bouncedAt: e.bouncedAt, - deliveryDelayedAt: e.deliveryDelayedAt, - openedAt: e.openedAt, - clickedAt: e.clickedAt, - markedAsSpamAt: e.markedAsSpamAt, - }; -} - -function PropertyRow({ - label, - value, - className, -}: { - label: string, - value: React.ReactNode, - className?: string, -}) { - return ( -
- {label} -
{value}
-
- ); -} - -function EmailDetailSheet({ - email, - open, - onOpenChange, - onRefresh, -}: { - email: AdminEmailOutbox | null, - open: boolean, - onOpenChange: (open: boolean) => void, - onRefresh: () => Promise, -}) { - const hexclaveAdminApp = useAdminApp(); - const { toast } = useToast(); - const [cancelDialogOpen, setCancelDialogOpen] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - // Editable fields state - const [scheduledAt, setScheduledAt] = useState(""); - const [isPaused, setIsPaused] = useState(false); - - // Initialize form when email changes - const initForm = (e: AdminEmailOutbox) => { - setScheduledAt(e.scheduledAt.toISOString().slice(0, 16)); - setIsPaused(isEmailPaused(e)); - }; - - // Reset form when sheet opens - if (email && open) { - // Only reset if values haven't been initialized yet - const expectedScheduledAt = email.scheduledAt.toISOString().slice(0, 16); - if (scheduledAt !== expectedScheduledAt && !isSaving) { - initForm(email); - } - } - - if (!email) return null; - - const editable = isEditable(email); - const displayData = getEmailDisplayData(email); - - const handleSave = async () => { - setIsSaving(true); - try { - const updates: { isPaused?: boolean, scheduledAtMillis?: number } = {}; - if (isPaused !== isEmailPaused(email)) { - updates.isPaused = isPaused; - } - const newScheduledAt = new Date(scheduledAt); - if (newScheduledAt.getTime() !== email.scheduledAt.getTime()) { - updates.scheduledAtMillis = newScheduledAt.getTime(); - } - if (Object.keys(updates).length > 0) { - await hexclaveAdminApp.updateOutboxEmail(email.id, updates); - toast({ - title: "Email updated", - description: "The email has been updated successfully.", - variant: "success", - }); - await onRefresh(); - } - onOpenChange(false); - } catch (error) { - toast({ - title: "Failed to update email", - description: String(error), - variant: "destructive", - }); - } finally { - setIsSaving(false); - } - }; - - const handleCancel = async () => { - await hexclaveAdminApp.cancelOutboxEmail(email.id); - toast({ - title: "Email cancelled", - description: "The email has been cancelled and will not be sent.", - variant: "success", - }); - setCancelDialogOpen(false); - await onRefresh(); - onOpenChange(false); - }; - - return ( - <> - - - - Email Details - - View and manage this email - - - -
- {/* Status Section */} -
- - {STATUS_LABELS[email.status]} - - {isEmailPaused(email) && ( - - - Paused - - )} -
- - {/* Basic Info */} -
- Basic Information -
- {email.id}} /> - - - {editable ? ( -
- - setScheduledAt(e.target.value)} - className="h-8 text-sm" - /> -
- ) : ( - - )} -
-
- - {/* Recipient Info */} -
- Recipient -
- - {email.to.type === "user-primary-email" && ( - {email.to.userId}} /> - )} - {email.to.type === "user-custom-emails" && ( - <> - {email.to.userId}} /> - - - )} - {email.to.type === "custom-emails" && ( - - )} -
-
- - {/* Rendering Info */} - {displayData.startedRenderingAt && ( -
- Rendering -
- - {displayData.renderedAt && ( - - )} - {displayData.subject && ( - - )} - {displayData.isTransactional !== undefined && ( - - )} - {displayData.isHighPriority !== undefined && ( - - )} -
-
- )} - - {/* Render Error */} - {displayData.renderError && ( -
- Render Error -
-                  {displayData.renderError}
-                
-
- )} - - {/* Sending Info */} - {displayData.startedSendingAt && ( -
- Sending -
- - {displayData.deliveredAt && ( - - )} -
-
- )} - - {/* Server Error */} - {displayData.serverError && ( -
- Server Error -
-                  {displayData.serverError}
-                
-
- )} - - {/* Skipped Info */} - {displayData.skippedAt && ( -
- Skipped -
- - {displayData.skippedReason && } - {displayData.skippedDetails && Object.keys(displayData.skippedDetails).length > 0 && ( - {JSON.stringify(displayData.skippedDetails, null, 2)}} - className="col-span-2" - /> - )} -
-
- )} - - {/* Bounce Info */} - {displayData.bouncedAt && ( -
- Bounced -
- -
-
- )} - - {/* Delivery Delayed Info */} - {displayData.deliveryDelayedAt && ( -
- Delivery Delayed -
- -
-
- )} - - {/* Delivery Tracking */} - {displayData.canHaveDeliveryInfo !== undefined && ( -
- Delivery Tracking -
- - {displayData.openedAt && } - {displayData.clickedAt && } - {displayData.markedAsSpamAt && } -
-
- )} - - {/* Controls Section */} - {editable && ( -
- Controls -
-
- - Pause email processing -
- -
-
- )} - - {/* Actions */} -
- {editable && ( - <> - - - - )} - -
-
-
-
- - setCancelDialogOpen(false)} - title="Cancel Email" - cancelButton - okButton={{ - label: "Cancel Email", - onClick: handleCancel, - props: { variant: "destructive" }, - }} - > - - Are you sure you want to cancel this email? This action cannot be undone. - - - - ); -} - -const EMAIL_PAGE_SIZE = 50; - -export default function PageClient() { - const hexclaveAdminApp = useAdminApp(); - const [statusFilter, setStatusFilter] = useState("all"); - const [simpleStatusFilter, setSimpleStatusFilter] = useState("all"); - const [selectedEmail, setSelectedEmail] = useState(null); - const [detailSheetOpen, setDetailSheetOpen] = useState(false); - - // Server-side infinite data source — cursor pagination against - // `listOutboxEmails`. Closure captures `statusFilter`/`simpleStatusFilter` - // so a filter change produces a new `dataSource` identity, which - // `useDataSource` uses to refetch from scratch. - const dataSource = useMemo>( - () => async function* (params) { - const options: { status?: string, simpleStatus?: string, cursor?: string, limit?: number } = { - limit: EMAIL_PAGE_SIZE, - }; - if (statusFilter !== "all") options.status = statusFilter; - if (simpleStatusFilter !== "all") options.simpleStatus = simpleStatusFilter; - if (typeof params.cursor === "string") options.cursor = params.cursor; - const result = await hexclaveAdminApp.listOutboxEmails(options); - yield { - rows: result.items, - hasMore: result.nextCursor != null, - nextCursor: result.nextCursor ?? undefined, - }; - }, - [hexclaveAdminApp, statusFilter, simpleStatusFilter], - ); - - const handleFilterChange = (newStatusFilter: string, newSimpleStatusFilter: string) => { - setStatusFilter(newStatusFilter); - setSimpleStatusFilter(newSimpleStatusFilter); - }; - - // Stable ref the `renderCell` closures reach through to trigger a - // refresh. Populated further below once `useDataSource` has returned its - // `reload` function. - const reloadRef = useRef<() => void>(() => {}); - - const emailColumns = useMemo[]>(() => [ - { - id: "subject", - header: "Subject", - width: 200, - renderCell: ({ row }) => { - const subject = getEmailDisplayData(row).subject; - return ( -
- - {subject || Pending} - -
- ); - }, - }, - { - id: "recipient", - header: "Recipient", - width: 150, - renderCell: ({ row }) => { - const display = getRecipientDisplay(row); - return ( -
- - {display} - -
- ); - }, - }, - { - id: "status", - header: "Status", - width: 150, - renderCell: ({ row }) => ( -
- - {STATUS_LABELS[row.status]} - - {isEmailPaused(row) && ( - - - - )} -
- ), - }, - { - id: "scheduled", - header: "Scheduled", - width: 130, - type: "dateTime", - accessor: "scheduledAt", - }, - { - id: "created", - header: "Created", - width: 130, - type: "dateTime", - accessor: "createdAt", - }, - { - id: "actions", - header: "", - width: 60, - sortable: false, - resizable: false, - renderCell: ({ row }) => { reloadRef.current(); }} />, - }, - ], []); - - const [emailGridState, setEmailGridState] = useDataGridUrlState(emailColumns, { paramPrefix: "outbox" }); - const getRowId = useCallback((row: AdminEmailOutbox) => row.id, []); - const emailGridData = useDataSource({ - dataSource, - columns: emailColumns, - getRowId, - sorting: emailGridState.sorting, - quickSearch: emailGridState.quickSearch, - pagination: emailGridState.pagination, - paginationMode: "infinite", - }); - - // Keep the ref pointed at the current `reload` so column `renderCell` - // closures built once still trigger a fresh fetch. - reloadRef.current = emailGridData.reload; - - const handleRefresh = useCallback(async () => { - emailGridData.reload(); - }, [emailGridData]); - - const emails = emailGridData.rows; - - return ( - runAsynchronouslyWithAlert(handleRefresh)} variant="outline"> - Refresh - - } - > - -
-
- Status: - -
-
- Category: - -
- -
- - {emailGridData.isLoading ? ( -
- Loading emails... -
- ) : emails.length === 0 ? ( -
- No emails found -
- ) : ( - } - onRowClick={(row) => { - setSelectedEmail(row); - setDetailSheetOpen(true); - }} - /> - )} -
- - { - setDetailSheetOpen(open); - if (!open) { - // Refresh the selected email from the list after closing - if (selectedEmail) { - const updated = emails.find(e => e.id === selectedEmail.id); - if (updated) { - setSelectedEmail(updated); - } - } - } - }} - onRefresh={async () => { - await handleRefresh(); - // Update selected email with fresh data - if (selectedEmail) { - const updated = emails.find(e => e.id === selectedEmail.id); - if (updated) { - setSelectedEmail(updated); - } - } - }} - /> -
- ); -} - diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page.tsx deleted file mode 100644 index 8a6f38db4..000000000 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-outbox/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import PageClient from "./page-client"; - -export const metadata = { - title: "Email Outbox", -}; - -export default function Page() { - return ( - - ); -} - - diff --git a/apps/dashboard/src/components/cmdk-commands.tsx b/apps/dashboard/src/components/cmdk-commands.tsx index dbf965bd2..d6856b0af 100644 --- a/apps/dashboard/src/components/cmdk-commands.tsx +++ b/apps/dashboard/src/components/cmdk-commands.tsx @@ -7,7 +7,7 @@ import { ALL_APPS_FRONTEND, getAppPath, getItemPath, hasNavigationItems, type Na import { getUninstalledAppIds } from "@/lib/apps-utils"; import { classifyClickHouseSqlVsPrompt } from "@/lib/classify-query"; import { cn } from "@/lib/utils"; -import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, EnvelopeSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react"; +import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react"; import { ALL_APPS, ALL_APP_TAGS, getParentAppId, type AppId } from "@hexclave/shared/dist/apps/apps-config"; import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises"; import Image from "next/image"; @@ -308,15 +308,6 @@ const PROJECT_SHORTCUTS: ProjectShortcutDefinition[] = [ keywords: ["email themes", "themes", "branding", "style", "templates"], requiredApps: ["emails"], }, - { - id: "emails/outbox", - icon: EnvelopeSimpleIcon, - label: "Email Outbox", - description: "Emails", - href: "/email-outbox", - keywords: ["email outbox", "outbox", "delivery", "queue", "scheduled emails"], - requiredApps: ["emails"], - }, { id: "data-vault/stores", icon: HardDriveIcon,