916ad39176
Deploy to route.crispygoat.com / deploy (push) Failing after 3m1s
- Add MinIO/S3-compatible storage client (src/lib/storage.ts) with uploadObject, deleteObject, presigned URL helpers, and BUCKETS constant - Wire product images, brand logos, and water log photos to MinIO via the new storage client - Migrate forgot-password to Neon Auth (remove Supabase /auth/v1/recover call) - Migrate send-scheduled cron to direct Postgres + Resend (remove Supabase Edge Function proxy) - Add logoUrl to email types (OrderReceipt, Welcome, PasswordReset) and pass brand_settings.logo_url from all call sites - Update email templates to use dynamic logoUrl instead of hardcoded Supabase bucket URLs - Remove hardcoded Supabase URLs from TuxedoVideoHero, TuxedoAboutPage, TimeTrackingFieldClient; use brand_settings props + local public/ fallback - Download brand logos (3) and tuxedo-hero.mp4 (36MB) from Supabase bucket to public/ for local development - Add MinIO env vars to .env.example (endpoint, access key, secret, buckets) - Fix TimeTrackingFieldClient to destructure logoUrl and brandAccent props - Fix admin/users.ts logoUrl type (null → undefined for optional string) - Remove stale sb- cookie from wholesale-auth - Migrate tuxedo/about page to remove supabase import and use pool query for wholesale_settings lookup
516 lines
23 KiB
TypeScript
516 lines
23 KiB
TypeScript
/**
|
||
* Resend Email Service — Route Commerce / Tuxedo Corn
|
||
*
|
||
* Branded transactional email templates for Tuxedo Corn storefront.
|
||
* All emails use the Olathe Sweet + Tuxedo Corn brand identity.
|
||
*
|
||
* Env vars required:
|
||
* RESEND_API_KEY — Resend API key
|
||
* FROM_EMAIL — e.g. "Tuxedo Corn <no-reply@tuxedocorn.com>"
|
||
*/
|
||
|
||
const FROM_EMAIL = process.env.FROM_EMAIL ?? "Tuxedo Corn <no-reply@tuxedocorn.com>";
|
||
const RESEND_API_KEY = process.env.RESEND_API_KEY ?? "";
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Shared email send function
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
async function sendEmail(opts: {
|
||
to: string;
|
||
subject: string;
|
||
html: string;
|
||
text?: string;
|
||
from?: string;
|
||
}): Promise<boolean> {
|
||
if (!RESEND_API_KEY) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch("https://api.resend.com/emails", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${RESEND_API_KEY}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
from: opts.from ?? FROM_EMAIL,
|
||
to: opts.to,
|
||
subject: opts.subject,
|
||
html: opts.html,
|
||
text: opts.text ?? opts.subject,
|
||
}),
|
||
});
|
||
|
||
if (!res.ok) {
|
||
return false;
|
||
}
|
||
|
||
return res.ok;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Generic campaign email — used by the scheduled-campaigns cron
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type CampaignEmailData = {
|
||
to: string;
|
||
subject: string;
|
||
html: string;
|
||
from?: string;
|
||
};
|
||
|
||
export async function sendCampaignEmail(data: CampaignEmailData): Promise<boolean> {
|
||
return sendEmail({
|
||
to: data.to,
|
||
subject: data.subject,
|
||
html: data.html,
|
||
text: data.subject,
|
||
from: data.from,
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Order Receipt — sent to customer after successful checkout
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type OrderReceiptData = {
|
||
customerName: string;
|
||
customerEmail: string;
|
||
orderId: string;
|
||
items: Array<{
|
||
name: string;
|
||
quantity: number;
|
||
price: number;
|
||
}>;
|
||
subtotal: number;
|
||
taxAmount: number;
|
||
total: number;
|
||
fulfillment: "pickup" | "ship";
|
||
stopCity?: string;
|
||
stopState?: string;
|
||
stopDate?: string;
|
||
stopTime?: string;
|
||
stopLocation?: string;
|
||
brandName?: string;
|
||
/** Public URL for the brand logo (e.g. from brand_settings.logo_url). */
|
||
logoUrl?: string | null;
|
||
};
|
||
|
||
export async function sendOrderReceiptEmail(data: OrderReceiptData): Promise<boolean> {
|
||
const pickupInfo = data.fulfillment === "pickup" && data.stopDate;
|
||
const itemRows = data.items
|
||
.map(
|
||
(item) => `
|
||
<tr>
|
||
<td style="padding: 10px 0; border-bottom: 1px solid #e7e5e4; color: #1c1917; font-size: 14px;">
|
||
${item.name} <span style="color: #78716c;">× ${item.quantity}</span>
|
||
</td>
|
||
<td style="padding: 10px 0; border-bottom: 1px solid #e7e5e4; text-align: right; color: #1c1917; font-size: 14px; font-weight: 600;">
|
||
$${(item.price * item.quantity).toFixed(2)}
|
||
</td>
|
||
</tr>`
|
||
)
|
||
.join("");
|
||
|
||
const pickupBlock = pickupInfo
|
||
? `
|
||
<div style="background: #fafaf9; border: 1px solid #e7e5e4; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
|
||
<p style="margin: 0 0 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #78716c;">Pickup Details</p>
|
||
<p style="margin: 0; font-size: 15px; font-weight: 700; color: #1c1917;">${data.stopCity}, ${data.stopState}</p>
|
||
<p style="margin: 4px 0 0; font-size: 14px; color: #57534e;">${data.stopDate}${data.stopTime ? ` · ${data.stopTime}` : ""}</p>
|
||
${data.stopLocation ? `<p style="margin: 4px 0 0; font-size: 13px; color: #78716c;">${data.stopLocation}</p>` : ""}
|
||
</div>`
|
||
: `<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
|
||
<p style="margin: 0; font-size: 14px; font-weight: 600; color: #166534;">Shipping to your door — cooler boxes ship after the season ends.</p>
|
||
</div>`;
|
||
|
||
const logoHeader = data.logoUrl
|
||
? `<img src="${data.logoUrl}" alt="${data.brandName ?? ""}" style="height: 40px; width: auto; object-fit: contain; margin-bottom: 16px; filter: brightness(0) invert(1); opacity: 0.9;" />`
|
||
: data.brandName
|
||
? `<p style="margin: 0 0 16px; font-size: 18px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">${data.brandName}</p>`
|
||
: "";
|
||
|
||
const logoCallout = data.logoUrl
|
||
? `<img src="${data.logoUrl}" alt="${data.brandName ?? ""}" style="height: 28px; width: auto; object-fit: contain; opacity: 0.7; flex-shrink: 0;" />`
|
||
: "";
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Order Confirmed — ${data.brandName ?? "Tuxedo Corn"}</title>
|
||
</head>
|
||
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
|
||
|
||
<!-- Header -->
|
||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||
${logoHeader}
|
||
<h1 style="margin: 0; font-size: 28px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Order Confirmed</h1>
|
||
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Order #${data.orderId.slice(0, 8).toUpperCase()}</p>
|
||
</div>
|
||
|
||
<!-- Body -->
|
||
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
|
||
<p style="margin: 0 0 4px; font-size: 16px; font-weight: 600; color: #1c1917;">Hi ${data.customerName.split(" ")[0]},</p>
|
||
<p style="margin: 0 0 24px; font-size: 14px; color: #57534e; line-height: 1.6;">
|
||
Great news — your order is confirmed and we're getting it ready.
|
||
${pickupInfo ? "Your corn will be hand-picked fresh the morning of your stop." : "We'll ship your cooler boxes after the season ends."}
|
||
</p>
|
||
|
||
<!-- Order summary table -->
|
||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 8px;">
|
||
<thead>
|
||
<tr>
|
||
<th style="text-align: left; padding: 8px 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #78716c; border-bottom: 2px solid #e7e5e4;">Item</th>
|
||
<th style="text-align: right; padding: 8px 0; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #78716c; border-bottom: 2px solid #e7e5e4;">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${itemRows}
|
||
</tbody>
|
||
</table>
|
||
|
||
<!-- Totals -->
|
||
<div style="border-top: 2px solid #e7e5e4; padding-top: 16px; margin-top: 8px;">
|
||
<div style="display: flex; justify-content: space-between; margin: 4px 0;">
|
||
<span style="font-size: 14px; color: #57534e;">Subtotal</span>
|
||
<span style="font-size: 14px; font-weight: 600; color: #1c1917;">$${data.subtotal.toFixed(2)}</span>
|
||
</div>
|
||
${data.taxAmount > 0 ? `
|
||
<div style="display: flex; justify-content: space-between; margin: 4px 0;">
|
||
<span style="font-size: 14px; color: #57534e;">Tax</span>
|
||
<span style="font-size: 14px; font-weight: 600; color: #1c1917;">$${data.taxAmount.toFixed(2)}</span>
|
||
</div>` : ""}
|
||
<div style="display: flex; justify-content: space-between; margin: 8px 0 0; padding-top: 12px; border-top: 1px solid #e7e5e4;">
|
||
<span style="font-size: 16px; font-weight: 800; color: #1c1917;">Total Paid</span>
|
||
<span style="font-size: 16px; font-weight: 800; color: #1c1917;">$${data.total.toFixed(2)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pickup / Shipping details -->
|
||
${pickupBlock}
|
||
|
||
<!-- Brand callout -->
|
||
${logoCallout || data.brandName ? `
|
||
<div style="background: #fafaf9; border-radius: 10px; padding: 16px 20px; margin-top: 24px; display: flex; align-items: center; gap: 12px;">
|
||
${logoCallout}
|
||
<p style="margin: 0; font-size: 12px; color: #78716c; line-height: 1.5;">
|
||
${data.brandName ? `Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct` : ""}
|
||
</p>
|
||
</div>` : ""}
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div style="text-align: center; margin-top: 24px;">
|
||
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
|
||
Questions? Reply to this email or contact us at <a href="mailto:hello@tuxedocorn.com" style="color: #78716c;">hello@tuxedocorn.com</a>
|
||
</p>
|
||
<p style="margin: 8px 0 0; font-size: 11px; color: #d6d3d1;">
|
||
© ${new Date().getFullYear()} ${data.brandName ?? "Tuxedo Corn"} · 59751 David Road, Olathe, CO 81425
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const text = `Order Confirmed — Tuxedo Corn (Order #${data.orderId.slice(0, 8).toUpperCase()})
|
||
|
||
Hi ${data.customerName.split(" ")[0]},
|
||
|
||
Your order is confirmed.${pickupInfo
|
||
? `\n\nPickup: ${data.stopCity}, ${data.stopState}\nDate: ${data.stopDate}${data.stopTime ? ` at ${data.stopTime}` : ""}\nLocation: ${data.stopLocation ?? ""}`
|
||
: "\n\nShipping: Cooler boxes ship after the season ends."}
|
||
|
||
Items:
|
||
${data.items.map((i) => ` ${i.name} × ${i.quantity} — $${(i.price * i.quantity).toFixed(2)}`).join("\n")}
|
||
|
||
Subtotal: $${data.subtotal.toFixed(2)}${data.taxAmount > 0 ? `\nTax: $${data.taxAmount.toFixed(2)}` : ""}
|
||
Total: $${data.total.toFixed(2)}
|
||
|
||
Grown in Olathe, Colorado — Non-GMO · Hand-Picked · Farm-Direct
|
||
Questions? Reply to this email or contact hello@tuxedocorn.com`;
|
||
|
||
return sendEmail({
|
||
to: data.customerEmail,
|
||
subject: `Your Order is Confirmed — Tuxedo Corn #${data.orderId.slice(0, 8).toUpperCase()}`,
|
||
html,
|
||
text,
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Welcome Email — sent to new admin or wholesale users
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type WelcomeEmailData = {
|
||
to: string;
|
||
name: string;
|
||
role: "brand_admin" | "wholesale_buyer" | "store_employee";
|
||
brandName?: string;
|
||
tempPassword?: string;
|
||
setupUrl?: string;
|
||
/** Public URL for the brand logo (e.g. from brand_settings.logo_url). */
|
||
logoUrl?: string | null;
|
||
};
|
||
|
||
export async function sendWelcomeEmail(data: WelcomeEmailData): Promise<boolean> {
|
||
const roleLabel =
|
||
data.role === "brand_admin" ? "Admin"
|
||
: data.role === "wholesale_buyer" ? "Wholesale Buyer"
|
||
: "Store Employee";
|
||
|
||
const brandBlock = data.brandName
|
||
? `<p style="margin: 4px 0 0; font-size: 14px; color: #57534e;">Brand: <strong>${data.brandName}</strong></p>`
|
||
: "";
|
||
|
||
const nextSteps = data.role === "brand_admin" ? `
|
||
<div style="background: #fafaf9; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
|
||
<p style="margin: 0 0 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #78716c;">Your first steps</p>
|
||
<ol style="margin: 0; padding: 0 0 0 20px; font-size: 14px; color: #57534e; line-height: 2;">
|
||
<li>Visit <strong>/admin/settings/brand</strong> to add your company info and logos</li>
|
||
<li>Add your first product under <strong>Products</strong></li>
|
||
<li>Create a stop under <strong>Tours & Stops</strong></li>
|
||
<li>Configure payments under <strong>Settings → Payments</strong></li>
|
||
</ol>
|
||
</div>`
|
||
: data.role === "wholesale_buyer" ? `
|
||
<div style="background: #fafaf9; border-radius: 12px; padding: 20px 24px; margin: 24px 0;">
|
||
<p style="margin: 0 0 12px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; color: #78716c;">What you can do</p>
|
||
<ul style="margin: 0; padding: 0 0 0 20px; font-size: 14px; color: #57534e; line-height: 2;">
|
||
<li>Browse and order products at your assigned wholesale pricing</li>
|
||
<li>Track your order history and upcoming deliveries</li>
|
||
<li>Manage billing with net-30 terms (if approved)</li>
|
||
</ul>
|
||
</div>`
|
||
: "";
|
||
|
||
const logoHeader = data.logoUrl
|
||
? `<img src="${data.logoUrl}" alt="${data.brandName ?? ""}" style="height: 36px; width: auto; object-fit: contain; margin-bottom: 16px;" />`
|
||
: data.brandName
|
||
? `<p style="margin: 0 0 16px; font-size: 18px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">${data.brandName}</p>`
|
||
: "";
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Welcome to ${data.brandName ?? "Route Commerce"}</title>
|
||
</head>
|
||
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
|
||
|
||
<!-- Header -->
|
||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||
${logoHeader}
|
||
<h1 style="margin: 0; font-size: 26px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">Welcome, ${data.name.split(" ")[0]}</h1>
|
||
<p style="margin: 8px 0 0; font-size: 14px; color: rgba(255,255,255,0.6);">Your ${roleLabel} account is ready</p>
|
||
${brandBlock}
|
||
</div>
|
||
|
||
<!-- Body -->
|
||
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
|
||
<p style="margin: 0 0 16px; font-size: 14px; color: #57534e; line-height: 1.7;">
|
||
Your account has been created on the Route Commerce platform and is now active for <strong>${data.brandName ?? "this brand"}</strong>.
|
||
</p>
|
||
${nextSteps}
|
||
<div style="margin-top: 24px; padding: 16px 20px; background: #f0fdf4; border-radius: 10px;">
|
||
<p style="margin: 0; font-size: 13px; color: #166534; line-height: 1.6;">
|
||
<strong>Need help?</strong> Reply to this email or reach us at <a href="mailto:hello@tuxedocorn.com" style="color: #15803d;">hello@tuxedocorn.com</a> — we respond same business day.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Footer -->
|
||
<div style="text-align: center; margin-top: 24px;">
|
||
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
|
||
© ${new Date().getFullYear()} ${data.brandName ?? "Route Commerce"} · Route Commerce Platform
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const text = `Welcome to Tuxedo Corn, ${data.name.split(" ")[0]}
|
||
|
||
Your ${roleLabel} account is now active${data.brandName ? ` for ${data.brandName}` : ""}.
|
||
|
||
${data.role === "brand_admin" ? `
|
||
First steps:
|
||
1. Visit /admin/settings/brand — add company info and logos
|
||
2. Add your first product
|
||
3. Create a stop under Tours & Stops
|
||
4. Configure payments under Settings → Payments
|
||
` : data.role === "wholesale_buyer" ? `
|
||
You can now:
|
||
- Browse and order products at your wholesale pricing
|
||
- Track your order history and upcoming deliveries
|
||
- Manage billing with net-30 terms (if approved)
|
||
` : ""}
|
||
|
||
Need help? Reply to this email or contact hello@tuxedocorn.com
|
||
`;
|
||
|
||
return sendEmail({
|
||
to: data.to,
|
||
subject: `Welcome to Tuxedo Corn — ${roleLabel} Account Ready`,
|
||
html,
|
||
text,
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Password Reset Email — triggered by Supabase auth (custom template via Resend)
|
||
// Note: Supabase handles the reset flow by default; this can be used as a
|
||
// custom template if you configure Resend as the SMTP provider in Supabase.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type PasswordResetData = {
|
||
to: string;
|
||
name: string;
|
||
resetUrl: string;
|
||
/** Public URL for the brand logo (e.g. from brand_settings.logo_url). */
|
||
logoUrl?: string | null;
|
||
};
|
||
|
||
export async function sendPasswordResetEmail(data: PasswordResetData): Promise<boolean> {
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Reset Your Password</title>
|
||
</head>
|
||
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
|
||
<div style="background: #1c1917; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||
${data.logoUrl ? `<img src="${data.logoUrl}" alt="" style="height: 32px; width: auto; object-fit: contain; margin-bottom: 16px;" />` : ""}
|
||
<h1 style="margin: 0; font-size: 24px; font-weight: 800; color: #ffffff;">Reset Your Password</h1>
|
||
</div>
|
||
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
|
||
<p style="margin: 0 0 16px; font-size: 14px; color: #57534e; line-height: 1.7;">
|
||
Hi <strong>${data.name.split(" ")[0]}</strong>, we received a request to reset your password.
|
||
Click the button below to set a new password. This link expires in 1 hour.
|
||
</p>
|
||
<div style="text-align: center; margin: 28px 0;">
|
||
<a href="${data.resetUrl}"
|
||
style="display: inline-block; background: #16a34a; color: #ffffff; text-decoration: none; font-weight: 700; font-size: 15px; padding: 14px 32px; border-radius: 10px;">
|
||
Reset Password
|
||
</a>
|
||
</div>
|
||
<p style="margin: 0; font-size: 13px; color: #a8a29e; text-align: center; line-height: 1.6;">
|
||
If you didn't request this, you can safely ignore this email.<br />
|
||
This link expires in 1 hour and can only be used once.
|
||
</p>
|
||
</div>
|
||
<div style="text-align: center; margin-top: 24px;">
|
||
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
|
||
© ${new Date().getFullYear()} Tuxedo Corn · Route Commerce Platform
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const text = `Reset Your Password — Tuxedo Corn
|
||
|
||
Hi ${data.name.split(" ")[0]},
|
||
|
||
We received a request to reset your password. Click the link below to set a new password:
|
||
|
||
${data.resetUrl}
|
||
|
||
This link expires in 1 hour and can only be used once.
|
||
|
||
If you didn't request this, you can safely ignore this email.`;
|
||
|
||
return sendEmail({
|
||
to: data.to,
|
||
subject: "Reset Your Password — Tuxedo Corn",
|
||
html,
|
||
text,
|
||
});
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Operational Alert — future-proof alert for low stock, system events, etc.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type OperationalAlertData = {
|
||
to: string;
|
||
severity: "info" | "warning" | "critical";
|
||
title: string;
|
||
message: string;
|
||
brandName?: string;
|
||
actionUrl?: string;
|
||
actionLabel?: string;
|
||
};
|
||
|
||
export async function sendOperationalAlert(data: OperationalAlertData): Promise<boolean> {
|
||
const severityConfig = {
|
||
info: { bg: "#1e3a5f", label: "Notice" },
|
||
warning: { bg: "#b45309", label: "Warning" },
|
||
critical: { bg: "#991b1b", label: "Critical" },
|
||
};
|
||
const cfg = severityConfig[data.severity] ?? severityConfig.info;
|
||
|
||
const brandBadge = data.brandName
|
||
? `<span style="display: inline-block; background: rgba(255,255,255,0.15); padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; margin-top: 8px;">${data.brandName}</span>`
|
||
: "";
|
||
|
||
const actionBlock = (data.actionUrl && data.actionLabel)
|
||
? `<div style="text-align: center; margin: 28px 0 0;">
|
||
<a href="${data.actionUrl}"
|
||
style="display: inline-block; background: #ffffff; color: ${cfg.bg}; text-decoration: none; font-weight: 700; font-size: 14px; padding: 12px 28px; border-radius: 8px;">
|
||
${data.actionLabel}
|
||
</a>
|
||
</div>`
|
||
: "";
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${data.title}</title>
|
||
</head>
|
||
<body style="margin: 0; padding: 0; background: #fafaf9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1c1917;">
|
||
<div style="max-width: 600px; margin: 0 auto; padding: 32px 16px;">
|
||
<div style="background: ${cfg.bg}; border-radius: 16px; padding: 32px; text-align: center; margin-bottom: 24px;">
|
||
<p style="margin: 0 0 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.7);">${cfg.label}</p>
|
||
<h1 style="margin: 0; font-size: 22px; font-weight: 800; color: #ffffff; letter-spacing: -0.02em;">${data.title}</h1>
|
||
${brandBadge}
|
||
</div>
|
||
<div style="background: #ffffff; border-radius: 16px; padding: 32px; border: 1px solid #e7e5e4;">
|
||
<p style="margin: 0; font-size: 15px; color: #57534e; line-height: 1.7;">${data.message}</p>
|
||
${actionBlock}
|
||
</div>
|
||
<div style="text-align: center; margin-top: 24px;">
|
||
<p style="margin: 0; font-size: 12px; color: #a8a29e;">
|
||
Route Commerce Platform · Powered by Tuxedo Corn
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
const text = `[${cfg.label.toUpperCase()}] ${data.title}
|
||
|
||
${data.message}
|
||
|
||
${data.actionUrl ? `${data.actionLabel}: ${data.actionUrl}` : ""}`;
|
||
|
||
return sendEmail({
|
||
to: data.to,
|
||
subject: `[${cfg.label}] ${data.title}`,
|
||
html,
|
||
text,
|
||
});
|
||
} |