mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2025-12-15 10:34:43 -05:00
Add confirmation dialog before sending invoice email
The commit adds a confirmation dialog when sending invoices, improves error handling with retries, and refines email-related UI text.
This commit is contained in:
@@ -9,6 +9,14 @@ import { Badge } from "~/components/ui/badge";
|
|||||||
import { Separator } from "~/components/ui/separator";
|
import { Separator } from "~/components/ui/separator";
|
||||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "~/components/ui/dialog";
|
||||||
import { PageHeader } from "~/components/layout/page-header";
|
import { PageHeader } from "~/components/layout/page-header";
|
||||||
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
import { FloatingActionBar } from "~/components/layout/floating-action-bar";
|
||||||
import { EmailComposer } from "~/components/forms/email-composer";
|
import { EmailComposer } from "~/components/forms/email-composer";
|
||||||
@@ -55,6 +63,8 @@ export default function SendEmailPage() {
|
|||||||
const [activeTab, setActiveTab] = useState("compose");
|
const [activeTab, setActiveTab] = useState("compose");
|
||||||
const [isSending, setIsSending] = useState(false);
|
const [isSending, setIsSending] = useState(false);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
// Email content state
|
// Email content state
|
||||||
const [subject, setSubject] = useState("");
|
const [subject, setSubject] = useState("");
|
||||||
@@ -87,10 +97,9 @@ export default function SendEmailPage() {
|
|||||||
void utils.invoices.getById.invalidate({ id: invoiceId });
|
void utils.invoices.getById.invalidate({ id: invoiceId });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
console.error("Email send error:", error);
|
|
||||||
|
|
||||||
let errorMessage = "Failed to send invoice email";
|
let errorMessage = "Failed to send invoice email";
|
||||||
let errorDescription = error.message;
|
let errorDescription = error.message;
|
||||||
|
let canRetry = false;
|
||||||
|
|
||||||
if (error.message.includes("Invalid recipient")) {
|
if (error.message.includes("Invalid recipient")) {
|
||||||
errorMessage = "Invalid Email Address";
|
errorMessage = "Invalid Email Address";
|
||||||
@@ -102,14 +111,35 @@ export default function SendEmailPage() {
|
|||||||
} else if (error.message.includes("rate limit")) {
|
} else if (error.message.includes("rate limit")) {
|
||||||
errorMessage = "Too Many Emails";
|
errorMessage = "Too Many Emails";
|
||||||
errorDescription = "Please wait a moment before sending another email.";
|
errorDescription = "Please wait a moment before sending another email.";
|
||||||
|
canRetry = true;
|
||||||
} else if (error.message.includes("no email address")) {
|
} else if (error.message.includes("no email address")) {
|
||||||
errorMessage = "No Email Address";
|
errorMessage = "No Email Address";
|
||||||
errorDescription = "This client doesn't have an email address on file.";
|
errorDescription = "This client doesn't have an email address on file.";
|
||||||
|
} else if (
|
||||||
|
error.message.includes("unavailable") ||
|
||||||
|
error.message.includes("timeout")
|
||||||
|
) {
|
||||||
|
errorMessage = "Service Temporarily Unavailable";
|
||||||
|
errorDescription =
|
||||||
|
"The email service is temporarily unavailable. Please try again.";
|
||||||
|
canRetry = true;
|
||||||
|
} else {
|
||||||
|
canRetry = true; // Allow retry for unknown errors
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error(errorMessage, {
|
toast.error(errorMessage, {
|
||||||
description: errorDescription,
|
description:
|
||||||
|
canRetry && retryCount < 2
|
||||||
|
? `${errorDescription} You can retry this operation.`
|
||||||
|
: errorDescription,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
|
action:
|
||||||
|
canRetry && retryCount < 2
|
||||||
|
? {
|
||||||
|
label: "Retry",
|
||||||
|
onClick: () => handleRetry(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
@@ -177,8 +207,12 @@ export default function SendEmailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Email content is now optional since template handles default messaging
|
// Show confirmation dialog
|
||||||
|
setShowConfirmDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmSendEmail = async () => {
|
||||||
|
setShowConfirmDialog(false);
|
||||||
setIsSending(true);
|
setIsSending(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -191,9 +225,16 @@ export default function SendEmailPage() {
|
|||||||
ccEmails: ccEmail.trim() || undefined,
|
ccEmails: ccEmail.trim() || undefined,
|
||||||
bccEmails: bccEmail.trim() || undefined,
|
bccEmails: bccEmail.trim() || undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
setRetryCount(0); // Reset retry count on success
|
||||||
|
} catch {
|
||||||
// Error handling is done in the mutation's onError
|
// Error handling is done in the mutation's onError
|
||||||
console.error("Send email error:", error);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
if (retryCount < 2) {
|
||||||
|
setRetryCount((prev) => prev + 1);
|
||||||
|
void confirmSendEmail();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -490,7 +531,7 @@ export default function SendEmailPage() {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSendEmail}
|
onClick={handleSendEmail}
|
||||||
disabled={!canSend || isSending}
|
disabled={!canSend || isSending}
|
||||||
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-all duration-200 hover:from-emerald-700 hover:to-teal-700 hover:shadow-lg"
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-md transition-colors duration-200 hover:from-emerald-700 hover:to-teal-700"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{isSending ? (
|
{isSending ? (
|
||||||
@@ -506,6 +547,52 @@ export default function SendEmailPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</FloatingActionBar>
|
</FloatingActionBar>
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Send Invoice Email?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will send invoice #{invoice.invoiceNumber} to{" "}
|
||||||
|
<strong>{invoice.client?.email}</strong>
|
||||||
|
{ccEmail && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
with CC to <strong>{ccEmail}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{bccEmail && (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
and BCC to <strong>{bccEmail}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
.
|
||||||
|
{retryCount > 0 && (
|
||||||
|
<div className="mt-2 text-sm text-yellow-600">
|
||||||
|
Retry attempt {retryCount} of 2
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowConfirmDialog(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={confirmSendEmail}
|
||||||
|
className="bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-700 hover:to-teal-700"
|
||||||
|
>
|
||||||
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
Send Email
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export function EnhancedSendInvoiceButton({
|
|||||||
!hasClientEmail
|
!hasClientEmail
|
||||||
? "Client has no email address"
|
? "Client has no email address"
|
||||||
: showResend
|
: showResend
|
||||||
? "Resend Invoice"
|
? "Resend Email"
|
||||||
: "Send Invoice"
|
: "Compose Email"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{invoiceLoading ? (
|
{invoiceLoading ? (
|
||||||
@@ -90,7 +90,7 @@ export function EnhancedSendInvoiceButton({
|
|||||||
) : (
|
) : (
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<span>{showResend ? "Resend Invoice" : "Send Invoice"}</span>
|
<span>{showResend ? "Resend Email" : "Compose Email"}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ export function FloatingActionBar({
|
|||||||
{/* Content container - full width when floating, content width when docked */}
|
{/* Content container - full width when floating, content width when docked */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full transition-all duration-300",
|
"w-full transition-transform duration-300",
|
||||||
isDocked ? "mx-auto px-4 mb-0" : "px-4 mb-4",
|
isDocked ? "mx-auto mb-0 px-4" : "mb-4 px-4",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card className="card-primary">
|
<Card className="card-primary">
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
|
|||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-150 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -16,7 +16,7 @@ const buttonVariants = cva(
|
|||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
"border border-border/40 bg-background/60 shadow-sm backdrop-blur-sm hover:bg-accent/50 hover:text-accent-foreground hover:border-border/60 transition-all duration-200",
|
"border border-border/40 bg-background/60 shadow-sm backdrop-blur-sm hover:bg-accent/50 hover:text-accent-foreground hover:border-border/60 transition-colors duration-150",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
ghost:
|
ghost:
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function generateInvoiceEmailTemplate({
|
|||||||
customMessage,
|
customMessage,
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
baseUrl = "https://beenvoice.app",
|
baseUrl: _baseUrl = "https://beenvoice.app",
|
||||||
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
}: InvoiceEmailTemplateProps): { html: string; text: string } {
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
@@ -173,44 +173,25 @@ export function generateInvoiceEmailTemplate({
|
|||||||
margin: 24px 0;
|
margin: 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-header {
|
.invoice-summary {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-number {
|
.invoice-number {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #16a34a;
|
color: #16a34a;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 8px;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.invoice-date {
|
.invoice-date {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
|
||||||
|
|
||||||
.invoice-amount {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.amount-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #1f2937;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invoice-details {
|
.invoice-details {
|
||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
@@ -219,12 +200,14 @@ export function generateInvoiceEmailTemplate({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row {
|
||||||
display: table;
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid #f3f4f6;
|
border-bottom: 1px solid #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.detail-row:last-child {
|
.detail-row:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -234,21 +217,19 @@ export function generateInvoiceEmailTemplate({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-label {
|
.detail-label {
|
||||||
display: table-cell;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
width: 50%;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-value {
|
.detail-value {
|
||||||
display: table-cell;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
width: 50%;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.business-info {
|
.business-info {
|
||||||
@@ -349,11 +330,14 @@ export function generateInvoiceEmailTemplate({
|
|||||||
border-top: 1px solid #e5e7eb;
|
border-top: 1px solid #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-logo {
|
.footer-brand {
|
||||||
max-width: 80px;
|
font-size: 18px;
|
||||||
height: auto;
|
font-weight: bold;
|
||||||
|
color: #16a34a;
|
||||||
margin: 0 auto 8px;
|
margin: 0 auto 8px;
|
||||||
display: block;
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-text {
|
.footer-text {
|
||||||
@@ -378,6 +362,12 @@ export function generateInvoiceEmailTemplate({
|
|||||||
mso-table-rspace: 0pt;
|
mso-table-rspace: 0pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Gmail specific fixes */
|
||||||
|
.gmail-fix {
|
||||||
|
border-collapse: separate !important;
|
||||||
|
border-spacing: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Apple Mail attachment preview fix */
|
/* Apple Mail attachment preview fix */
|
||||||
.attachment-notice {
|
.attachment-notice {
|
||||||
border: 2px dashed #bbf7d0 !important;
|
border: 2px dashed #bbf7d0 !important;
|
||||||
@@ -404,15 +394,10 @@ export function generateInvoiceEmailTemplate({
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-row {
|
.detail-row td {
|
||||||
display: block;
|
display: block !important;
|
||||||
}
|
width: 100% !important;
|
||||||
|
text-align: left !important;
|
||||||
.detail-label,
|
|
||||||
.detail-value {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -434,39 +419,41 @@ export function generateInvoiceEmailTemplate({
|
|||||||
${customContent ? `<div class="message custom-content">${customContent}</div>` : ""}
|
${customContent ? `<div class="message custom-content">${customContent}</div>` : ""}
|
||||||
|
|
||||||
<div class="invoice-card">
|
<div class="invoice-card">
|
||||||
<div class="invoice-header">
|
<div class="invoice-summary">
|
||||||
<div>
|
<div class="invoice-number">#${invoice.invoiceNumber}</div>
|
||||||
<div class="invoice-number">#${invoice.invoiceNumber}</div>
|
<div class="invoice-date">Issue Date: ${formatDate(invoice.issueDate)}</div>
|
||||||
<div class="invoice-date">Issue Date: ${formatDate(invoice.issueDate)}</div>
|
<div class="invoice-date">Due Date: ${formatDate(invoice.dueDate)}</div>
|
||||||
<div class="invoice-date">Due Date: ${formatDate(invoice.dueDate)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="invoice-amount">
|
|
||||||
<div class="amount-label">Total Amount</div>
|
|
||||||
<div class="amount-value">${formatCurrency(total)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="invoice-details">
|
<div class="invoice-details">
|
||||||
<div class="detail-row">
|
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
|
||||||
<span class="detail-label">Client</span>
|
<tr>
|
||||||
<span class="detail-value">${invoice.client.name}</span>
|
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Client</td>
|
||||||
</div>
|
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${invoice.client.name}</td>
|
||||||
<div class="detail-row">
|
</tr>
|
||||||
<span class="detail-label">Subtotal</span>
|
</table>
|
||||||
<span class="detail-value">${formatCurrency(subtotal)}</span>
|
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
|
||||||
</div>
|
<tr>
|
||||||
|
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Subtotal</td>
|
||||||
|
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${formatCurrency(subtotal)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
${
|
${
|
||||||
invoice.taxRate > 0
|
invoice.taxRate > 0
|
||||||
? `<div class="detail-row">
|
? `<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-bottom: 1px solid #f3f4f6;">
|
||||||
<span class="detail-label">Tax (${invoice.taxRate}%)</span>
|
<tr>
|
||||||
<span class="detail-value">${formatCurrency(taxAmount)}</span>
|
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left;">Tax (${invoice.taxRate}%)</td>
|
||||||
</div>`
|
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right;">${formatCurrency(taxAmount)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
<div class="detail-row">
|
<table class="detail-row" cellpadding="0" cellspacing="0" border="0" width="100%" style="border-collapse: separate; border-spacing: 0; width: 100%; border-top: 2px solid #e5e7eb; margin-top: 8px; padding-top: 12px;">
|
||||||
<span class="detail-label">Total</span>
|
<tr>
|
||||||
<span class="detail-value">${formatCurrency(total)}</span>
|
<td class="detail-label" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: left; font-weight: bold; font-size: 16px;">Total</td>
|
||||||
</div>
|
<td class="detail-value" style="width: 50%; vertical-align: top; padding: 8px 0; text-align: right; font-weight: bold; font-size: 18px; color: #16a34a;">${formatCurrency(total)}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -497,7 +484,7 @@ export function generateInvoiceEmailTemplate({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<img src="${baseUrl}/beenvoice-logo.svg" alt="beenvoice" class="footer-logo" />
|
<div class="footer-brand">beenvoice</div>
|
||||||
${
|
${
|
||||||
invoice.business
|
invoice.business
|
||||||
? `<div class="footer-text">
|
? `<div class="footer-text">
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ const registerFonts = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fontsRegistered = true;
|
fontsRegistered = true;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.warn("Font registration failed, using built-in fonts:", error);
|
|
||||||
fontsRegistered = true; // Don't keep trying
|
fontsRegistered = true; // Don't keep trying
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1076,8 +1075,6 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
|||||||
// Download the PDF
|
// Download the PDF
|
||||||
saveAs(blob, filename);
|
saveAs(blob, filename);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("PDF generation error:", error);
|
|
||||||
|
|
||||||
// Provide more specific error messages
|
// Provide more specific error messages
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes("timeout")) {
|
if (error.message.includes("timeout")) {
|
||||||
@@ -1102,8 +1099,6 @@ export async function generateInvoicePDF(invoice: InvoiceData): Promise<void> {
|
|||||||
export async function generateInvoicePDFBlob(
|
export async function generateInvoicePDFBlob(
|
||||||
invoice: InvoiceData,
|
invoice: InvoiceData,
|
||||||
): Promise<Blob> {
|
): Promise<Blob> {
|
||||||
const isServerSide = typeof window === "undefined";
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure fonts are registered (important for server-side generation)
|
// Ensure fonts are registered (important for server-side generation)
|
||||||
registerFonts();
|
registerFonts();
|
||||||
@@ -1121,10 +1116,6 @@ export async function generateInvoicePDFBlob(
|
|||||||
throw new Error("Client information is required");
|
throw new Error("Client information is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Generating PDF blob for invoice ${invoice.invoiceNumber} (${isServerSide ? "server-side" : "client-side"})...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate PDF blob with timeout (same as generateInvoicePDF)
|
// Generate PDF blob with timeout (same as generateInvoicePDF)
|
||||||
const pdfPromise = pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
const pdfPromise = pdf(<InvoicePDF invoice={invoice} />).toBlob();
|
||||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
@@ -1137,17 +1128,8 @@ export async function generateInvoicePDFBlob(
|
|||||||
if (!blob || blob.size === 0) {
|
if (!blob || blob.size === 0) {
|
||||||
throw new Error("Generated PDF is empty");
|
throw new Error("Generated PDF is empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
`PDF blob generated successfully, size: ${blob.size} bytes (${isServerSide ? "server-side" : "client-side"})`,
|
|
||||||
);
|
|
||||||
return blob;
|
return blob;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
|
||||||
`PDF generation error for email attachment (${isServerSide ? "server-side" : "client-side"}):`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Provide more specific error messages (same as generateInvoicePDF)
|
// Provide more specific error messages (same as generateInvoicePDF)
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (error.message.includes("timeout")) {
|
if (error.message.includes("timeout")) {
|
||||||
|
|||||||
@@ -119,15 +119,6 @@ export const emailRouter = createTRPCRouter({
|
|||||||
let resendInstance: Resend;
|
let resendInstance: Resend;
|
||||||
let fromEmail: string;
|
let fromEmail: string;
|
||||||
|
|
||||||
console.log("Email configuration debug:");
|
|
||||||
console.log(
|
|
||||||
"- Business resendApiKey:",
|
|
||||||
invoice.business?.resendApiKey ? "***SET***" : "not set",
|
|
||||||
);
|
|
||||||
console.log("- Business resendDomain:", invoice.business?.resendDomain);
|
|
||||||
console.log("- System RESEND_DOMAIN:", env.RESEND_DOMAIN);
|
|
||||||
console.log("- Business email:", invoice.business?.email);
|
|
||||||
|
|
||||||
// Check if business has custom Resend configuration
|
// Check if business has custom Resend configuration
|
||||||
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
|
if (invoice.business?.resendApiKey && invoice.business?.resendDomain) {
|
||||||
// Use business's custom Resend setup
|
// Use business's custom Resend setup
|
||||||
@@ -135,21 +126,16 @@ export const emailRouter = createTRPCRouter({
|
|||||||
const fromName =
|
const fromName =
|
||||||
invoice.business.emailFromName ?? invoice.business.name ?? userName;
|
invoice.business.emailFromName ?? invoice.business.name ?? userName;
|
||||||
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
fromEmail = `${fromName} <noreply@${invoice.business.resendDomain}>`;
|
||||||
console.log("- Using business custom Resend configuration");
|
|
||||||
} else if (env.RESEND_DOMAIN) {
|
} else if (env.RESEND_DOMAIN) {
|
||||||
// Use system Resend configuration
|
// Use system Resend configuration
|
||||||
resendInstance = defaultResend;
|
resendInstance = defaultResend;
|
||||||
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
fromEmail = `noreply@${env.RESEND_DOMAIN}`;
|
||||||
console.log("- Using system Resend configuration");
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback to business email if no configured domains
|
// Fallback to business email if no configured domains
|
||||||
resendInstance = defaultResend;
|
resendInstance = defaultResend;
|
||||||
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com";
|
fromEmail = invoice.business?.email ?? "noreply@yourdomain.com";
|
||||||
console.log("- Using fallback configuration");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("- Final fromEmail:", fromEmail);
|
|
||||||
|
|
||||||
// Prepare CC and BCC lists
|
// Prepare CC and BCC lists
|
||||||
const ccEmails: string[] = [];
|
const ccEmails: string[] = [];
|
||||||
const bccEmails: string[] = [];
|
const bccEmails: string[] = [];
|
||||||
@@ -163,8 +149,6 @@ export const emailRouter = createTRPCRouter({
|
|||||||
for (const email of ccList) {
|
for (const email of ccList) {
|
||||||
if (emailRegex.test(email)) {
|
if (emailRegex.test(email)) {
|
||||||
ccEmails.push(email);
|
ccEmails.push(email);
|
||||||
} else {
|
|
||||||
console.warn("Invalid CC email format, skipping:", email);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,8 +162,6 @@ export const emailRouter = createTRPCRouter({
|
|||||||
for (const email of bccList) {
|
for (const email of bccList) {
|
||||||
if (emailRegex.test(email)) {
|
if (emailRegex.test(email)) {
|
||||||
bccEmails.push(email);
|
bccEmails.push(email);
|
||||||
} else {
|
|
||||||
console.warn("Invalid BCC email format, skipping:", email);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,11 +171,6 @@ export const emailRouter = createTRPCRouter({
|
|||||||
// Validate business email format before adding to CC
|
// Validate business email format before adding to CC
|
||||||
if (emailRegex.test(invoice.business.email)) {
|
if (emailRegex.test(invoice.business.email)) {
|
||||||
ccEmails.push(invoice.business.email);
|
ccEmails.push(invoice.business.email);
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
"Invalid business email format, skipping CC:",
|
|
||||||
invoice.business.email,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,8 +199,7 @@ export const emailRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} catch (sendError) {
|
} catch {
|
||||||
console.error("Resend API call failed:", sendError);
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Email service is currently unavailable. Please try again later.",
|
"Email service is currently unavailable. Please try again later.",
|
||||||
);
|
);
|
||||||
@@ -231,7 +207,6 @@ export const emailRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// Enhanced error checking
|
// Enhanced error checking
|
||||||
if (emailResult.error) {
|
if (emailResult.error) {
|
||||||
console.error("Resend API error:", emailResult.error);
|
|
||||||
const errorMsg = emailResult.error.message?.toLowerCase() ?? "";
|
const errorMsg = emailResult.error.message?.toLowerCase() ?? "";
|
||||||
|
|
||||||
// Provide more specific error messages based on error type
|
// Provide more specific error messages based on error type
|
||||||
@@ -287,12 +262,8 @@ export const emailRouter = createTRPCRouter({
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, input.invoiceId));
|
.where(eq(invoices.id, input.invoiceId));
|
||||||
} catch (dbError) {
|
} catch {
|
||||||
console.error("Failed to update invoice status:", dbError);
|
|
||||||
// Don't throw here - email was sent successfully, status update is secondary
|
// Don't throw here - email was sent successfully, status update is secondary
|
||||||
console.warn(
|
|
||||||
`Invoice ${invoice.invoiceNumber} sent but status not updated`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user