Files
the-pet-loft/convex/emails.ts
ianshaloom 3d50cb895c feat(orders): implement QA audit fixes — return flow, refund webhook, TS cleanup
Convex backend (AUDIT-5–10):
- schema: add returnLabelUrl, returnTrackingNumber, returnCarrier fields +
  by_return_tracking_number_and_carrier and by_stripe_payment_intent_id indexes
- orders: markReturnReceived now sets status="completed"; add getOrderByPaymentIntent
  and applyReturnAccepted internal helpers
- returnActions: add acceptReturn action — creates Shippo return label (is_return:true),
  persists label data, sends return label email to customer
- stripeActions: handle refund.updated webhook to auto-mark orders refunded via Stripe Dashboard
- shippoWebhook: add getOrderByReturnTracking; applyTrackingUpdate extended with
  isReturnTracking flag (return events use return_tracking_update type, skip delivered transition)
- emails: add sendReturnLabelEmail; fulfillmentActions: createShippingLabel action

Admin UI (AUDIT-1–6):
- OrderActionsBar: full rewrite per authoritative action matrix; remove UpdateStatusDialog;
  add AcceptReturnButton for delivered+returnRequested state
- AcceptReturnButton: new action component matching CreateLabelButton pattern
- FulfilmentCard: add returnLabelUrl prop; show "Return label" row; rename outbound
  label to "Outbound label" when both are present
- statusConfig: add return_accepted to OrderEventType and EVENT_TYPE_LABELS
- orders detail page and all supporting cards/components

Storefront & shared (TS fixes):
- checkout/success, CheckoutErrorState, OrderReviewStep, PaymentStep: replace
  Button as/color/isLoading/variant="flat" with HeroUI v3-compatible props
- ReviewList: isLoading → isPending for HeroUI v3 Button
- packages/utils: add return and completed entries to ORDER_STATUS_LABELS/COLORS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-07 17:59:29 +03:00

