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>
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
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">
|
||
© ${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’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 5–10 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’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’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,
|
||
});
|
||
},
|
||
});
|