ReviewOS

also looking at this

stacks/pet-store

umbrella: pet-store storefront fixes (rolling)

#3
Merged glennmichael123 wants to merge umbrella/pet-store into main
586 files +38082 -7487

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

resources/emails/order-canceled.stxadded+79-0
Changes to resources/emails/order-canceled.stx
@@ -0,0 +1,79 @@
1<script server>
2const orderId = props.orderId || ''
3const orderUrl = props.orderUrl || '#'
4const customerName = props.customerName || 'there'
5const reason = props.reason || ''
6const refundAmount = Number(props.refundAmount || 0)
7const appName = props.appName || 'PetStore'
8const year = props.year || new Date().getFullYear()
9
10function fmt(n) { return '$' + Number(n).toFixed(2) }
11</script>
12<!DOCTYPE html>
13<html lang="en">
14<head>
15 <meta charset="UTF-8">
16 <meta name="viewport" content="width=device-width, initial-scale=1.0">
17 <title>Your order was canceled — {{ appName }}</title>
18</head>
19<body style="margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background-color:#f5f5f4;line-height:1.6;color:#1c1917;">
20 <table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;border-collapse:collapse;">
21 <tr>
22 <td style="padding:40px 20px;">
23 <table role="presentation" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background-color:#ffffff;border-radius:12px;overflow:hidden;">
24
25 <tr>
26 <td style="padding:32px 32px 24px;border-bottom:1px solid #e7e5e4;">
27 <p style="margin:0 0 4px;color:#78716c;font-size:12px;text-transform:uppercase;letter-spacing:0.08em;font-weight:600;">{{ appName }}</p>
28 <h1 style="margin:0;color:#1c1917;font-size:24px;font-weight:700;letter-spacing:-0.01em;">Your order was canceled.</h1>
29 <p style="margin:8px 0 0;color:#57534e;font-size:14px;">Order <strong style="color:#1c1917;">#{{ orderId }}</strong></p>
30 </td>
31 </tr>
32
33 <tr>
34 <td style="padding:24px 32px;">
35 <p style="margin:0 0 16px;color:#1c1917;font-size:15px;">Hi {{ customerName }},</p>
36 <p style="margin:0 0 16px;color:#57534e;font-size:15px;">
37 We've canceled order <strong>#</strong>. </span><span class="t-string">
38 </p>
39
40 @if (refundAmount > 0)
41 <table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;margin:24px 0 0;background-color:#fafaf9;border-radius:8px;">
42 <tr>
43 <td style="padding:20px;">
44 <p style="margin:0 0 4px;color:#78716c;font-size:12px;text-transform:uppercase;letter-spacing:0.08em;font-weight:600;">Refund</p>
45 <p style="margin:0;color:#1c1917;font-size:18px;font-weight:700;font-variant-numeric:tabular-nums;">{{ fmt(refundAmount) }}</p>
46 <p style="margin:8px 0 0;color:#57534e;font-size:13px;">Should land back on your card in 5–10 business days. Bank timing varies.</p>
47 </td>
48 </tr>
49 </table>
50 @endif
51
52 <p style="margin:24px 0 0;color:#57534e;font-size:14px;">
53 If this was a mistake — or if you'd like to reorder when stock is back — just reply to this email. We're a small team and we read every one.
54 </p>
55
56 <table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0 0;">
57 <tr>
58 <td>
59 <a href="" style="display:inline-block;background-color:#1c1917;color:#ffffff;text-decoration:none;padding:12px 24px;border-radius:9999px;font-size:14px;font-weight:600;">View order details</a>
60 </td>
61 </tr>
62 </table>
63 </td>
64 </tr>
65
66 <tr>
67 <td style="padding:24px 32px;border-top:1px solid #e7e5e4;background-color:#fafaf9;">
68 <p style="margin:0;color:#78716c;font-size:13px;">Sorry it didn't work out this time — we'd love a second shot whenever you're ready.</p>
69 </td>
70 </tr>
71
72 </table>
73
74 <p style="text-align:center;color:#a8a29e;font-size:11px;margin:24px 0 0;">&copy; {{ year }} {{ appName }}.</p>
75 </td>
76 </tr>
77 </table>
78</body>
79</html>
resources/views/orders/[id].stxmodified+7-1
Changes to resources/views/orders/[id].stx
@@ -113,7 +113,13 @@ const total = order ? Number(order.total_amount || 0) : 0
113113 <div class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-stone-100">
114114 <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-stone-700" fill="none" viewBox="0 0 24 24" stroke-width="1.75" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m0 0a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0M3.375 5.25h12.5c.621 0 1.125.504 1.125 1.125v9M3.375 5.25a1.125 1.125 0 00-1.125 1.125v9.75c0 .621.504 1.125 1.125 1.125h.375M16.5 14.25V11.25c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v6c0 .621-.504 1.125-1.125 1.125H17.25"/></svg>
115115 </div>
116 @if (order.tracking_number)
116 @if (String(order.status || '').toUpperCase() === 'CANCELED')
117 <p class="mt-3 font-semibold text-stone-900">Order canceled</p>
118 <p class="mt-1 text-stone-600">If you have questions, just reply to the cancellation email.</p>
119 @elseif (String(order.status || '').toUpperCase() === 'DELIVERED')
120 <p class="mt-3 font-semibold text-stone-900">Delivered</p>
121 <p class="mt-1 text-stone-600">{{ order.carrier ? `${order.carrier} · ` : '' }}<span class="font-mono text-xs text-stone-700">{{ order.tracking_number || '' }}</span></p>
122 @elseif (order.tracking_number)
117123 <p class="mt-3 font-semibold text-stone-900">Shipped {{ order.shipped_at ? String(order.shipped_at).slice(0, 10) : '' }}</p>
118124 <p class="mt-1 text-stone-600">{{ order.carrier ? `${order.carrier} · ` : '' }}<span class="font-mono text-xs text-stone-700">{{ order.tracking_number }}</span></p>
119125 @if (order.tracking_url)
routes/api.tsmodified+8-4
Changes to routes/api.ts
@@ -49,11 +49,15 @@ route.post('/api/account/addresses/{id}/delete', 'Actions/Storefront/DeleteAddre
4949route.post('/api/account/addresses/{id}/default', 'Actions/Storefront/SetDefaultAddressAction').skipCsrf()
5050
5151// ============================================================================
52// Admin: order fulfillment
52// Admin: order lifecycle
5353//
5454// Admin-only — gated by the framework's `auth` middleware (User-side
55// session, same as the rest of the dashboard). Marks an order as
56// shipped, saves carrier + tracking info, and fires the "your order
57// shipped" email to the customer.
55// session, same as the rest of the dashboard).
56//
57// /ship → save tracking info + flip to SHIPPED + email buyer
58// /deliver → flip to DELIVERED (no email; carrier already sent one)
59// /cancel → flip to CANCELED + email buyer with reason + refund
5860// ============================================================================
5961route.post('/api/orders/{id}/ship', 'Actions/Commerce/MarkOrderShippedAction').middleware('auth')
62route.post('/api/orders/{id}/deliver', 'Actions/Commerce/MarkOrderDeliveredAction').middleware('auth')
63route.post('/api/orders/{id}/cancel', 'Actions/Commerce/CancelOrderAction').middleware('auth')
storage/framework/defaults/app/Actions/Commerce/CancelOrderAction.tsadded+95-0
Changes to storage/framework/defaults/app/Actions/Commerce/CancelOrderAction.ts
@@ -0,0 +1,95 @@
1import { Action } from '@stacksjs/actions'
2import { config } from '@stacksjs/config'
3import { db } from '@stacksjs/database'
4import { log } from '@stacksjs/logging'
5import { response } from '@stacksjs/router'
6import sendOrderCanceled from '../../Mail/OrderCanceled'
7
8/**
9 * Admin-only: cancel an order. Flips status to CANCELED and emails
10 * the customer with the reason + refund amount (if any).
11 *
12 * Email failures are caught + logged. The DB write is the source of
13 * truth for "is this order canceled?" — bouncing the admin response
14 * because the mailer is down forces them to cancel the same order
15 * twice, which can confuse a refund flow downstream.
16 *
17 * Currently does not initiate the actual payment refund — that lives
18 * in the payments package and depends on the gateway. The
19 * `refund_amount` here is informational on the email; the refund
20 * itself is the operator's next step.
21 */
22export default new Action({
23 name: 'CancelOrderAction',
24 description: 'Mark an order CANCELED and notify the customer.',
25 method: 'POST',
26
27 async handle(request: any) {
28 const id = Number(request.getParam('id') ?? request.get('id') ?? 0)
29 if (!id)
30 return response.json({ success: false, message: 'Missing order id.' }, { status: 422 })
31
32 const reason = String(request.get('reason') ?? '').trim().slice(0, 200)
33 const refundAmount = Number(request.get('refund_amount') ?? 0)
34
35 if (!Number.isFinite(refundAmount) || refundAmount < 0)
36 return response.json({ success: false, message: 'Invalid refund amount.' }, { status: 422 })
37
38 const order = await (db as any)
39 .selectFrom('orders')
40 .where('id', '=', id)
41 .selectAll()
42 .executeTakeFirst()
43
44 if (!order)
45 return response.json({ success: false, message: 'Order not found.' }, { status: 404 })
46
47 // Already-canceled is idempotent — re-canceling shouldn't fire
48 // another email or reset shipped_at fields. Just return the row.
49 if (String(order.status || '').toUpperCase() === 'CANCELED') {
50 return response.json({
51 success: true,
52 message: 'Order was already canceled.',
53 order: { id: order.id, status: 'CANCELED' },
54 })
55 }
56
57 const now = new Date().toISOString()
58 await (db as any)
59 .updateTable('orders')
60 .set({ status: 'CANCELED', updated_at: now })
61 .where('id', '=', id)
62 .execute()
63
64 try {
65 const customer = order.customer_id
66 ? await (db as any)
67 .selectFrom('customers')
68 .where('id', '=', order.customer_id)
69 .selectAll()
70 .executeTakeFirst()
71 : null
72
73 if (customer?.email) {
74 const baseUrl = config.app?.url || 'http://localhost:3000'
75 await sendOrderCanceled({
76 to: customer.email,
77 orderId: order.id,
78 customerName: customer.name || 'there',
79 reason: reason || undefined,
80 refundAmount,
81 orderUrl: `${baseUrl}/orders/${order.uuid}`,
82 })
83 }
84 }
85 catch (err) {
86 log.error?.('[CancelOrder] email failed', err)
87 }
88
89 return response.json({
90 success: true,
91 message: 'Order canceled.',
92 order: { id: order.id, status: 'CANCELED', reason: reason || null, refundAmount },
93 })
94 },
95})
storage/framework/defaults/app/Actions/Commerce/MarkOrderDeliveredAction.tsadded+43-0
Changes to storage/framework/defaults/app/Actions/Commerce/MarkOrderDeliveredAction.ts
@@ -0,0 +1,43 @@
1import { Action } from '@stacksjs/actions'
2import { db } from '@stacksjs/database'
3import { response } from '@stacksjs/router'
4
5/**
6 * Admin-only: mark an order as delivered.
7 *
8 * No email — buyers see "Delivered" on the order detail page next
9 * time they load it, and most carriers send their own delivery
10 * confirmation. Spamming a second one from us is noise.
11 *
12 * Idempotent on already-delivered orders: re-marking just bumps
13 * `updated_at` so admins know the row was acknowledged.
14 */
15export default new Action({
16 name: 'MarkOrderDeliveredAction',
17 description: 'Flip an order to DELIVERED.',
18 method: 'POST',
19
20 async handle(request: any) {
21 const id = Number(request.getParam('id') ?? request.get('id') ?? 0)
22 if (!id)
23 return response.json({ success: false, message: 'Missing order id.' }, { status: 422 })
24
25 const order = await (db as any)
26 .selectFrom('orders')
27 .where('id', '=', id)
28 .selectAll()
29 .executeTakeFirst()
30
31 if (!order)
32 return response.json({ success: false, message: 'Order not found.' }, { status: 404 })
33
34 const now = new Date().toISOString()
35 await (db as any)
36 .updateTable('orders')
37 .set({ status: 'DELIVERED', updated_at: now })
38 .where('id', '=', id)
39 .execute()
40
41 return response.json({ success: true, message: 'Order marked delivered.', order: { id: order.id, status: 'DELIVERED' } })
42 },
43})
storage/framework/defaults/app/Mail/OrderCanceled.tsadded+52-0
Changes to storage/framework/defaults/app/Mail/OrderCanceled.ts
@@ -0,0 +1,52 @@
1import { config } from '@stacksjs/config'
2import { mail, template } from '@stacksjs/email'
3
4export interface OrderCanceledOptions {
5 to: string
6 orderId: number | string
7 customerName?: string
8 /** Free-text reason shown to the customer ("out of stock", "fraud check", …). */
9 reason?: string
10 /** Dollar amount being refunded; pass 0 for cancellations before payment. */
11 refundAmount?: number
12 /** Absolute URL the "View order details" CTA should link to. */
13 orderUrl: string
14}
15
16/**
17 * Send the order-canceled notification.
18 *
19 * Uses `resources/emails/order-canceled.stx`. The template degrades
20 * cleanly without a reason or refund — the buyer still gets the
21 * "we canceled your order" message either way.
22 *
23 * Failures bubble up as rejected promises. The action that calls
24 * this should `.catch()` so a flaky mailer doesn't unwind the
25 * cancellation in the database.
26 */
27export async function sendOrderCanceled(options: OrderCanceledOptions): Promise<void> {
28 const appName = config.app.name || 'PetStore'
29 const fromAddress = config.email.from?.address || 'hello@stacksjs.com'
30
31 const { html, text } = await template('order-canceled', {
32 variables: {
33 orderId: options.orderId,
34 orderUrl: options.orderUrl,
35 customerName: options.customerName || 'there',
36 reason: options.reason || '',
37 refundAmount: options.refundAmount ?? 0,
38 appName,
39 },
40 subject: `Your ${appName} order #${options.orderId} was canceled`,
41 })
42
43 await mail.send({
44 to: [options.to],
45 from: { name: appName, address: fromAddress },
46 subject: `Your ${appName} order #${options.orderId} was canceled`,
47 html,
48 text,
49 })
50}
51
52export default sendOrderCanceled