@@ -101,6 +111,9 @@ export default function SendEmailPage() {
const [isSending, setIsSending] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
+ const [showScheduleDialog, setShowScheduleDialog] = useState(false);
+ const [scheduledAt, setScheduledAt] = useState("");
+ const [minimumScheduledAt, setMinimumScheduledAt] = useState("");
const [retryCount, setRetryCount] = useState(0);
// Email content state
@@ -118,6 +131,7 @@ export default function SendEmailPage() {
// Get utils for cache invalidation
const utils = api.useUtils();
+ const timeZone = useMemo(() => getLocalTimeZone(), []);
// Email sending mutation
const sendEmailMutation = api.email.sendInvoice.useMutation({
@@ -183,6 +197,31 @@ export default function SendEmailPage() {
},
});
+ const scheduleEmailMutation = api.email.scheduleInvoice.useMutation({
+ onSuccess: async (data) => {
+ await utils.invoices.getById.invalidate({ id: invoiceId });
+ toast.success("Invoice scheduled", {
+ description: `It will send ${formatZonedDateTime(data.scheduledAt, data.timeZone)}.`,
+ });
+ router.push(`/dashboard/invoices/${invoiceId}`);
+ },
+ onError: (error) => {
+ toast.error("Could not schedule invoice", { description: error.message });
+ },
+ });
+
+ const cancelScheduleMutation = api.email.cancelScheduledInvoice.useMutation({
+ onSuccess: async () => {
+ await utils.invoices.getById.invalidate({ id: invoiceId });
+ toast.success("Scheduled send cancelled");
+ },
+ onError: (error) => {
+ toast.error("Could not cancel scheduled send", {
+ description: error.message,
+ });
+ },
+ });
+
// Transform invoice data for components
const invoice = useMemo(() => {
return invoiceData
@@ -196,6 +235,9 @@ export default function SendEmailPage() {
taxRate: invoiceData.taxRate,
currency: invoiceData.currency,
emailMessage: invoiceData.emailMessage,
+ scheduledSendAt: invoiceData.scheduledSendAt,
+ scheduledSendTimeZone: invoiceData.scheduledSendTimeZone,
+ scheduledSendStatus: invoiceData.scheduledSendStatus,
client: invoiceData.client
? {
name: invoiceData.client.name,
@@ -287,6 +329,42 @@ export default function SendEmailPage() {
}
};
+ const confirmScheduleEmail = async () => {
+ const sendAt = new Date(scheduledAt);
+ if (
+ Number.isNaN(sendAt.getTime()) ||
+ sendAt.getTime() < Date.now() + 60_000
+ ) {
+ toast.error("Choose a future send time", {
+ description: "The scheduled time must be at least one minute from now.",
+ });
+ return;
+ }
+ if (toLocalDateTimeInputValue(sendAt) !== scheduledAt) {
+ toast.error("That local time does not exist", {
+ description:
+ "Choose another time. The selected value falls inside a daylight-saving clock change.",
+ });
+ return;
+ }
+ try {
+ await scheduleEmailMutation.mutateAsync({
+ invoiceId,
+ scheduledAt: sendAt,
+ timeZone,
+ customSubject: subject,
+ customContent: emailContent,
+ customMessage: normalizedCustomMessage,
+ useHtml: true,
+ ccEmails: ccEmail.trim() || undefined,
+ bccEmails: bccEmail.trim() || undefined,
+ });
+ setShowScheduleDialog(false);
+ } catch {
+ // The mutation displays the server error.
+ }
+ };
+
const handleRetry = () => {
if (retryCount < 2) {
setRetryCount((prev) => prev + 1);
@@ -348,6 +426,31 @@ export default function SendEmailPage() {
)}
+ {invoice.scheduledSendStatus === "pending" && invoice.scheduledSendAt ? (
+
+
+
+
+ Scheduled for{" "}
+ {formatZonedDateTime(
+ invoice.scheduledSendAt,
+ invoice.scheduledSendTimeZone ?? timeZone,
+ )}{" "}
+ ({invoice.scheduledSendTimeZone ?? timeZone})
+
+
+
+
+ ) : null}
+
{/* Main Content */}
@@ -365,66 +468,66 @@ export default function SendEmailPage() {
-
-
-
- Compose Email
-
-
-
- {isInitialized ? (
-
- ) : (
-
-
-
-
- Initializing email content...
-
-
+
+
+
+ Compose Email
+
+
+
+ {isInitialized ? (
+
+ ) : (
+
+
+
+
+ Initializing email content...
+
- )}
-
-
+
+ )}
+
+
-
-
-
- Email Preview
-
-
-
-
-
-
-
-
+
+
+
+ Email Preview
+
+
+
+
+
+
+
+
@@ -579,6 +682,24 @@ export default function SendEmailPage() {
Cancel
+
+