mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-30 21:01:54 +08:00
fix: remove email-outbox dashboard page (#1662)
This commit is contained in:
parent
0c8f5e33ed
commit
d57bba3a06
@ -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:
|
||||
|
||||
|
||||
@ -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<AdminEmailOutboxStatus, string> = {
|
||||
"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<AdminEmailOutboxSimpleStatus, string> = {
|
||||
"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<void>,
|
||||
}) {
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<DotsThreeIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canPause && (
|
||||
<DropdownMenuItem onClick={handlePause}>
|
||||
<PauseIcon className="mr-2 h-4 w-4" />
|
||||
Pause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUnpause && (
|
||||
<DropdownMenuItem onClick={handleUnpause}>
|
||||
<PlayIcon className="mr-2 h-4 w-4" />
|
||||
Unpause
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(canPause || canUnpause) && canCancel && <DropdownMenuSeparator />}
|
||||
{canCancel && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<XCircleIcon className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ActionDialog
|
||||
open={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
title="Cancel Email"
|
||||
cancelButton
|
||||
okButton={{
|
||||
label: "Cancel Email",
|
||||
onClick: handleCancel,
|
||||
props: { variant: "destructive" },
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
Are you sure you want to cancel this email? This action cannot be undone.
|
||||
</Typography>
|
||||
</ActionDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, unknown>,
|
||||
// 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 (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<Typography className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Typography>
|
||||
<div className="text-sm">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmailDetailSheet({
|
||||
email,
|
||||
open,
|
||||
onOpenChange,
|
||||
onRefresh,
|
||||
}: {
|
||||
email: AdminEmailOutbox | null,
|
||||
open: boolean,
|
||||
onOpenChange: (open: boolean) => void,
|
||||
onRefresh: () => Promise<void>,
|
||||
}) {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const { toast } = useToast();
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Editable fields state
|
||||
const [scheduledAt, setScheduledAt] = useState<string>("");
|
||||
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 (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Email Details</SheetTitle>
|
||||
<SheetDescription>
|
||||
View and manage this email
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
{/* Status Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant={getStatusBadgeVariant(email.simpleStatus)} className="text-sm">
|
||||
{STATUS_LABELS[email.status]}
|
||||
</Badge>
|
||||
{isEmailPaused(email) && (
|
||||
<Badge variant="outline" className="text-sm">
|
||||
<PauseIcon className="h-3 w-3 mr-1" />
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Basic Information</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.id}</code>} />
|
||||
<PropertyRow label="Created" value={email.createdAt.toLocaleString()} />
|
||||
<PropertyRow label="Updated" value={email.updatedAt.toLocaleString()} />
|
||||
{editable ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Scheduled At</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<PropertyRow label="Scheduled At" value={email.scheduledAt.toLocaleString()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recipient Info */}
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Recipient</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Type" value={email.to.type} />
|
||||
{email.to.type === "user-primary-email" && (
|
||||
<PropertyRow label="User ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.to.userId}</code>} />
|
||||
)}
|
||||
{email.to.type === "user-custom-emails" && (
|
||||
<>
|
||||
<PropertyRow label="User ID" value={<code className="text-xs bg-muted px-1 py-0.5 rounded">{email.to.userId}</code>} />
|
||||
<PropertyRow label="Emails" value={email.to.emails.join(", ") || "None"} className="col-span-2" />
|
||||
</>
|
||||
)}
|
||||
{email.to.type === "custom-emails" && (
|
||||
<PropertyRow label="Emails" value={email.to.emails.join(", ") || "None"} className="col-span-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rendering Info */}
|
||||
{displayData.startedRenderingAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Rendering</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Started Rendering" value={displayData.startedRenderingAt.toLocaleString()} />
|
||||
{displayData.renderedAt && (
|
||||
<PropertyRow label="Rendered At" value={displayData.renderedAt.toLocaleString()} />
|
||||
)}
|
||||
{displayData.subject && (
|
||||
<PropertyRow label="Subject" value={displayData.subject} className="col-span-2" />
|
||||
)}
|
||||
{displayData.isTransactional !== undefined && (
|
||||
<PropertyRow label="Transactional" value={displayData.isTransactional ? "Yes" : "No"} />
|
||||
)}
|
||||
{displayData.isHighPriority !== undefined && (
|
||||
<PropertyRow label="High Priority" value={displayData.isHighPriority ? "Yes" : "No"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render Error */}
|
||||
{displayData.renderError && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold text-destructive">Render Error</Typography>
|
||||
<pre className="text-xs bg-destructive/10 text-destructive p-3 rounded overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{displayData.renderError}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sending Info */}
|
||||
{displayData.startedSendingAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Sending</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Started Sending" value={displayData.startedSendingAt.toLocaleString()} />
|
||||
{displayData.deliveredAt && (
|
||||
<PropertyRow label="Delivered At" value={displayData.deliveredAt.toLocaleString()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Error */}
|
||||
{displayData.serverError && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold text-destructive">Server Error</Typography>
|
||||
<pre className="text-xs bg-destructive/10 text-destructive p-3 rounded overflow-x-auto whitespace-pre-wrap break-words">
|
||||
{displayData.serverError}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skipped Info */}
|
||||
{displayData.skippedAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Skipped</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Skipped At" value={displayData.skippedAt.toLocaleString()} />
|
||||
{displayData.skippedReason && <PropertyRow label="Reason" value={displayData.skippedReason} />}
|
||||
{displayData.skippedDetails && Object.keys(displayData.skippedDetails).length > 0 && (
|
||||
<PropertyRow
|
||||
label="Details"
|
||||
value={<pre className="text-xs bg-muted p-2 rounded overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(displayData.skippedDetails, null, 2)}</pre>}
|
||||
className="col-span-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bounce Info */}
|
||||
{displayData.bouncedAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold text-destructive">Bounced</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Bounced At" value={displayData.bouncedAt.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery Delayed Info */}
|
||||
{displayData.deliveryDelayedAt && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Delivery Delayed</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Delayed At" value={displayData.deliveryDelayedAt.toLocaleString()} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delivery Tracking */}
|
||||
{displayData.canHaveDeliveryInfo !== undefined && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Delivery Tracking</Typography>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<PropertyRow label="Tracking Available" value={displayData.canHaveDeliveryInfo ? "Yes" : "No"} />
|
||||
{displayData.openedAt && <PropertyRow label="Opened At" value={displayData.openedAt.toLocaleString()} />}
|
||||
{displayData.clickedAt && <PropertyRow label="Clicked At" value={displayData.clickedAt.toLocaleString()} />}
|
||||
{displayData.markedAsSpamAt && <PropertyRow label="Marked as Spam At" value={displayData.markedAsSpamAt.toLocaleString()} />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls Section */}
|
||||
{editable && (
|
||||
<div className="space-y-4 border-t pt-4">
|
||||
<Typography className="font-semibold">Controls</Typography>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Paused</Label>
|
||||
<Typography className="text-xs text-muted-foreground">Pause email processing</Typography>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPaused}
|
||||
onCheckedChange={setIsPaused}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-4 border-t">
|
||||
{editable && (
|
||||
<>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
>
|
||||
Cancel Email
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ActionDialog
|
||||
open={cancelDialogOpen}
|
||||
onClose={() => setCancelDialogOpen(false)}
|
||||
title="Cancel Email"
|
||||
cancelButton
|
||||
okButton={{
|
||||
label: "Cancel Email",
|
||||
onClick: handleCancel,
|
||||
props: { variant: "destructive" },
|
||||
}}
|
||||
>
|
||||
<Typography>
|
||||
Are you sure you want to cancel this email? This action cannot be undone.
|
||||
</Typography>
|
||||
</ActionDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const EMAIL_PAGE_SIZE = 50;
|
||||
|
||||
export default function PageClient() {
|
||||
const hexclaveAdminApp = useAdminApp();
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
const [simpleStatusFilter, setSimpleStatusFilter] = useState<string>("all");
|
||||
const [selectedEmail, setSelectedEmail] = useState<AdminEmailOutbox | null>(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<DataGridDataSource<AdminEmailOutbox>>(
|
||||
() => 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<DataGridColumnDef<AdminEmailOutbox>[]>(() => [
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject",
|
||||
width: 200,
|
||||
renderCell: ({ row }) => {
|
||||
const subject = getEmailDisplayData(row).subject;
|
||||
return (
|
||||
<div className="truncate">
|
||||
<SimpleTooltip tooltip={subject || "Not rendered yet"}>
|
||||
<span>{subject || <span className="text-muted-foreground italic">Pending</span>}</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "recipient",
|
||||
header: "Recipient",
|
||||
width: 150,
|
||||
renderCell: ({ row }) => {
|
||||
const display = getRecipientDisplay(row);
|
||||
return (
|
||||
<div className="truncate">
|
||||
<SimpleTooltip tooltip={display}>
|
||||
<span className="text-sm">{display}</span>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
width: 150,
|
||||
renderCell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getStatusBadgeVariant(row.simpleStatus)}>
|
||||
{STATUS_LABELS[row.status]}
|
||||
</Badge>
|
||||
{isEmailPaused(row) && (
|
||||
<SimpleTooltip tooltip="This email is paused">
|
||||
<PauseIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</SimpleTooltip>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 }) => <EmailActions email={row} onRefresh={async () => { 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 (
|
||||
<PageLayout
|
||||
title="Email Outbox"
|
||||
description="View and manage scheduled and sent emails"
|
||||
actions={
|
||||
<Button onClick={() => runAsynchronouslyWithAlert(handleRefresh)} variant="outline">
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<SettingCard title="Email Queue" description="All emails in the outbox">
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography className="text-sm font-medium">Status:</Typography>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(value) => handleFilterChange(value, simpleStatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{(Object.entries(STATUS_LABELS) as [AdminEmailOutboxStatus, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Typography className="text-sm font-medium">Category:</Typography>
|
||||
<Select
|
||||
value={simpleStatusFilter}
|
||||
onValueChange={(value) => handleFilterChange(statusFilter, value)}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All categories" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All categories</SelectItem>
|
||||
{(Object.entries(SIMPLE_STATUS_LABELS) as [AdminEmailOutboxSimpleStatus, string][]).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{emailGridData.isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Typography className="text-muted-foreground">Loading emails...</Typography>
|
||||
</div>
|
||||
) : emails.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Typography className="text-muted-foreground">No emails found</Typography>
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
columns={emailColumns}
|
||||
rows={emailGridData.rows}
|
||||
getRowId={getRowId}
|
||||
totalRowCount={emailGridData.totalRowCount}
|
||||
state={emailGridState}
|
||||
onChange={setEmailGridState}
|
||||
paginationMode="infinite"
|
||||
hasMore={emailGridData.hasMore}
|
||||
isLoadingMore={emailGridData.isLoadingMore}
|
||||
onLoadMore={emailGridData.loadMore}
|
||||
footer={false}
|
||||
fillHeight={false}
|
||||
maxHeight={500}
|
||||
toolbar={(ctx) => <DataGridToolbar ctx={ctx} hideQuickSearch />}
|
||||
onRowClick={(row) => {
|
||||
setSelectedEmail(row);
|
||||
setDetailSheetOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</SettingCard>
|
||||
|
||||
<EmailDetailSheet
|
||||
email={selectedEmail}
|
||||
open={detailSheetOpen}
|
||||
onOpenChange={(open) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
import PageClient from "./page-client";
|
||||
|
||||
export const metadata = {
|
||||
title: "Email Outbox",
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<PageClient />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user