First Commit. Duplicate bunext-sqlite preset
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
import { formatInvoiceDate } from "@/src/functions/backend/pdf/letter-date";
|
||||
import { CompanyLetterheadDefaults } from "@/src/dict/company-dict";
|
||||
import { formatNaira } from "@/src/utils/money";
|
||||
import { ThemeColors } from "@/src/functions/backend/theme/theme-colors";
|
||||
|
||||
const letter_divider = (
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
type InvoiceEmailContentProps = {
|
||||
invoice: {
|
||||
invoice_number?: string;
|
||||
client_name?: string;
|
||||
issue_date?: number | "";
|
||||
due_date?: number | "";
|
||||
status?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
description?: string;
|
||||
quantity?: number | string | "";
|
||||
unit_price_kobo?: number | string | "";
|
||||
}[];
|
||||
};
|
||||
|
||||
type QuoteEmailContentProps = {
|
||||
quote: {
|
||||
quote_number?: string;
|
||||
client_name?: string;
|
||||
issue_date?: number | "";
|
||||
valid_until?: number | "";
|
||||
status?: string;
|
||||
notes?: string;
|
||||
};
|
||||
items: {
|
||||
description?: string;
|
||||
quantity?: number | string | "";
|
||||
unit_price_kobo?: number | string | "";
|
||||
}[];
|
||||
};
|
||||
|
||||
export function InvoiceEmailContent({
|
||||
invoice,
|
||||
items,
|
||||
}: InvoiceEmailContentProps) {
|
||||
const total_kobo = items.reduce((acc, item) => {
|
||||
return (
|
||||
acc +
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: ThemeColors.ink,
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name}
|
||||
</div>
|
||||
{letter_divider}
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
margin: "0 0 6px",
|
||||
}}
|
||||
>
|
||||
Invoice {invoice.invoice_number || ""}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Hello {invoice.client_name || "there"},
|
||||
<br />
|
||||
Please find attached invoice {invoice.invoice_number || ""} for
|
||||
your records.
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
|
||||
<table
|
||||
width="100%"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ fontSize: "13px", borderCollapse: "collapse" }}
|
||||
>
|
||||
<tr
|
||||
style={{
|
||||
background: ThemeColors.ink,
|
||||
color: ThemeColors.surface,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "8px 10px" }}>Description</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Qty
|
||||
</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Amount
|
||||
</td>
|
||||
</tr>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={
|
||||
index % 2 == 0
|
||||
? { background: ThemeColors.faint }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td style={{ padding: "7px 10px" }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{Number(item.quantity) || 0}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{formatNaira(
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price_kobo) || 0),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Total (NGN)
|
||||
</td>
|
||||
<td
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatNaira(total_kobo)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Invoice {invoice.invoice_number || ""} was issued on{" "}
|
||||
{formatInvoiceDate(invoice.issue_date || Date.now())}
|
||||
{invoice.due_date
|
||||
? ` and is due by ${formatInvoiceDate(invoice.due_date)}`
|
||||
: ""}
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
Thank you for your business.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: ThemeColors.muted,
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
paddingTop: "12px",
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name} -{" "}
|
||||
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
|
||||
{CompanyLetterheadDefaults.phone} |{" "}
|
||||
{CompanyLetterheadDefaults.email} |{" "}
|
||||
{CompanyLetterheadDefaults.website}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function QuoteEmailContent({ quote, items }: QuoteEmailContentProps) {
|
||||
const total_kobo = items.reduce((acc, item) => {
|
||||
return (
|
||||
acc +
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price_kobo) || 0)
|
||||
);
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
fontWeight: "600",
|
||||
color: ThemeColors.ink,
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name}
|
||||
</div>
|
||||
{letter_divider}
|
||||
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
margin: "0 0 6px",
|
||||
}}
|
||||
>
|
||||
Quotation {quote.quote_number || ""}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Hello {quote.client_name || "there"},
|
||||
<br />
|
||||
Please find attached quotation {quote.quote_number || ""} for
|
||||
our proposed works.
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div style={{ fontSize: "14px", lineHeight: "1.6" }}>
|
||||
<table
|
||||
width="100%"
|
||||
cellPadding="0"
|
||||
cellSpacing="0"
|
||||
style={{ fontSize: "13px", borderCollapse: "collapse" }}
|
||||
>
|
||||
<tr
|
||||
style={{
|
||||
background: ThemeColors.ink,
|
||||
color: ThemeColors.surface,
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: "8px 10px" }}>Description</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Qty
|
||||
</td>
|
||||
<td align="right" style={{ padding: "8px 10px" }}>
|
||||
Amount
|
||||
</td>
|
||||
</tr>
|
||||
{items.map((item, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
style={
|
||||
index % 2 == 0
|
||||
? { background: ThemeColors.faint }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<td style={{ padding: "7px 10px" }}>
|
||||
{item.description}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{Number(item.quantity) || 0}
|
||||
</td>
|
||||
<td align="right" style={{ padding: "7px 10px" }}>
|
||||
{formatNaira(
|
||||
(Number(item.quantity) || 0) *
|
||||
(Number(item.unit_price_kobo) || 0),
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td
|
||||
colSpan={2}
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
Total (NGN)
|
||||
</td>
|
||||
<td
|
||||
align="right"
|
||||
style={{
|
||||
padding: "9px 10px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
{formatNaira(total_kobo)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{letter_divider}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.7",
|
||||
color: ThemeColors.body,
|
||||
}}
|
||||
>
|
||||
Quotation {quote.quote_number || ""} was issued on{" "}
|
||||
{formatInvoiceDate(quote.issue_date || Date.now())}
|
||||
{quote.valid_until
|
||||
? ` and is valid until ${formatInvoiceDate(quote.valid_until)}`
|
||||
: ""}
|
||||
.
|
||||
<br />
|
||||
<br />
|
||||
We look forward to working with you.
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: ThemeColors.muted,
|
||||
borderTop: `1px solid ${ThemeColors.hairline}`,
|
||||
paddingTop: "12px",
|
||||
}}
|
||||
>
|
||||
{CompanyLetterheadDefaults.name} -{" "}
|
||||
{CompanyLetterheadDefaults.address_lines.join(", ")} |{" "}
|
||||
{CompanyLetterheadDefaults.phone} |{" "}
|
||||
{CompanyLetterheadDefaults.email} |{" "}
|
||||
{CompanyLetterheadDefaults.website}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
const SMTPTimeoutMilliseconds = 15 * 1000;
|
||||
|
||||
const EmailClient = nodemailer.createTransport({
|
||||
host: process.env.EMAIL_DOMAIN,
|
||||
port: 465,
|
||||
secure: true,
|
||||
connectionTimeout: SMTPTimeoutMilliseconds,
|
||||
greetingTimeout: SMTPTimeoutMilliseconds,
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmailClient;
|
||||
@@ -0,0 +1,22 @@
|
||||
type Params = {
|
||||
/**
|
||||
* The real recipient, used in production
|
||||
*/
|
||||
to: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Outside production, all outgoing mail is redirected to
|
||||
* `DEV_EMAIL_RECIPIENT` so real users never get test emails. Returns
|
||||
* `undefined` when no dev recipient is configured - callers should skip
|
||||
* sending in that case.
|
||||
*/
|
||||
export default function resolveEmailRecipient({
|
||||
to,
|
||||
}: Params): string | undefined {
|
||||
if (process.env.NODE_ENV == "production") {
|
||||
return to;
|
||||
}
|
||||
|
||||
return process.env.DEV_EMAIL_RECIPIENT || undefined;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { renderToString } from "react-dom/server";
|
||||
import path from "path";
|
||||
import type Mail from "nodemailer/lib/mailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import EmailClient from "./email-client";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Params = {
|
||||
options?: Mail.Options & Partial<SMTPTransport.Options>;
|
||||
to: string;
|
||||
content: ReactNode;
|
||||
subject: string;
|
||||
text: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export default async function sendEmail({
|
||||
options,
|
||||
to,
|
||||
content,
|
||||
subject,
|
||||
text,
|
||||
title,
|
||||
}: Params): Promise<APIResponseObject> {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const EmailRoot = (
|
||||
await import(path.join(process.cwd(), `src/email/root?t=${now}`))
|
||||
).default as FC<any>;
|
||||
const final_component = <EmailRoot>{content}</EmailRoot>;
|
||||
|
||||
const html = renderToString(final_component);
|
||||
|
||||
const mail = await EmailClient.sendMail({
|
||||
from: `"${title || `${SiteData["SiteName"]} Mailer`}" <${process.env.EMAIL_USER}>`,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
sender: `${process.env.EMAIL_USER}`,
|
||||
...options,
|
||||
});
|
||||
|
||||
return {
|
||||
success: Boolean(mail.accepted?.[0]),
|
||||
singleRes: mail,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
msg: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user