also looking at this
umbrella: pet-store storefront fixes (rolling)
#3
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.
| @@ -0,0 +1,79 @@ | ||
| 1 | <script server> | |
| 2 | const orderId = props.orderId || '' | |
| 3 | const orderUrl = props.orderUrl || '#' | |
| 4 | const customerName = props.customerName || 'there' | |
| 5 | const reason = props.reason || '' | |
| 6 | const refundAmount = Number(props.refundAmount || 0) | |
| 7 | const appName = props.appName || 'PetStore' | |
| 8 | const year = props.year || new Date().getFullYear() | |
| 9 | ||
| 10 | function 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;">© {{ year }} {{ appName }}.</p> | |
| 75 | </td> | |
| 76 | </tr> | |
| 77 | </table> | |
| 78 | </body> | |
| 79 | </html> | |
| @@ -113,7 +113,13 @@ const total = order ? Number(order.total_amount || 0) : 0 | ||
| 113 | 113 | <div class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-stone-100"> |
| 114 | 114 | <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> |
| 115 | 115 | </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) | |
| 117 | 123 | <p class="mt-3 font-semibold text-stone-900">Shipped {{ order.shipped_at ? String(order.shipped_at).slice(0, 10) : '' }}</p> |
| 118 | 124 | <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> |
| 119 | 125 | @if (order.tracking_url) |
| @@ -49,11 +49,15 @@ route.post('/api/account/addresses/{id}/delete', 'Actions/Storefront/DeleteAddre | ||
| 49 | 49 | route.post('/api/account/addresses/{id}/default', 'Actions/Storefront/SetDefaultAddressAction').skipCsrf() |
| 50 | 50 | |
| 51 | 51 | // ============================================================================ |
| 52 | // Admin: order fulfillment | |
| 52 | // Admin: order lifecycle | |
| 53 | 53 | // |
| 54 | 54 | // 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 | |
| 58 | 60 | // ============================================================================ |
| 59 | 61 | route.post('/api/orders/{id}/ship', 'Actions/Commerce/MarkOrderShippedAction').middleware('auth') |
| 62 | route.post('/api/orders/{id}/deliver', 'Actions/Commerce/MarkOrderDeliveredAction').middleware('auth') | |
| 63 | route.post('/api/orders/{id}/cancel', 'Actions/Commerce/CancelOrderAction').middleware('auth') | |
| @@ -0,0 +1,95 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { config } from '@stacksjs/config' | |
| 3 | import { db } from '@stacksjs/database' | |
| 4 | import { log } from '@stacksjs/logging' | |
| 5 | import { response } from '@stacksjs/router' | |
| 6 | import 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 | */ | |
| 22 | export 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 | }) | |
| @@ -0,0 +1,43 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { 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 | */ | |
| 15 | export 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 | }) | |
| @@ -0,0 +1,52 @@ | ||
| 1 | import { config } from '@stacksjs/config' | |
| 2 | import { mail, template } from '@stacksjs/email' | |
| 3 | ||
| 4 | export 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 | */ | |
| 27 | export 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 | ||
| 52 | export default sendOrderCanceled | |