284 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { components } from "./_generated/api";
import { Resend } from "@convex-dev/resend";
import { internalMutation } from "./_generated/server";
import { v } from "convex/values";
// ─── Component instance ───────────────────────────────────────────────────────
export const resend = new Resend(components.resend, {
// Set testMode: false once you have a verified Resend domain and want to
// deliver to real addresses. While testMode is true, only Resend's own test
// addresses (e.g. delivered@resend.dev) will actually receive mail.
testMode: false,
});
// Update this once your sending domain is verified in Resend.
const FROM = "The Pet Loft <no-reply@thepetloft.co.uk>";
// ─── HTML helpers ─────────────────────────────────────────────────────────────
function base(body: string): string {
return `
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;background:#f0f8f7;padding:24px">
<div style="background:#236f6b;padding:20px 24px;border-radius:8px 8px 0 0">
<h1 style="color:#fff;margin:0;font-size:22px;letter-spacing:-0.5px">The Pet Loft</h1>
</div>
<div style="background:#fff;padding:32px 24px;border-radius:0 0 8px 8px;color:#1a2e2d">
${body}
</div>
<p style="color:#1a2e2d;font-size:12px;text-align:center;margin-top:16px;opacity:0.6">
&copy; ${new Date().getFullYear()} The Pet Loft. All rights reserved.
</p>
</div>
`;
}
function formatPrice(amountInSmallestUnit: number, currency: string): string {
return new Intl.NumberFormat("en-GB", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amountInSmallestUnit / 100);
}
function btn(href: string, label: string): string {
return `<a href="${href}" style="display:inline-block;background:#38a99f;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;font-weight:bold;margin-top:16px">${label}</a>`;
}
// ─── Order confirmation ───────────────────────────────────────────────────────
export const sendOrderConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
total: v.number(),
currency: v.string(),
items: v.array(
v.object({
productName: v.string(),
variantName: v.string(),
quantity: v.number(),
unitPrice: v.number(),
}),
),
shippingAddress: v.object({
fullName: v.string(),
addressLine1: v.string(),
city: v.string(),
postalCode: v.string(),
country: v.string(),
}),
},
handler: async (ctx, args) => {
const rows = args.items
.map(
(item) => `
<tr>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7">
<strong>${item.productName}</strong><br>
<span style="font-size:13px;opacity:0.7">${item.variantName}</span>
</td>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:center">×${item.quantity}</td>
<td style="padding:8px 0;border-bottom:1px solid #f0f8f7;text-align:right;color:#236f6b;font-weight:bold">
${formatPrice(item.unitPrice * item.quantity, args.currency)}
</td>
</tr>`,
)
.join("");
const addr = args.shippingAddress;
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Order confirmed!</h2>
<p>Hi ${args.firstName}, thank you for your order. We&rsquo;re getting it ready now.</p>
<p style="margin-bottom:4px"><strong>Order:</strong> ${args.orderNumber}</p>
<table style="width:100%;border-collapse:collapse;margin:20px 0">
${rows}
<tr>
<td colspan="2" style="padding:12px 0;font-weight:bold">Total</td>
<td style="padding:12px 0;text-align:right;color:#236f6b;font-weight:bold;font-size:18px">
${formatPrice(args.total, args.currency)}
</td>
</tr>
</table>
<p style="margin-bottom:4px"><strong>Shipping to:</strong></p>
<p style="margin:0;line-height:1.6">
${addr.fullName}<br>
${addr.addressLine1}<br>
${addr.city}, ${addr.postalCode}<br>
${addr.country}
</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Order confirmed — ${args.orderNumber}`,
html,
});
},
});
// ─── Shipping confirmation ────────────────────────────────────────────────────
export const sendShippingConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
trackingNumber: v.string(),
trackingUrl: v.string(),
carrier: v.string(),
estimatedDelivery: v.optional(v.number()),
},
handler: async (ctx, args) => {
const eta = args.estimatedDelivery
? `<p><strong>Estimated delivery:</strong> ${new Date(args.estimatedDelivery).toDateString()}</p>`
: "";
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your order is on its way!</h2>
<p>Hi ${args.firstName}, <strong>${args.orderNumber}</strong> has been shipped.</p>
<p><strong>Carrier:</strong> ${args.carrier}</p>
<p><strong>Tracking number:</strong> ${args.trackingNumber}</p>
${eta}
${btn(args.trackingUrl, "Track your order")}
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has shipped`,
html,
});
},
});
// ─── Delivery confirmation ────────────────────────────────────────────────────
export const sendDeliveryConfirmation = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your order has been delivered!</h2>
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been delivered.</p>
<p>We hope your pets love their new goodies! If anything is wrong with your order, please contact our support team.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has been delivered`,
html,
});
},
});
// ─── Cancellation ─────────────────────────────────────────────────────────────
export const sendCancellationNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#f2705a;margin-top:0">Order cancelled</h2>
<p>Hi ${args.firstName}, your order <strong>${args.orderNumber}</strong> has been cancelled.</p>
<p>If you did not request this cancellation or need help, please get in touch with our support team.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Your order ${args.orderNumber} has been cancelled`,
html,
});
},
});
// ─── Refund ───────────────────────────────────────────────────────────────────
export const sendRefundNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
total: v.number(),
currency: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Refund processed</h2>
<p>Hi ${args.firstName}, your refund for order <strong>${args.orderNumber}</strong> has been processed.</p>
<p><strong>Refund amount:</strong> ${formatPrice(args.total, args.currency)}</p>
<p>Please allow 510 business days for the amount to appear on your statement.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Refund processed for order ${args.orderNumber}`,
html,
});
},
});
// ─── Return label ─────────────────────────────────────────────────────────────
export const sendReturnLabelEmail = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
returnLabelUrl: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Your return label is ready</h2>
<p>Hi ${args.firstName}, your return request for order <strong>${args.orderNumber}</strong> has been accepted.</p>
<p>Please use the link below to download your prepaid return label and attach it to your parcel.</p>
${btn(args.returnLabelUrl, "Download return label")}
<p style="margin-top:16px;font-size:13px;color:#888">Once we receive your return, we&rsquo;ll process your refund promptly.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Return label for order ${args.orderNumber}`,
html,
});
},
});
// ─── Return requested ─────────────────────────────────────────────────────────
export const sendReturnRequestedNotice = internalMutation({
args: {
to: v.string(),
firstName: v.string(),
orderNumber: v.string(),
},
handler: async (ctx, args) => {
const html = base(`
<h2 style="color:#236f6b;margin-top:0">Return request received</h2>
<p>Hi ${args.firstName}, we&rsquo;ve received your return request for order <strong>${args.orderNumber}</strong>.</p>
<p>Our team will review it and get back to you within 2 business days.</p>
`);
await resend.sendEmail(ctx, {
from: FROM,
to: args.to,
subject: `Return request received for order ${args.orderNumber}`,
html,
});
},
});