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>
This commit is contained in:
2026-03-07 17:59:29 +03:00
parent 8e4309892c
commit 3d50cb895c
32 changed files with 3046 additions and 45 deletions

283
convex/emails.ts Normal file
View File

@@ -0,0 +1,283 @@
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,
});
},
});