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,34 @@ | ||
| 1 | -- Customer address book. | |
| 2 | -- | |
| 3 | -- One row per address a customer has saved; `is_default = 1` marks | |
| 4 | -- the address that pre-fills checkout's shipping form. The action | |
| 5 | -- that flips a row to default also flips every other row for that | |
| 6 | -- customer back to 0, so at most one default exists per customer. | |
| 7 | -- | |
| 8 | -- `name` and `phone` are denormalized from `customers` because each | |
| 9 | -- shipment can go to a different person at a different number — the | |
| 10 | -- gift-to-mom case. Pre-filling them from the customer record on | |
| 11 | -- the form is fine, but the row of record is per-address. | |
| 12 | -- | |
| 13 | -- Postal address fields follow the rough US/CA shape; the | |
| 14 | -- `country` column is intentionally TEXT (ISO-2 like 'US' or full | |
| 15 | -- name) so storefronts can localize without a schema change. | |
| 16 | CREATE TABLE IF NOT EXISTS "addresses" ( | |
| 17 | "id" INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 18 | "customer_id" INTEGER NOT NULL, | |
| 19 | "label" TEXT, | |
| 20 | "name" TEXT NOT NULL, | |
| 21 | "phone" TEXT, | |
| 22 | "street_1" TEXT NOT NULL, | |
| 23 | "street_2" TEXT, | |
| 24 | "city" TEXT NOT NULL, | |
| 25 | "region" TEXT, | |
| 26 | "postal_code" TEXT NOT NULL, | |
| 27 | "country" TEXT NOT NULL DEFAULT 'US', | |
| 28 | "is_default" INTEGER NOT NULL DEFAULT 0, | |
| 29 | "created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 30 | "updated_at" TEXT, | |
| 31 | "uuid" TEXT | |
| 32 | ); | |
| 33 | CREATE INDEX IF NOT EXISTS "addresses_customer_idx" ON "addresses" ("customer_id"); | |
| 34 | CREATE INDEX IF NOT EXISTS "addresses_customer_default_idx" ON "addresses" ("customer_id", "is_default"); | |
| @@ -61,7 +61,7 @@ if (customerId) { | ||
| 61 | 61 | </div> |
| 62 | 62 | </div> |
| 63 | 63 | |
| 64 | <div class="grid gap-4 sm:grid-cols-3 mt-10"> | |
| 64 | <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mt-10"> | |
| 65 | 65 | <div class="p-6 border border-stone-200 rounded-3xl"> |
| 66 | 66 | <p class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Orders</p> |
| 67 | 67 | <p class="mt-3 font-bold tabular-nums text-3xl text-stone-900">{{ orderCount }}</p> |
| @@ -83,6 +83,14 @@ if (customerId) { | ||
| 83 | 83 | <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg> |
| 84 | 84 | </a> |
| 85 | 85 | </div> |
| 86 | <div class="p-6 border border-stone-200 rounded-3xl"> | |
| 87 | <p class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Addresses</p> | |
| 88 | <p class="mt-3 font-semibold text-lg text-stone-900">Address book</p> | |
| 89 | <a href="/addresses" class="inline-flex gap-1 items-center mt-4 font-semibold text-sm text-stone-700 hover:text-stone-900"> | |
| 90 | Manage addresses | |
| 91 | <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg> | |
| 92 | </a> | |
| 93 | </div> | |
| 86 | 94 | </div> |
| 87 | 95 | |
| 88 | 96 | <div class="mt-12"> |
| @@ -0,0 +1,200 @@ | ||
| 1 | @extends('layouts/storefront') | |
| 2 | ||
| 3 | @section('title', 'Addresses — PetStore') | |
| 4 | @section('description', 'Manage the addresses your orders ship to.') | |
| 5 | ||
| 6 | <script server> | |
| 7 | const Database = require('bun:sqlite').Database | |
| 8 | const path = require('path') | |
| 9 | const { readCustomerId } = require(path.resolve(process.cwd(), 'resources/lib/customer-cookie.cjs')) | |
| 10 | ||
| 11 | const dbPath = path.resolve(process.cwd(), process.env.DB_DATABASE_PATH || 'database/stacks.sqlite') | |
| 12 | ||
| 13 | const rawCookie = (typeof requestContext !== 'undefined') ? requestContext.cookie('stacks_customer') : null | |
| 14 | const customerId = readCustomerId(rawCookie) | |
| 15 | ||
| 16 | let customer = null | |
| 17 | let addresses = [] | |
| 18 | let editing = null | |
| 19 | ||
| 20 | if (customerId) { | |
| 21 | const db = new Database(dbPath, { readonly: true }) | |
| 22 | customer = db.query(`SELECT id, name, email, phone FROM customers WHERE id = ?1`).get(customerId) | |
| 23 | ||
| 24 | if (customer) { | |
| 25 | addresses = db.query(` | |
| 26 | SELECT id, label, name, phone, street_1, street_2, city, region, postal_code, country, is_default, updated_at | |
| 27 | FROM addresses | |
| 28 | WHERE customer_id = ?1 | |
| 29 | ORDER BY is_default DESC, id DESC | |
| 30 | `).all(customer.id) | |
| 31 | ||
| 32 | // ?edit=<id> opens the form pre-filled with that address. Anything | |
| 33 | // else (or no param) shows a fresh "add an address" form below | |
| 34 | // the list. | |
| 35 | const editId = Number((typeof query !== 'undefined' && query?.edit) || 0) | |
| 36 | if (editId) | |
| 37 | editing = addresses.find(a => Number(a.id) === editId) || null | |
| 38 | } | |
| 39 | db.close() | |
| 40 | } | |
| 41 | ||
| 42 | const noticeRaw = (typeof query !== 'undefined' && query?.notice) ? String(query.notice) : '' | |
| 43 | const noticeSaved = noticeRaw === 'saved' | |
| 44 | const noticeDeleted = noticeRaw === 'deleted' | |
| 45 | const noticeDefaultSet = noticeRaw === 'default-set' | |
| 46 | const noticeMissing = noticeRaw === 'missing-fields' | |
| 47 | const noticeTooLong = noticeRaw === 'field-too-long' | |
| 48 | </script> | |
| 49 | ||
| 50 | @section('content') | |
| 51 | @if (!customer) | |
| 52 | <meta http-equiv="refresh" content="0; url=/login"> | |
| 53 | <div class="mx-auto px-6 py-24 max-w-md text-center"> | |
| 54 | <p class="text-stone-600">Redirecting to <a href="/login" class="font-semibold underline">sign in</a>…</p> | |
| 55 | </div> | |
| 56 | @else | |
| 57 | <div class="mx-auto px-6 py-12 max-w-3xl"> | |
| 58 | <nav class="font-semibold text-stone-500 text-xs"> | |
| 59 | <a href="/account" class="hover:text-stone-900">Account</a> | |
| 60 | <span class="mx-2">/</span> | |
| 61 | <span class="text-stone-900">Addresses</span> | |
| 62 | </nav> | |
| 63 | ||
| 64 | <h1 class="mt-3 font-bold text-4xl text-stone-900 tracking-tight">Addresses</h1> | |
| 65 | <p class="mt-2 text-stone-600">{{ addresses.length === 1 ? '1 address' : `${addresses.length} addresses` }} on file. The default is what checkout pre-fills.</p> | |
| 66 | ||
| 67 | @if (noticeSaved) | |
| 68 | <div class="mt-6 px-5 py-4 text-emerald-900 text-sm bg-emerald-50 border border-emerald-200 rounded-2xl">Saved.</div> | |
| 69 | @endif | |
| 70 | @if (noticeDeleted) | |
| 71 | <div class="mt-6 px-5 py-4 text-sm text-stone-700 bg-stone-100 border border-stone-200 rounded-2xl">Address removed.</div> | |
| 72 | @endif | |
| 73 | @if (noticeDefaultSet) | |
| 74 | <div class="mt-6 px-5 py-4 text-emerald-900 text-sm bg-emerald-50 border border-emerald-200 rounded-2xl">Default updated.</div> | |
| 75 | @endif | |
| 76 | @if (noticeMissing) | |
| 77 | <div class="mt-6 px-5 py-4 text-amber-900 text-sm bg-amber-50 border border-amber-200 rounded-2xl">Name, street, city, and postal code are required.</div> | |
| 78 | @endif | |
| 79 | @if (noticeTooLong) | |
| 80 | <div class="mt-6 px-5 py-4 text-amber-900 text-sm bg-amber-50 border border-amber-200 rounded-2xl">One of those fields is too long.</div> | |
| 81 | @endif | |
| 82 | ||
| 83 | @if (addresses.length > 0) | |
| 84 | <ul class="mt-8 space-y-3"> | |
| 85 | @foreach (addresses as addr) | |
| 86 | <li class="rounded-3xl border p-6"> | |
| 87 | <div class="flex flex-wrap gap-4 items-start justify-between"> | |
| 88 | <div> | |
| 89 | <div class="flex gap-2 items-center"> | |
| 90 | @if (addr.label) | |
| 91 | <p class="font-semibold text-stone-500 text-xs tracking-wider uppercase">{{ addr.label }}</p> | |
| 92 | @endif | |
| 93 | @if (addr.is_default) | |
| 94 | <span class="inline-flex items-center px-2 py-0.5 font-semibold text-[10px] text-stone-50 tracking-wider uppercase bg-stone-900 rounded-full">Default</span> | |
| 95 | @endif | |
| 96 | </div> | |
| 97 | <p class="mt-1 font-semibold text-stone-900">{{ addr.name }}</p> | |
| 98 | <p class="mt-1 text-sm text-stone-700 whitespace-pre-line">{{ addr.street_1 }}{{ addr.street_2 ? `\n${addr.street_2}` : '' }} | |
| 99 | {{ addr.city }}{{ addr.region ? `, ${addr.region}` : '' }} {{ addr.postal_code }} | |
| 100 | {{ addr.country }}</p> | |
| 101 | @if (addr.phone) | |
| 102 | <p class="mt-1 text-sm text-stone-500">{{ addr.phone }}</p> | |
| 103 | @endif | |
| 104 | </div> | |
| 105 | ||
| 106 | <div class="flex flex-col gap-2 items-end"> | |
| 107 | <a href="/addresses?edit=" class="font-semibold text-sm text-stone-700 underline underline-offset-4 hover:text-stone-900">Edit</a> | |
| 108 | @if (!addr.is_default) | |
| 109 | <form action="/api/account/addresses//default" method="POST"> | |
| 110 | <button type="submit" class="font-semibold text-sm text-stone-700 underline underline-offset-4 hover:text-stone-900">Set default</button> | |
| 111 | </form> | |
| 112 | @endif | |
| 113 | <form action="/api/account/addresses//delete" method="POST" onsubmit="return confirm('Remove this address?');"> | |
| 114 | <button type="submit" class="font-semibold text-rose-700 text-sm underline underline-offset-4 hover:text-rose-900">Remove</button> | |
| 115 | </form> | |
| 116 | </div> | |
| 117 | </div> | |
| 118 | </li> | |
| 119 | @endforeach | |
| 120 | </ul> | |
| 121 | @endif | |
| 122 | ||
| 123 | <section class="mt-10 p-6 border border-stone-200 rounded-3xl"> | |
| 124 | <h2 class="font-bold text-2xl text-stone-900 tracking-tight">{{ editing ? 'Edit address' : 'Add an address' }}</h2> | |
| 125 | <p class="mt-1 text-sm text-stone-600">{{ editing ? 'Update this address. Other orders that already shipped to it stay unchanged.' : 'New addresses go straight into your address book; checkout will offer them on the shipping step.' }}</p> | |
| 126 | ||
| 127 | <form action="/api/account/addresses" method="POST" class="grid gap-4 sm:grid-cols-2 mt-6"> | |
| 128 | @if (editing) | |
| 129 | <input type="hidden" name="id" value=""> | |
| 130 | @endif | |
| 131 | ||
| 132 | <div class="sm:col-span-2"> | |
| 133 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Label (optional)</label> | |
| 134 | <input type="text" name="label" maxlength="50" value="" placeholder="Home, Work, Mom's house…" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 135 | </div> | |
| 136 | ||
| 137 | <div> | |
| 138 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Name</label> | |
| 139 | <input type="text" name="name" required autocomplete="name" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 140 | </div> | |
| 141 | ||
| 142 | <div> | |
| 143 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Phone</label> | |
| 144 | <input type="tel" name="phone" autocomplete="tel" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 145 | </div> | |
| 146 | ||
| 147 | <div class="sm:col-span-2"> | |
| 148 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Street</label> | |
| 149 | <input type="text" name="street_1" required autocomplete="address-line1" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 150 | </div> | |
| 151 | ||
| 152 | <div class="sm:col-span-2"> | |
| 153 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Apt / suite (optional)</label> | |
| 154 | <input type="text" name="street_2" autocomplete="address-line2" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 155 | </div> | |
| 156 | ||
| 157 | <div> | |
| 158 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">City</label> | |
| 159 | <input type="text" name="city" required autocomplete="address-level2" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 160 | </div> | |
| 161 | ||
| 162 | <div> | |
| 163 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">State / region</label> | |
| 164 | <input type="text" name="region" autocomplete="address-level1" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 165 | </div> | |
| 166 | ||
| 167 | <div> | |
| 168 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Postal code</label> | |
| 169 | <input type="text" name="postal_code" required autocomplete="postal-code" value="" class="mt-1 px-4 py-3 w-full text-sm border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 170 | </div> | |
| 171 | ||
| 172 | <div> | |
| 173 | <label class="font-semibold text-stone-500 text-xs tracking-wider uppercase">Country</label> | |
| 174 | <input type="text" name="country" maxlength="2" autocomplete="country" value="US" class="mt-1 px-4 py-3 w-full text-sm uppercase border border-stone-300 rounded-2xl focus:border-stone-900 focus:outline-none"> | |
| 175 | <p class="mt-1 text-stone-500 text-xs">2-letter ISO code (US, CA, GB, …).</p> | |
| 176 | </div> | |
| 177 | ||
| 178 | <div class="sm:col-span-2"> | |
| 179 | <label class="inline-flex gap-3 items-center text-sm text-stone-700"> | |
| 180 | <input type="checkbox" name="is_default" value="1" {{ editing?.is_default ? 'checked' : '' }} class="h-4 w-4 text-stone-900 border-stone-300 rounded focus:ring-stone-900"> | |
| 181 | Use as default for new orders | |
| 182 | </label> | |
| 183 | </div> | |
| 184 | ||
| 185 | <div class="flex flex-wrap gap-4 items-center justify-between sm:col-span-2 pt-2"> | |
| 186 | <a href="/account" class="font-semibold text-sm text-stone-600 underline underline-offset-4 hover:text-stone-900">Back to account</a> | |
| 187 | <div class="flex gap-3 items-center"> | |
| 188 | @if (editing) | |
| 189 | <a href="/addresses" class="font-semibold text-sm text-stone-500 hover:text-stone-900">Cancel</a> | |
| 190 | @endif | |
| 191 | <button type="submit" class="inline-flex items-center justify-center px-6 py-3 font-semibold text-sm text-stone-50 bg-stone-900 hover:bg-stone-800 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-50 focus-visible:ring-stone-900 transition-colors"> | |
| 192 | {{ editing ? 'Save changes' : 'Add address' }} | |
| 193 | </button> | |
| 194 | </div> | |
| 195 | </div> | |
| 196 | </form> | |
| 197 | </section> | |
| 198 | </div> | |
| 199 | @endif | |
| 200 | @endsection | |
| @@ -39,6 +39,15 @@ route.post('/api/auth/logout', 'Actions/Storefront/LogoutAction').skipCsrf() | ||
| 39 | 39 | // the action — no separate middleware — and rate-limits per IP. |
| 40 | 40 | route.post('/api/account/profile', 'Actions/Storefront/UpdateProfileAction').skipCsrf() |
| 41 | 41 | |
| 42 | // Customer address book. Each action reads the `stacks_customer` | |
| 43 | // cookie inline + scopes every UPDATE/DELETE by `customer_id` so a | |
| 44 | // guessed row id can't be hijacked across accounts. SaveAddress | |
| 45 | // handles both create and update — splitting them doubled the route | |
| 46 | // + view code without doubling the value. | |
| 47 | route.post('/api/account/addresses', 'Actions/Storefront/SaveAddressAction').skipCsrf() | |
| 48 | route.post('/api/account/addresses/{id}/delete', 'Actions/Storefront/DeleteAddressAction').skipCsrf() | |
| 49 | route.post('/api/account/addresses/{id}/default', 'Actions/Storefront/SetDefaultAddressAction').skipCsrf() | |
| 50 | ||
| 42 | 51 | // ============================================================================ |
| 43 | 52 | // Admin: order fulfillment |
| 44 | 53 | // |
| @@ -0,0 +1,81 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { rateLimit, response } from '@stacksjs/router' | |
| 4 | import { readCustomerCookie } from '../../Storefront/CustomerCookie' | |
| 5 | ||
| 6 | const CUSTOMER_COOKIE = 'stacks_customer' | |
| 7 | ||
| 8 | /** | |
| 9 | * Delete an address from the logged-in customer's address book. | |
| 10 | * | |
| 11 | * The action is idempotent: deleting an address that doesn't exist | |
| 12 | * (or that belongs to someone else) silently no-ops, then redirects | |
| 13 | * back to /addresses. We don't surface "not found" because that | |
| 14 | * would leak whether a row id existed for a different customer. | |
| 15 | * | |
| 16 | * If the deleted row was the default, the next-most-recent address | |
| 17 | * gets promoted automatically. We keep the address book having a | |
| 18 | * default whenever it's non-empty so checkout pre-fill always has | |
| 19 | * something to load. | |
| 20 | */ | |
| 21 | export default new Action({ | |
| 22 | name: 'DeleteAddressAction', | |
| 23 | description: 'Remove an address from the logged-in customer\'s address book.', | |
| 24 | method: 'POST', | |
| 25 | ||
| 26 | async handle(request: any) { | |
| 27 | await rateLimit('storefront-address-delete', 20).per('minute') | |
| 28 | ||
| 29 | const customerId = readCustomerCookie(request, CUSTOMER_COOKIE) | |
| 30 | if (!customerId) { | |
| 31 | return request.wantsJson?.() | |
| 32 | ? response.json({ success: false, message: 'Sign in first.' }, { status: 401 }) | |
| 33 | : response.redirect('/login') | |
| 34 | } | |
| 35 | ||
| 36 | const id = Number(request.getParam('id') ?? request.get('id') ?? 0) | |
| 37 | if (!id) { | |
| 38 | return request.wantsJson?.() | |
| 39 | ? response.json({ success: false, message: 'Missing address id.' }, { status: 422 }) | |
| 40 | : response.redirect('/addresses') | |
| 41 | } | |
| 42 | ||
| 43 | const target = await (db as any) | |
| 44 | .selectFrom('addresses') | |
| 45 | .where('id', '=', id) | |
| 46 | .where('customer_id', '=', customerId) | |
| 47 | .selectAll() | |
| 48 | .executeTakeFirst() | |
| 49 | ||
| 50 | if (target) { | |
| 51 | await (db as any) | |
| 52 | .deleteFrom('addresses') | |
| 53 | .where('id', '=', id) | |
| 54 | .where('customer_id', '=', customerId) | |
| 55 | .execute() | |
| 56 | ||
| 57 | // If we just deleted the default, promote the most recent | |
| 58 | // remaining address so the address book never sits in a | |
| 59 | // "no default" state for someone with addresses on file. | |
| 60 | if (target.is_default) { | |
| 61 | const fallback = await (db as any) | |
| 62 | .selectFrom('addresses') | |
| 63 | .where('customer_id', '=', customerId) | |
| 64 | .orderBy('id', 'desc') | |
| 65 | .select('id') | |
| 66 | .executeTakeFirst() | |
| 67 | if (fallback?.id) { | |
| 68 | await (db as any) | |
| 69 | .updateTable('addresses') | |
| 70 | .set({ is_default: 1, updated_at: new Date().toISOString() }) | |
| 71 | .where('id', '=', fallback.id) | |
| 72 | .execute() | |
| 73 | } | |
| 74 | } | |
| 75 | } | |
| 76 | ||
| 77 | if (request.wantsJson?.()) | |
| 78 | return response.json({ success: true }) | |
| 79 | return response.redirect('/addresses?notice=deleted') | |
| 80 | }, | |
| 81 | }) | |
| @@ -0,0 +1,184 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { rateLimit, response } from '@stacksjs/router' | |
| 4 | import { readCustomerCookie } from '../../Storefront/CustomerCookie' | |
| 5 | ||
| 6 | const CUSTOMER_COOKIE = 'stacks_customer' | |
| 7 | ||
| 8 | /** | |
| 9 | * Create or update an address in the logged-in customer's address | |
| 10 | * book. | |
| 11 | * | |
| 12 | * The form posts the same fields whether it's a brand-new address | |
| 13 | * (no `id` field) or an edit (`id` set + the row already belongs to | |
| 14 | * this customer). Splitting the two paths into separate actions | |
| 15 | * doubled the route + view code without doubling the value, so they | |
| 16 | * share one endpoint here. | |
| 17 | * | |
| 18 | * Ownership: every UPDATE includes `WHERE id = ? AND customer_id = ?` | |
| 19 | * so a guessed id from a different account can't be hijacked. CREATE | |
| 20 | * always uses the cookie's customer_id directly. | |
| 21 | * | |
| 22 | * Validation is intentionally loose on the postal-address fields — | |
| 23 | * different countries shape addresses differently, and we don't want | |
| 24 | * to reject "1A 1AA" because it doesn't match a US ZIP regex. The | |
| 25 | * carrier will fail the label if the address is truly garbage. | |
| 26 | */ | |
| 27 | export default new Action({ | |
| 28 | name: 'SaveAddressAction', | |
| 29 | description: 'Create or update an address in the logged-in customer\'s address book.', | |
| 30 | method: 'POST', | |
| 31 | ||
| 32 | async handle(request: any) { | |
| 33 | await rateLimit('storefront-address-save', 20).per('minute') | |
| 34 | ||
| 35 | const customerId = readCustomerCookie(request, CUSTOMER_COOKIE) | |
| 36 | if (!customerId) { | |
| 37 | return request.wantsJson?.() | |
| 38 | ? response.json({ success: false, message: 'Sign in first.' }, { status: 401 }) | |
| 39 | : response.redirect('/login') | |
| 40 | } | |
| 41 | ||
| 42 | const id = Number(request.get('id') ?? 0) | |
| 43 | const label = String(request.get('label') ?? '').trim().slice(0, 50) | |
| 44 | const name = String(request.get('name') ?? '').trim() | |
| 45 | const phone = String(request.get('phone') ?? '').trim() | |
| 46 | const street1 = String(request.get('street_1') ?? '').trim() | |
| 47 | const street2 = String(request.get('street_2') ?? '').trim() | |
| 48 | const city = String(request.get('city') ?? '').trim() | |
| 49 | const region = String(request.get('region') ?? '').trim() | |
| 50 | const postalCode = String(request.get('postal_code') ?? '').trim() | |
| 51 | const country = String(request.get('country') ?? 'US').trim().toUpperCase().slice(0, 2) | |
| 52 | const setDefault = String(request.get('is_default') ?? '') === '1' | |
| 53 | ||
| 54 | if (!name || !street1 || !city || !postalCode) { | |
| 55 | return request.wantsJson?.() | |
| 56 | ? response.json({ success: false, message: 'Name, street, city, and postal code are required.' }, { status: 422 }) | |
| 57 | : response.redirect('/addresses?notice=missing-fields') | |
| 58 | } | |
| 59 | if (name.length > 255 || street1.length > 255 || street2.length > 255 || city.length > 100 || region.length > 100 || postalCode.length > 20) { | |
| 60 | return request.wantsJson?.() | |
| 61 | ? response.json({ success: false, message: 'One of those fields is too long.' }, { status: 422 }) | |
| 62 | : response.redirect('/addresses?notice=field-too-long') | |
| 63 | } | |
| 64 | ||
| 65 | const now = new Date().toISOString() | |
| 66 | ||
| 67 | if (id > 0) { | |
| 68 | // Update path. The WHERE clause includes `customer_id` so an | |
| 69 | // attacker can't update someone else's row even if they guess | |
| 70 | // the id. | |
| 71 | const existing = await (db as any) | |
| 72 | .selectFrom('addresses') | |
| 73 | .where('id', '=', id) | |
| 74 | .where('customer_id', '=', customerId) | |
| 75 | .selectAll() | |
| 76 | .executeTakeFirst() | |
| 77 | ||
| 78 | if (!existing) { | |
| 79 | return request.wantsJson?.() | |
| 80 | ? response.json({ success: false, message: 'Address not found.' }, { status: 404 }) | |
| 81 | : response.redirect('/addresses?notice=not-found') | |
| 82 | } | |
| 83 | ||
| 84 | await (db as any) | |
| 85 | .updateTable('addresses') | |
| 86 | .set({ | |
| 87 | label: label || null, | |
| 88 | name, | |
| 89 | phone: phone || null, | |
| 90 | street_1: street1, | |
| 91 | street_2: street2 || null, | |
| 92 | city, | |
| 93 | region: region || null, | |
| 94 | postal_code: postalCode, | |
| 95 | country, | |
| 96 | updated_at: now, | |
| 97 | }) | |
| 98 | .where('id', '=', id) | |
| 99 | .where('customer_id', '=', customerId) | |
| 100 | .execute() | |
| 101 | ||
| 102 | if (setDefault) | |
| 103 | await markDefault(customerId, id, now) | |
| 104 | } | |
| 105 | else { | |
| 106 | // Insert. If the customer has no addresses yet, this one auto- | |
| 107 | // wins the default flag — saves a second click on first save. | |
| 108 | const existing = await (db as any) | |
| 109 | .selectFrom('addresses') | |
| 110 | .where('customer_id', '=', customerId) | |
| 111 | .select('id') | |
| 112 | .execute() | |
| 113 | ||
| 114 | const isFirst = existing.length === 0 | |
| 115 | ||
| 116 | const result = await (db as any) | |
| 117 | .insertInto('addresses') | |
| 118 | .values({ | |
| 119 | customer_id: customerId, | |
| 120 | uuid: crypto.randomUUID(), | |
| 121 | label: label || null, | |
| 122 | name, | |
| 123 | phone: phone || null, | |
| 124 | street_1: street1, | |
| 125 | street_2: street2 || null, | |
| 126 | city, | |
| 127 | region: region || null, | |
| 128 | postal_code: postalCode, | |
| 129 | country, | |
| 130 | is_default: setDefault || isFirst ? 1 : 0, | |
| 131 | created_at: now, | |
| 132 | }) | |
| 133 | .executeTakeFirstOrThrow?.() ?? await (db as any) | |
| 134 | .insertInto('addresses') | |
| 135 | .values({ | |
| 136 | customer_id: customerId, | |
| 137 | uuid: crypto.randomUUID(), | |
| 138 | label: label || null, | |
| 139 | name, | |
| 140 | phone: phone || null, | |
| 141 | street_1: street1, | |
| 142 | street_2: street2 || null, | |
| 143 | city, | |
| 144 | region: region || null, | |
| 145 | postal_code: postalCode, | |
| 146 | country, | |
| 147 | is_default: setDefault || isFirst ? 1 : 0, | |
| 148 | created_at: now, | |
| 149 | }) | |
| 150 | .execute() | |
| 151 | ||
| 152 | // If the user explicitly checked "set as default" on a non- | |
| 153 | // first address, demote the previous default. (For first- | |
| 154 | // address inserts the auto-default flag above already wins.) | |
| 155 | if (setDefault && !isFirst) { | |
| 156 | const newId = Number(result?.insertId ?? 0) | |
| 157 | if (newId > 0) | |
| 158 | await markDefault(customerId, newId, now) | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | if (request.wantsJson?.()) | |
| 163 | return response.json({ success: true }) | |
| 164 | return response.redirect('/addresses?notice=saved') | |
| 165 | }, | |
| 166 | }) | |
| 167 | ||
| 168 | async function markDefault(customerId: number, addressId: number, now: string) { | |
| 169 | // Demote every other address for this customer, then promote the | |
| 170 | // target. Two writes; sqlite has no atomic "set this one to 1 and | |
| 171 | // every other to 0" idiom that's cleaner than this pair. | |
| 172 | await (db as any) | |
| 173 | .updateTable('addresses') | |
| 174 | .set({ is_default: 0, updated_at: now }) | |
| 175 | .where('customer_id', '=', customerId) | |
| 176 | .where('id', '!=', addressId) | |
| 177 | .execute() | |
| 178 | await (db as any) | |
| 179 | .updateTable('addresses') | |
| 180 | .set({ is_default: 1, updated_at: now }) | |
| 181 | .where('id', '=', addressId) | |
| 182 | .where('customer_id', '=', customerId) | |
| 183 | .execute() | |
| 184 | } | |
| @@ -0,0 +1,74 @@ | ||
| 1 | import { Action } from '@stacksjs/actions' | |
| 2 | import { db } from '@stacksjs/database' | |
| 3 | import { rateLimit, response } from '@stacksjs/router' | |
| 4 | import { readCustomerCookie } from '../../Storefront/CustomerCookie' | |
| 5 | ||
| 6 | const CUSTOMER_COOKIE = 'stacks_customer' | |
| 7 | ||
| 8 | /** | |
| 9 | * Mark one address as the default for the logged-in customer; demote | |
| 10 | * every other address for that same customer to non-default in the | |
| 11 | * same flow. Two writes — sqlite has no atomic "flip every row that | |
| 12 | * matches X to 0 except this one" idiom that's cleaner than a pair. | |
| 13 | * | |
| 14 | * Defaults are mutually exclusive per customer: at most one row per | |
| 15 | * customer has `is_default = 1`. Checkout's pre-fill flow reads that | |
| 16 | * row, so keeping the invariant matters. | |
| 17 | */ | |
| 18 | export default new Action({ | |
| 19 | name: 'SetDefaultAddressAction', | |
| 20 | description: 'Mark one address as the customer\'s default; demote the others.', | |
| 21 | method: 'POST', | |
| 22 | ||
| 23 | async handle(request: any) { | |
| 24 | await rateLimit('storefront-address-default', 20).per('minute') | |
| 25 | ||
| 26 | const customerId = readCustomerCookie(request, CUSTOMER_COOKIE) | |
| 27 | if (!customerId) { | |
| 28 | return request.wantsJson?.() | |
| 29 | ? response.json({ success: false, message: 'Sign in first.' }, { status: 401 }) | |
| 30 | : response.redirect('/login') | |
| 31 | } | |
| 32 | ||
| 33 | const id = Number(request.getParam('id') ?? request.get('id') ?? 0) | |
| 34 | if (!id) { | |
| 35 | return request.wantsJson?.() | |
| 36 | ? response.json({ success: false, message: 'Missing address id.' }, { status: 422 }) | |
| 37 | : response.redirect('/addresses') | |
| 38 | } | |
| 39 | ||
| 40 | // Verify ownership before doing the demote/promote pair so we | |
| 41 | // don't accidentally clear someone else's default just because | |
| 42 | // they got the same id. | |
| 43 | const target = await (db as any) | |
| 44 | .selectFrom('addresses') | |
| 45 | .where('id', '=', id) | |
| 46 | .where('customer_id', '=', customerId) | |
| 47 | .select('id') | |
| 48 | .executeTakeFirst() | |
| 49 | ||
| 50 | if (!target) { | |
| 51 | return request.wantsJson?.() | |
| 52 | ? response.json({ success: false, message: 'Address not found.' }, { status: 404 }) | |
| 53 | : response.redirect('/addresses?notice=not-found') | |
| 54 | } | |
| 55 | ||
| 56 | const now = new Date().toISOString() | |
| 57 | await (db as any) | |
| 58 | .updateTable('addresses') | |
| 59 | .set({ is_default: 0, updated_at: now }) | |
| 60 | .where('customer_id', '=', customerId) | |
| 61 | .where('id', '!=', id) | |
| 62 | .execute() | |
| 63 | await (db as any) | |
| 64 | .updateTable('addresses') | |
| 65 | .set({ is_default: 1, updated_at: now }) | |
| 66 | .where('id', '=', id) | |
| 67 | .where('customer_id', '=', customerId) | |
| 68 | .execute() | |
| 69 | ||
| 70 | if (request.wantsJson?.()) | |
| 71 | return response.json({ success: true }) | |
| 72 | return response.redirect('/addresses?notice=default-set') | |
| 73 | }, | |
| 74 | }) | |