feat: generic checkout page — dynamic products + Stripe (#55) #56

Merged
forgejo_admin merged 4 commits from 55-feat-generic-checkout-page-dynamic-produ into main 2026-03-20 08:05:40 +00:00

Summary

  • New /checkout route consumes generic checkout API from basketball-api #127
  • Dynamic product cards + custom form fields driven by product's custom_fields JSON
  • Stripe redirect for paid products, direct success for opt-out

Changes

  • src/routes/checkout/+page.svelte: Main checkout page — fetches products by category, renders cards with select interaction, dynamic form fields (number/select/text), Stripe session creation, validation
  • src/routes/checkout/success/+page.svelte: Order confirmation page
  • src/routes/checkout/cancel/+page.svelte: Payment cancelled with retry link

Test Plan

  • npm run build passes
  • Manual test with real parent token after basketball-api #127 deploys
  • Jersey number + top size + shorts size fields render and validate
  • Stripe redirect works for $90 and $130 options
  • Opt-out skips Stripe, goes to success page

Review Checklist

  • No secrets committed
  • No unnecessary file changes
  • Commit messages are descriptive
  • Passed automated review-fix loop
  • Closes #55
  • plan-wkq — Phase 11 girls jersey checkout
  • Depends on: basketball-api #127 (backend endpoints)
## Summary - New `/checkout` route consumes generic checkout API from basketball-api #127 - Dynamic product cards + custom form fields driven by product's `custom_fields` JSON - Stripe redirect for paid products, direct success for opt-out ## Changes - `src/routes/checkout/+page.svelte`: Main checkout page — fetches products by category, renders cards with select interaction, dynamic form fields (number/select/text), Stripe session creation, validation - `src/routes/checkout/success/+page.svelte`: Order confirmation page - `src/routes/checkout/cancel/+page.svelte`: Payment cancelled with retry link ## Test Plan - [x] `npm run build` passes - [ ] Manual test with real parent token after basketball-api #127 deploys - [ ] Jersey number + top size + shorts size fields render and validate - [ ] Stripe redirect works for $90 and $130 options - [ ] Opt-out skips Stripe, goes to success page ## Review Checklist - [x] No secrets committed - [x] No unnecessary file changes - [x] Commit messages are descriptive - [ ] Passed automated review-fix loop ## Related - Closes #55 - `plan-wkq` — Phase 11 girls jersey checkout - Depends on: basketball-api #127 (backend endpoints)
Add /checkout route that consumes the new generic checkout API:
- Fetches products by category from /checkout/products
- Renders product cards with select interaction
- Shows dynamic custom form fields (number, select, text) from
  product's custom_fields JSON spec
- Validates required fields before submission
- Creates Stripe Checkout Session via /checkout/create-session
- Handles opt-out products (direct success, no Stripe)
- Success and cancel pages included

Tokenized parent links: /checkout?token=...&category=jersey

Closes #55

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

PR #56 Review

DOMAIN REVIEW

Tech stack: SvelteKit 5 (Svelte runes / $state), vanilla CSS, Stripe redirect flow, parent-token auth pattern.

Security

  • No {@html} usage anywhere -- all dynamic content is auto-escaped by Svelte. XSS risk is mitigated.
  • Token is passed as a URL query parameter and forwarded to the API as a query param on create-session. This matches the existing pattern from jersey/+page.svelte. The token never touches innerHTML or similar.
  • The category query parameter is interpolated into the fetch URL (/checkout/products?category=${category}). Since this hits a same-origin API and is not rendered as HTML, the frontend risk is low. Backend should validate/whitelist the category value.

UX / Responsiveness

  • Good: loading state with spinner, error banner, disabled button during submission, "Selected" visual state on cards.
  • Good: mobile-friendly layout -- single-column grid, max-width: 700px, padding: 2rem 1rem gives breathing room at 375px.
  • Missing: no empty state if the API returns zero products. The grid will simply be empty with no user feedback.
  • Missing: the checkout-card-featured class is applied based on price_cents > 0 && product_type !== 'opt_out' but there is no CSS rule for .checkout-card-featured -- the class does nothing. The old jersey page had a visual badge for "Best Value"; this page lost that distinction.

Svelte Patterns

  • Correct use of Svelte 5 runes ($state, $page).
  • {@const selectedProduct = ...} inside the template block is idiomatic Svelte 5.
  • goto import is used but goto is only called for opt-out redirect -- appropriate.

Accessibility

  • Product cards use onclick on a div (checkout-card-body) without role="button", tabindex="0", or onkeydown handler. Keyboard-only users cannot select a product by pressing Enter/Space on the card body; they must tab to the button inside. This is a partial mitigation since the button exists, but the card body click area is a dead zone for keyboard users.
  • Form inputs have associated <label> elements with matching for/id -- good.
  • Error banner has no role="alert" or aria-live="assertive", so screen readers may not announce validation errors.
  • Spinner has no aria-label or screen-reader text -- loading state is visually indicated only.

BLOCKERS

1. BUG: checkout_token is never written to sessionStorage

checkout/cancel/+page.svelte line 8 reads:

token = sessionStorage.getItem('checkout_token') || '';

But checkout/+page.svelte never calls sessionStorage.setItem('checkout_token', token). The jersey page (jersey/+page.svelte line 26) has the equivalent sessionStorage.setItem('jersey_token', token) call -- this was missed in the port.

Result: The "Try Again" link on the cancel page will never render. Users who cancel Stripe payment land on a dead-end page with no way back.

Fix: Add sessionStorage.setItem('checkout_token', token) in checkout/+page.svelte's onMount, after the token-null check, mirroring the jersey page pattern.

2. Cancel page hardcodes category=jersey

checkout/cancel/+page.svelte line 23:

<a href="/checkout?token={token}&category=jersey" class="btn btn-primary">Try Again</a>

This page is supposed to be the generic checkout cancel page, but it hardcodes category=jersey. If the checkout flow is used for any other product category in the future, the retry link sends users to the wrong product listing.

Fix: Store the category alongside the token (e.g., sessionStorage.setItem('checkout_category', category)) and read it back in the cancel page.

3. No test coverage

550 lines of new functionality with zero tests. The Test Plan in the PR body has only unchecked manual test items. There are no automated tests anywhere in this repo (no .test.js or .spec.js files outside node_modules).

Per BLOCKER criteria: "New functionality with zero test coverage."

Mitigation note: This repo has no test infrastructure at all (no vitest, no playwright config). This is a systemic gap, not specific to this PR. If the team decision is that westside-app does not require automated tests at this stage, this blocker can be waived by explicit owner approval. But it must be acknowledged.

NITS

  1. Unused import: checkout/cancel/+page.svelte imports page from $app/stores (line 2) but never uses it. Dead import.

  2. Unused CSS class: .btn-secondary is defined in checkout/+page.svelte styles (line 429) but never applied in the template. Dead CSS.

  3. DRY: duplicated submit button block: The submit button markup is duplicated between the {#if selectedProduct?.custom_fields?.length} and {:else} branches (lines 213-225 vs 229-241 in the on-disk file). These are identical blocks. Could extract to a snippet or restructure the conditional so the button is always rendered outside the custom-fields check.

  4. formatPrice does not handle fractional cents: Math.round(cents / 100) will round $89.99 (8999 cents) to $90. If the API ever returns non-round-dollar amounts, this will silently truncate. Consider (cents / 100).toFixed(2) with a dollar sign, or keep Math.round but document the assumption.

  5. No empty-products state: If the API returns an empty array, the page shows the header and an empty grid with no message. Add a "No products available" fallback.

  6. checkout-card-featured class has no CSS: The class is conditionally applied (line 150) but has no corresponding style rule. Either remove the class or add the intended visual treatment.

  7. Error banner accessibility: Add role="alert" to the .checkout-error div so screen readers announce validation errors.

  8. Card keyboard accessibility: The checkout-card-body div has an onclick but no keyboard interaction support. Either add role="button" tabindex="0" onkeydown or remove the div click handler and let the button be the sole interaction target (with visual styling to make this clear).

SOP COMPLIANCE

  • Branch named after issue: 55-feat-generic-checkout-page-dynamic-produ references issue #55
  • PR body has: Summary, Changes, Test Plan, Related sections
  • Related references plan slug: plan-wkq Phase 11
  • No secrets committed -- token is a URL parameter, not hardcoded
  • Tests exist and pass -- no tests (see BLOCKER #3)
  • No unnecessary file changes -- 3 files, all directly related
  • Commit messages are descriptive

PROCESS OBSERVATIONS

  • Deployment frequency: This PR depends on basketball-api #127 (backend). Both must deploy together or the checkout page will show a fetch error on mount. Coordinate deployment order.
  • Change failure risk: The sessionStorage bug (BLOCKER #1) would cause a broken user experience on payment cancellation. This is a real user-facing flow for parents paying for jerseys -- high impact if shipped as-is.
  • DRY concern: The existing jersey/ routes and the new checkout/ routes serve overlapping purposes. Once checkout/ is validated, the old jersey/ routes should be deprecated/removed to avoid maintaining two checkout flows. Worth tracking as a follow-up issue.
  • Test gap: This repo has zero test infrastructure. This is the third checkout-adjacent PR (after #45 and #54). The pattern of shipping user-facing payment flows without tests increases change failure risk over time.

VERDICT: NOT APPROVED

Two functional bugs (sessionStorage never written, hardcoded category) must be fixed before merge. The test coverage blocker is acknowledged as a systemic repo gap -- recommend explicit owner decision on whether to enforce or waive for westside-app.

## PR #56 Review ### DOMAIN REVIEW **Tech stack**: SvelteKit 5 (Svelte runes / `$state`), vanilla CSS, Stripe redirect flow, parent-token auth pattern. **Security** - No `{@html}` usage anywhere -- all dynamic content is auto-escaped by Svelte. XSS risk is mitigated. - Token is passed as a URL query parameter and forwarded to the API as a query param on `create-session`. This matches the existing pattern from `jersey/+page.svelte`. The token never touches `innerHTML` or similar. - The `category` query parameter is interpolated into the fetch URL (`/checkout/products?category=${category}`). Since this hits a same-origin API and is not rendered as HTML, the frontend risk is low. Backend should validate/whitelist the category value. **UX / Responsiveness** - Good: loading state with spinner, error banner, disabled button during submission, "Selected" visual state on cards. - Good: mobile-friendly layout -- single-column grid, `max-width: 700px`, `padding: 2rem 1rem` gives breathing room at 375px. - Missing: no empty state if the API returns zero products. The grid will simply be empty with no user feedback. - Missing: the `checkout-card-featured` class is applied based on `price_cents > 0 && product_type !== 'opt_out'` but there is no CSS rule for `.checkout-card-featured` -- the class does nothing. The old jersey page had a visual badge for "Best Value"; this page lost that distinction. **Svelte Patterns** - Correct use of Svelte 5 runes (`$state`, `$page`). - `{@const selectedProduct = ...}` inside the template block is idiomatic Svelte 5. - `goto` import is used but `goto` is only called for opt-out redirect -- appropriate. **Accessibility** - Product cards use `onclick` on a `div` (`checkout-card-body`) without `role="button"`, `tabindex="0"`, or `onkeydown` handler. Keyboard-only users cannot select a product by pressing Enter/Space on the card body; they must tab to the button inside. This is a partial mitigation since the button exists, but the card body click area is a dead zone for keyboard users. - Form inputs have associated `<label>` elements with matching `for`/`id` -- good. - Error banner has no `role="alert"` or `aria-live="assertive"`, so screen readers may not announce validation errors. - Spinner has no `aria-label` or screen-reader text -- loading state is visually indicated only. ### BLOCKERS **1. BUG: `checkout_token` is never written to sessionStorage** `checkout/cancel/+page.svelte` line 8 reads: ```js token = sessionStorage.getItem('checkout_token') || ''; ``` But `checkout/+page.svelte` never calls `sessionStorage.setItem('checkout_token', token)`. The jersey page (`jersey/+page.svelte` line 26) has the equivalent `sessionStorage.setItem('jersey_token', token)` call -- this was missed in the port. **Result**: The "Try Again" link on the cancel page will never render. Users who cancel Stripe payment land on a dead-end page with no way back. **Fix**: Add `sessionStorage.setItem('checkout_token', token)` in `checkout/+page.svelte`'s `onMount`, after the token-null check, mirroring the jersey page pattern. **2. Cancel page hardcodes `category=jersey`** `checkout/cancel/+page.svelte` line 23: ```svelte <a href="/checkout?token={token}&category=jersey" class="btn btn-primary">Try Again</a> ``` This page is supposed to be the generic checkout cancel page, but it hardcodes `category=jersey`. If the checkout flow is used for any other product category in the future, the retry link sends users to the wrong product listing. **Fix**: Store the category alongside the token (e.g., `sessionStorage.setItem('checkout_category', category)`) and read it back in the cancel page. **3. No test coverage** 550 lines of new functionality with zero tests. The Test Plan in the PR body has only unchecked manual test items. There are no automated tests anywhere in this repo (no `.test.js` or `.spec.js` files outside `node_modules`). Per BLOCKER criteria: "New functionality with zero test coverage." **Mitigation note**: This repo has no test infrastructure at all (no vitest, no playwright config). This is a systemic gap, not specific to this PR. If the team decision is that westside-app does not require automated tests at this stage, this blocker can be waived by explicit owner approval. But it must be acknowledged. ### NITS 1. **Unused import**: `checkout/cancel/+page.svelte` imports `page` from `$app/stores` (line 2) but never uses it. Dead import. 2. **Unused CSS class**: `.btn-secondary` is defined in `checkout/+page.svelte` styles (line 429) but never applied in the template. Dead CSS. 3. **DRY: duplicated submit button block**: The submit button markup is duplicated between the `{#if selectedProduct?.custom_fields?.length}` and `{:else}` branches (lines 213-225 vs 229-241 in the on-disk file). These are identical blocks. Could extract to a snippet or restructure the conditional so the button is always rendered outside the custom-fields check. 4. **`formatPrice` does not handle fractional cents**: `Math.round(cents / 100)` will round $89.99 (8999 cents) to `$90`. If the API ever returns non-round-dollar amounts, this will silently truncate. Consider `(cents / 100).toFixed(2)` with a dollar sign, or keep `Math.round` but document the assumption. 5. **No empty-products state**: If the API returns an empty array, the page shows the header and an empty grid with no message. Add a "No products available" fallback. 6. **`checkout-card-featured` class has no CSS**: The class is conditionally applied (line 150) but has no corresponding style rule. Either remove the class or add the intended visual treatment. 7. **Error banner accessibility**: Add `role="alert"` to the `.checkout-error` div so screen readers announce validation errors. 8. **Card keyboard accessibility**: The `checkout-card-body` div has an `onclick` but no keyboard interaction support. Either add `role="button" tabindex="0" onkeydown` or remove the div click handler and let the button be the sole interaction target (with visual styling to make this clear). ### SOP COMPLIANCE - [x] Branch named after issue: `55-feat-generic-checkout-page-dynamic-produ` references issue #55 - [x] PR body has: Summary, Changes, Test Plan, Related sections - [x] Related references plan slug: `plan-wkq` Phase 11 - [x] No secrets committed -- token is a URL parameter, not hardcoded - [ ] Tests exist and pass -- no tests (see BLOCKER #3) - [x] No unnecessary file changes -- 3 files, all directly related - [x] Commit messages are descriptive ### PROCESS OBSERVATIONS - **Deployment frequency**: This PR depends on basketball-api #127 (backend). Both must deploy together or the checkout page will show a fetch error on mount. Coordinate deployment order. - **Change failure risk**: The sessionStorage bug (BLOCKER #1) would cause a broken user experience on payment cancellation. This is a real user-facing flow for parents paying for jerseys -- high impact if shipped as-is. - **DRY concern**: The existing `jersey/` routes and the new `checkout/` routes serve overlapping purposes. Once `checkout/` is validated, the old `jersey/` routes should be deprecated/removed to avoid maintaining two checkout flows. Worth tracking as a follow-up issue. - **Test gap**: This repo has zero test infrastructure. This is the third checkout-adjacent PR (after #45 and #54). The pattern of shipping user-facing payment flows without tests increases change failure risk over time. ### VERDICT: NOT APPROVED Two functional bugs (sessionStorage never written, hardcoded category) must be fixed before merge. The test coverage blocker is acknowledged as a systemic repo gap -- recommend explicit owner decision on whether to enforce or waive for westside-app.
- Store checkout_token and checkout_category in sessionStorage on mount
- Cancel page reads both from sessionStorage instead of hardcoding jersey
- Remove dead import (page store in cancel)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fix: CI pipeline + a11y compliance across all forms
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
d89c655aac
- Fix Woodpecker clone: use git init + fetch SHA instead of shallow
  branch clone (fixes PR event checkout failures)
- Add pull_request to CI when events
- Fix all a11y_label_has_associated_control warnings: add for/id
  pairs to register, player profile, billing forms
- Use span instead of label for non-input elements (photo upload,
  Stripe mock fields, radio groups with aria-labelledby)
- Fix implicit any types in coach page flatMap

svelte-check: 0 errors (was 2), 7 warnings (was 25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

PR #56 Re-Review

DOMAIN REVIEW

Stack: SvelteKit (Svelte 5 runes), vanilla CSS, Woodpecker CI, Stripe integration.

Previous blockers -- verification:

  1. sessionStorage key mismatch -- RESOLVED. src/routes/checkout/+page.svelte lines 36-37 now write checkout_token and checkout_category to sessionStorage. src/routes/checkout/cancel/+page.svelte lines 8-9 read those exact keys. No mismatch.

  2. Hardcoded category=jersey in cancel retry link -- RESOLVED. Cancel page reads checkout_category from sessionStorage (line 9, defaulting to 'jersey') and passes the dynamic {category} variable in the retry link (line 24).

Woodpecker CI fixes:

  • Clone step correctly uses git init + git remote add + git fetch ${CI_COMMIT_SHA} --depth 1 + git checkout ${CI_COMMIT_SHA} -- this handles both branch pushes and PR merge commits where CI_COMMIT_BRANCH may not exist.
  • pull_request event added to the when clause, and the validate step's when includes pull_request in its event list. Correct.

Accessibility fixes:

  • src/routes/register/+page.svelte: All <label> elements now have for attributes matching id on their inputs. Radio groups correctly use <span> + role="radiogroup" + aria-labelledby pattern for both age gate and payment method. Photo upload label correctly changed to <span> since the existing <label for="photo"> below handles the association.
  • src/routes/players/[id]/+page.svelte: Contract signature label now has for="contract-sig" matching id="contract-sig" on the input.
  • src/routes/players/[id]/billing/+page.svelte: Stripe element mock labels correctly changed from <label> to <span> -- these are not real inputs, so <label> without a for attribute was generating warnings.
  • src/routes/coach/+page.svelte: flatMap/map callbacks now have /** @type {any} */ JSDoc annotations, fixing implicit-any warnings.

All changes are clean and correct.

Checkout page architecture review:

  • Token passed via query param and stored in sessionStorage -- appropriate for Stripe's redirect flow.
  • Products fetched from API by category -- no hardcoded product data.
  • Custom fields rendered dynamically based on product's custom_fields JSON -- good extensibility.
  • Required field validation runs before submit.
  • Error handling covers fetch failures, non-OK responses, and JSON parse failures.
  • Opt-out products skip Stripe redirect and go directly to success page.
  • formatPrice uses Math.round(cents / 100) -- works for whole-dollar amounts (which these are).

BLOCKERS

None. Both previously identified blockers are resolved.

Note on test coverage: This repo has no test framework (package.json has no test script, no test files exist). CI validation is svelte-check + npm run build. Given this is a parent-facing checkout page with Stripe integration, adding a test framework (Vitest + Testing Library) is strongly recommended but is out of scope for this PR and should be tracked separately.

NITS

  1. Checkout page product cards use onclick on a <div> (line 156): The checkout-card-body div has onclick but no role="button", tabindex="0", or keyboard handler. Screen reader and keyboard-only users cannot select products via the card body. The <button> below it works for keyboard, but the card body click target is inaccessible. Consider adding role="button" tabindex="0" onkeydown or making the entire card a button.

  2. Token in query string is logged in server access logs: The checkout token appears in the URL (?token=...). This is standard for email-based magic links and Stripe's own pattern, but worth noting for log-scrubbing awareness.

  3. formatPrice truncates fractional dollars: Math.round(cents / 100) drops cents. If a product ever costs $12.50 (1250 cents), it would display as $13. Consider (cents / 100).toFixed(2) if fractional prices are possible.

  4. .btn-secondary defined in checkout page CSS but unused: Line 433-435 of +page.svelte defines .btn-secondary but it is never applied in the template. Dead CSS.

  5. Success page has no navigation: After checkout, the success page shows a message but no link back to the main site. Users are left on a dead-end page.

SOP COMPLIANCE

  • Branch named after issue (55-feat-generic-checkout-page-dynamic-produ references issue #55)
  • PR body has Summary, Changes, Test Plan, Related sections
  • Related section references plan slug (plan-wkq)
  • No secrets committed
  • No unnecessary file changes -- a11y and CI fixes are directly related to making this PR pass svelte-check
  • Commit messages are descriptive

PROCESS OBSERVATIONS

  • Deployment frequency: This PR is blocked on basketball-api #127 deploying first (backend endpoints). Manual test plan items are unchecked pending that dependency. Consider adding a note to the issue when the dependency is met so this can be manually validated promptly.
  • Change failure risk: Low. The checkout flow is a new route with no impact on existing pages. The a11y and CI fixes touch existing pages but are purely additive (for/id attributes, <label> to <span> changes).
  • Test gap: No test framework in this repo. This is the second PR adding significant user-facing functionality without automated tests. Recommend tracking test framework setup as a separate issue.

VERDICT: APPROVED

## PR #56 Re-Review ### DOMAIN REVIEW **Stack**: SvelteKit (Svelte 5 runes), vanilla CSS, Woodpecker CI, Stripe integration. **Previous blockers -- verification:** 1. **sessionStorage key mismatch -- RESOLVED.** `src/routes/checkout/+page.svelte` lines 36-37 now write `checkout_token` and `checkout_category` to sessionStorage. `src/routes/checkout/cancel/+page.svelte` lines 8-9 read those exact keys. No mismatch. 2. **Hardcoded `category=jersey` in cancel retry link -- RESOLVED.** Cancel page reads `checkout_category` from sessionStorage (line 9, defaulting to `'jersey'`) and passes the dynamic `{category}` variable in the retry link (line 24). **Woodpecker CI fixes:** - Clone step correctly uses `git init` + `git remote add` + `git fetch ${CI_COMMIT_SHA} --depth 1` + `git checkout ${CI_COMMIT_SHA}` -- this handles both branch pushes and PR merge commits where `CI_COMMIT_BRANCH` may not exist. - `pull_request` event added to the `when` clause, and the validate step's `when` includes `pull_request` in its event list. Correct. **Accessibility fixes:** - `src/routes/register/+page.svelte`: All `<label>` elements now have `for` attributes matching `id` on their inputs. Radio groups correctly use `<span>` + `role="radiogroup"` + `aria-labelledby` pattern for both age gate and payment method. Photo upload label correctly changed to `<span>` since the existing `<label for="photo">` below handles the association. - `src/routes/players/[id]/+page.svelte`: Contract signature label now has `for="contract-sig"` matching `id="contract-sig"` on the input. - `src/routes/players/[id]/billing/+page.svelte`: Stripe element mock labels correctly changed from `<label>` to `<span>` -- these are not real inputs, so `<label>` without a `for` attribute was generating warnings. - `src/routes/coach/+page.svelte`: `flatMap`/`map` callbacks now have `/** @type {any} */` JSDoc annotations, fixing implicit-any warnings. All changes are clean and correct. **Checkout page architecture review:** - Token passed via query param and stored in sessionStorage -- appropriate for Stripe's redirect flow. - Products fetched from API by category -- no hardcoded product data. - Custom fields rendered dynamically based on product's `custom_fields` JSON -- good extensibility. - Required field validation runs before submit. - Error handling covers fetch failures, non-OK responses, and JSON parse failures. - Opt-out products skip Stripe redirect and go directly to success page. - `formatPrice` uses `Math.round(cents / 100)` -- works for whole-dollar amounts (which these are). ### BLOCKERS None. Both previously identified blockers are resolved. **Note on test coverage:** This repo has no test framework (`package.json` has no `test` script, no test files exist). CI validation is `svelte-check` + `npm run build`. Given this is a parent-facing checkout page with Stripe integration, adding a test framework (Vitest + Testing Library) is strongly recommended but is out of scope for this PR and should be tracked separately. ### NITS 1. **Checkout page product cards use `onclick` on a `<div>` (line 156)**: The `checkout-card-body` div has `onclick` but no `role="button"`, `tabindex="0"`, or keyboard handler. Screen reader and keyboard-only users cannot select products via the card body. The `<button>` below it works for keyboard, but the card body click target is inaccessible. Consider adding `role="button" tabindex="0" onkeydown` or making the entire card a button. 2. **Token in query string is logged in server access logs**: The checkout token appears in the URL (`?token=...`). This is standard for email-based magic links and Stripe's own pattern, but worth noting for log-scrubbing awareness. 3. **`formatPrice` truncates fractional dollars**: `Math.round(cents / 100)` drops cents. If a product ever costs $12.50 (1250 cents), it would display as `$13`. Consider `(cents / 100).toFixed(2)` if fractional prices are possible. 4. **`.btn-secondary` defined in checkout page CSS but unused**: Line 433-435 of `+page.svelte` defines `.btn-secondary` but it is never applied in the template. Dead CSS. 5. **Success page has no navigation**: After checkout, the success page shows a message but no link back to the main site. Users are left on a dead-end page. ### SOP COMPLIANCE - [x] Branch named after issue (`55-feat-generic-checkout-page-dynamic-produ` references issue #55) - [x] PR body has Summary, Changes, Test Plan, Related sections - [x] Related section references plan slug (`plan-wkq`) - [x] No secrets committed - [x] No unnecessary file changes -- a11y and CI fixes are directly related to making this PR pass `svelte-check` - [x] Commit messages are descriptive ### PROCESS OBSERVATIONS - **Deployment frequency**: This PR is blocked on basketball-api #127 deploying first (backend endpoints). Manual test plan items are unchecked pending that dependency. Consider adding a note to the issue when the dependency is met so this can be manually validated promptly. - **Change failure risk**: Low. The checkout flow is a new route with no impact on existing pages. The a11y and CI fixes touch existing pages but are purely additive (`for`/`id` attributes, `<label>` to `<span>` changes). - **Test gap**: No test framework in this repo. This is the second PR adding significant user-facing functionality without automated tests. Recommend tracking test framework setup as a separate issue. ### VERDICT: APPROVED
Author
Owner

PR #56 Review

DOMAIN REVIEW

Tech stack: SvelteKit 5 (Svelte 5 runes), static adapter, Keycloak auth, Stripe checkout integration, Woodpecker CI. Applying SvelteKit/accessibility/security/CI checklists.

Checkout page (src/routes/checkout/+page.svelte)

  1. Query parameter injection in API URL (line 40): The category value is read from the URL query string and interpolated directly into the API URL without validation or encoding:

    const res = await fetch(`${API_BASE}/checkout/products?category=${category}`);
    

    A crafted URL like ?category=jersey%26admin=true would inject additional query parameters into the backend request. Use encodeURIComponent(category) or validate against an allowlist of known categories.

  2. formatPrice truncates fractional dollars (line 117): Math.round(cents / 100) drops cents -- 9950 cents renders as $100 instead of $99.50. Use (cents / 100).toFixed(2) or at minimum .toLocaleString() with currency formatting. This is a correctness bug for any non-round-dollar product.

  3. Checkout card onclick div is not keyboard-accessible (line 156): The .checkout-card-body div has onclick but no role="button", tabindex="0", or onkeydown handler. Keyboard-only users cannot select a product by clicking the card body. The button in the footer IS accessible, so this is degraded but not broken -- keyboard users can still reach the "Select" button.

  4. Error banner missing role="alert" (line 145): The .checkout-error div renders dynamic error text but lacks role="alert" or aria-live="assertive", so screen readers won't announce errors when they appear.

  5. DRY: submit button duplicated (lines 217-229 and 233-245): The submit button with its conditional text (submitting spinner / price / confirm) is copy-pasted between the custom_fields branch and the no-fields branch. Extract to a {@const} block or a shared snippet.

  6. outline: none on focus (line 413): .checkout-input:focus { outline: none; } removes the default focus indicator. The border-color change to #d42026 on #0a0a0a background provides 2.7:1 contrast which does not meet WCAG 2.1 AA for non-text elements (3:1 minimum). Consider adding a visible focus ring or increasing the border width on focus.

  7. Unused CSS class: .btn-secondary is defined but never used in the checkout page template.

Success page (src/routes/checkout/success/+page.svelte): Clean and minimal. No issues.

Cancel page (src/routes/checkout/cancel/+page.svelte): Token retrieved from sessionStorage for the retry link is appropriate. The encodeURIComponent concern applies here too -- the token and category are interpolated into the href without encoding, though since the token originated from a URL param and was stored as-is, the risk is low.

CI config (.woodpecker.yaml): The clone fix (git init + git fetch by SHA) is the correct pattern for PR events where CI_COMMIT_BRANCH may not resolve. The event: pull_request trigger addition is necessary for PR validation. Both changes are correct.

a11y fixes (register, players/[id], players/[id]/billing): All correct. for/id associations properly wired. <label> correctly changed to <span> for elements without a single associated input (Card Number, Expiration, CVC mocks; Player Photo; radio groups). role="radiogroup" + aria-labelledby correctly added to age gate and payment method radio groups.

TypeScript fix (coach/+page.svelte): JSDoc @type {any} annotations on flatMap/map callbacks are correct for untyped API responses.

BLOCKERS

  1. category query param not encoded before API interpolation -- src/routes/checkout/+page.svelte line 40. User-controlled input (category from URL) is concatenated directly into a fetch URL. While the backend likely validates this, the frontend should either encodeURIComponent(category) or validate against an allowlist (['jersey', ...]). This is unvalidated user input flowing into an HTTP request.

  2. formatPrice correctness bug -- src/routes/checkout/+page.svelte line 117. Any product with a non-round-dollar price (e.g., $99.50 = 9950 cents) will display the wrong amount. For a payment page, displaying incorrect prices is a blocker.

NITS

  1. Keyboard accessibility on card body click area: Add role="button" tabindex="0" and an onkeydown handler (Enter/Space) to .checkout-card-body, or remove the div click and rely solely on the button.

  2. Error role="alert": Add role="alert" to the .checkout-error div so screen readers announce validation errors.

  3. DRY the submit button: The identical submit button block appears twice. Factor it out.

  4. Focus visibility: Replace outline: none with a visible focus indicator that meets 3:1 contrast. Consider outline: 2px solid #d42026; outline-offset: 2px; instead.

  5. Unused .btn-secondary CSS rule: Dead code in the checkout page styles.

  6. encodeURIComponent on cancel page retry link: The token/category values in the href on the cancel page should be encoded for defense-in-depth.

SOP COMPLIANCE

  • Branch named after issue (55-feat-generic-checkout-page-dynamic-produ references issue #55)
  • PR body has Summary, Changes, Test Plan, Related sections
  • Related section references plan slug (plan-wkq)
  • No secrets committed
  • No unnecessary file changes -- a11y fixes and TS fixes are directly relevant cleanup
  • Commit messages are descriptive
  • Tests exist -- No test framework exists in this repo at all (no vitest, no playwright, no test directory). The Test Plan lists only npm run build passing and manual testing. For a payment checkout page, this is a significant gap, though it is a repo-wide gap, not unique to this PR.

PROCESS OBSERVATIONS

  • Change failure risk: MEDIUM-HIGH. This PR introduces a Stripe payment flow for parents. The formatPrice bug means prices could display incorrectly, which directly impacts trust and potentially legal compliance for consumer-facing payment pages. The category injection is lower risk since the backend likely validates, but defense-in-depth is standard for payment flows.
  • Test coverage gap is repo-wide. This PR should not be blocked for lacking tests when the entire repo has no test infrastructure. However, a follow-up issue to add vitest + basic component tests (especially for the checkout flow) should be tracked. Payment pages are the highest-risk surface to leave untested.
  • Manual test plan is incomplete: Several items are unchecked, indicating the PR was submitted before manual validation. The PR depends on basketball-api #127 which may not be deployed yet, so this is potentially expected.

VERDICT: NOT APPROVED

Two blockers must be fixed:

  1. Encode/validate category before API URL interpolation
  2. Fix formatPrice to handle fractional dollar amounts correctly
## PR #56 Review ### DOMAIN REVIEW **Tech stack**: SvelteKit 5 (Svelte 5 runes), static adapter, Keycloak auth, Stripe checkout integration, Woodpecker CI. Applying SvelteKit/accessibility/security/CI checklists. **Checkout page (`src/routes/checkout/+page.svelte`)** 1. **Query parameter injection in API URL (line 40)**: The `category` value is read from the URL query string and interpolated directly into the API URL without validation or encoding: ```js const res = await fetch(`${API_BASE}/checkout/products?category=${category}`); ``` A crafted URL like `?category=jersey%26admin=true` would inject additional query parameters into the backend request. Use `encodeURIComponent(category)` or validate against an allowlist of known categories. 2. **`formatPrice` truncates fractional dollars (line 117)**: `Math.round(cents / 100)` drops cents -- 9950 cents renders as `$100` instead of `$99.50`. Use `(cents / 100).toFixed(2)` or at minimum `.toLocaleString()` with currency formatting. This is a correctness bug for any non-round-dollar product. 3. **Checkout card `onclick` div is not keyboard-accessible (line 156)**: The `.checkout-card-body` div has `onclick` but no `role="button"`, `tabindex="0"`, or `onkeydown` handler. Keyboard-only users cannot select a product by clicking the card body. The button in the footer IS accessible, so this is degraded but not broken -- keyboard users can still reach the "Select" button. 4. **Error banner missing `role="alert"` (line 145)**: The `.checkout-error` div renders dynamic error text but lacks `role="alert"` or `aria-live="assertive"`, so screen readers won't announce errors when they appear. 5. **DRY: submit button duplicated (lines 217-229 and 233-245)**: The submit button with its conditional text (submitting spinner / price / confirm) is copy-pasted between the `custom_fields` branch and the no-fields branch. Extract to a `{@const}` block or a shared snippet. 6. **`outline: none` on focus (line 413)**: `.checkout-input:focus { outline: none; }` removes the default focus indicator. The `border-color` change to `#d42026` on `#0a0a0a` background provides 2.7:1 contrast which does not meet WCAG 2.1 AA for non-text elements (3:1 minimum). Consider adding a visible focus ring or increasing the border width on focus. 7. **Unused CSS class**: `.btn-secondary` is defined but never used in the checkout page template. **Success page (`src/routes/checkout/success/+page.svelte`)**: Clean and minimal. No issues. **Cancel page (`src/routes/checkout/cancel/+page.svelte`)**: Token retrieved from `sessionStorage` for the retry link is appropriate. The `encodeURIComponent` concern applies here too -- the token and category are interpolated into the `href` without encoding, though since the token originated from a URL param and was stored as-is, the risk is low. **CI config (`.woodpecker.yaml`)**: The clone fix (`git init` + `git fetch` by SHA) is the correct pattern for PR events where `CI_COMMIT_BRANCH` may not resolve. The `event: pull_request` trigger addition is necessary for PR validation. Both changes are correct. **a11y fixes (`register`, `players/[id]`, `players/[id]/billing`)**: All correct. `for`/`id` associations properly wired. `<label>` correctly changed to `<span>` for elements without a single associated input (Card Number, Expiration, CVC mocks; Player Photo; radio groups). `role="radiogroup"` + `aria-labelledby` correctly added to age gate and payment method radio groups. **TypeScript fix (`coach/+page.svelte`)**: JSDoc `@type {any}` annotations on `flatMap`/`map` callbacks are correct for untyped API responses. ### BLOCKERS 1. **`category` query param not encoded before API interpolation** -- `src/routes/checkout/+page.svelte` line 40. User-controlled input (`category` from URL) is concatenated directly into a fetch URL. While the backend likely validates this, the frontend should either `encodeURIComponent(category)` or validate against an allowlist (`['jersey', ...]`). This is unvalidated user input flowing into an HTTP request. 2. **`formatPrice` correctness bug** -- `src/routes/checkout/+page.svelte` line 117. Any product with a non-round-dollar price (e.g., $99.50 = 9950 cents) will display the wrong amount. For a payment page, displaying incorrect prices is a blocker. ### NITS 1. **Keyboard accessibility on card body click area**: Add `role="button" tabindex="0"` and an `onkeydown` handler (Enter/Space) to `.checkout-card-body`, or remove the div click and rely solely on the button. 2. **Error `role="alert"`**: Add `role="alert"` to the `.checkout-error` div so screen readers announce validation errors. 3. **DRY the submit button**: The identical submit button block appears twice. Factor it out. 4. **Focus visibility**: Replace `outline: none` with a visible focus indicator that meets 3:1 contrast. Consider `outline: 2px solid #d42026; outline-offset: 2px;` instead. 5. **Unused `.btn-secondary` CSS rule**: Dead code in the checkout page styles. 6. **`encodeURIComponent` on cancel page retry link**: The token/category values in the `href` on the cancel page should be encoded for defense-in-depth. ### SOP COMPLIANCE - [x] Branch named after issue (`55-feat-generic-checkout-page-dynamic-produ` references issue #55) - [x] PR body has Summary, Changes, Test Plan, Related sections - [x] Related section references plan slug (`plan-wkq`) - [x] No secrets committed - [x] No unnecessary file changes -- a11y fixes and TS fixes are directly relevant cleanup - [x] Commit messages are descriptive - [ ] Tests exist -- **No test framework exists in this repo at all** (no vitest, no playwright, no test directory). The Test Plan lists only `npm run build` passing and manual testing. For a payment checkout page, this is a significant gap, though it is a repo-wide gap, not unique to this PR. ### PROCESS OBSERVATIONS - **Change failure risk: MEDIUM-HIGH**. This PR introduces a Stripe payment flow for parents. The `formatPrice` bug means prices could display incorrectly, which directly impacts trust and potentially legal compliance for consumer-facing payment pages. The `category` injection is lower risk since the backend likely validates, but defense-in-depth is standard for payment flows. - **Test coverage gap is repo-wide**. This PR should not be blocked for lacking tests when the entire repo has no test infrastructure. However, a follow-up issue to add vitest + basic component tests (especially for the checkout flow) should be tracked. Payment pages are the highest-risk surface to leave untested. - **Manual test plan is incomplete**: Several items are unchecked, indicating the PR was submitted before manual validation. The PR depends on basketball-api #127 which may not be deployed yet, so this is potentially expected. ### VERDICT: NOT APPROVED Two blockers must be fixed: 1. Encode/validate `category` before API URL interpolation 2. Fix `formatPrice` to handle fractional dollar amounts correctly
fix: encode category param + correct fractional price display
All checks were successful
ci/woodpecker/pr/woodpecker Pipeline was successful
8f445482e1
- encodeURIComponent on category query param to prevent injection
- formatPrice handles fractional cents correctly (9950 → $99.50)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

PR #56 Review

DOMAIN REVIEW

Tech stack: SvelteKit 5 (Svelte 5 runes), vanilla CSS, Keycloak auth, Stripe checkout integration.

Previous blockers resolved -- verification:

  1. Unvalidated category parameter -- FIXED. Line 40: encodeURIComponent(category || 'jersey') properly sanitizes the query parameter before URL interpolation. Prevents injection of arbitrary query strings or URL manipulation.

  2. formatPrice truncation bug -- FIXED. Lines 117-121: Division-based approach (cents / 100) with modulo check correctly handles all cases:

    • 9950 -> 99.5 -> $99.50 (fractional cents: .toFixed(2))
    • 9000 -> 90 -> $90 (whole dollar: no trailing zeros)
    • 0 -> Free

Checkout page (+page.svelte):

  • Token validation gates the entire flow (line 30-33) -- good.
  • Error handling with try/catch/finally on product fetch -- good.
  • Required field validation before submit -- good.
  • Stripe redirect vs opt-out branching is clear (line 102-106).
  • submitting state prevents double-submit -- good.
  • Product fetch uses public endpoint (no auth header needed for catalog) while create-session passes token as query param -- consistent with the API design.

Cancel page (cancel/+page.svelte):

  • Reconstructs retry link from sessionStorage -- functional approach.
  • Note: the "Try Again" href at line 24 interpolates token and category directly into the URL without encoding. Since token comes from the API (UUID/JWT format) and category was previously stored from a URL param, this is low risk but inconsistent with the encodeURIComponent fix on the main page. Not a blocker since these values originate from controlled sources.

Success page (success/+page.svelte):

  • Simple confirmation. No dynamic data. Clean.

Accessibility fixes (register, billing, player detail pages):

  • Proper for/id label-input pairing added across 15+ form fields.
  • <label> changed to <span> for non-input elements (Stripe mock fields, radio groups) -- correct semantic HTML.
  • role="radiogroup" and aria-labelledby added to radio button groups -- good ARIA patterns.

CI changes (.woodpecker.yaml):

  • Added event: pull_request trigger -- enables CI on PRs, good practice.
  • Clone step updated from git clone --branch to git init + git fetch SHA -- fixes PR checkout where CI_COMMIT_BRANCH may not exist on the remote. This matches the pattern from pal-e-platform PR #118.

BLOCKERS

None. Both previously identified blockers are resolved.

Regarding test coverage: this repo has zero test infrastructure (no vitest, no test runner, no test files). The package.json has no test script. This is a pre-existing repo-wide gap, not introduced by this PR. The PR's Test Plan appropriately identifies manual testing steps. Blocking a checkout page PR for the absence of a test framework the project has never had would be scope creep -- but this gap should be tracked as a separate issue.

NITS

  1. Cancel page token encoding: The retry link at cancel/+page.svelte:24 does not encodeURIComponent the token or category values in the href. Low risk since both come from controlled sources, but worth making consistent with the main page's approach.

  2. Hardcoded logo URL: checkout/+page.svelte:143 hardcodes the MinIO logo URL. Other pages likely do the same, so this is a pre-existing pattern, but a shared constant or config would be cleaner.

  3. .btn-secondary defined but unused: Line 435-438 in the checkout page style block defines .btn-secondary but it is not used in the template. Dead CSS.

  4. checkout-card-featured class: Applied conditionally at line 157 but has no corresponding CSS rule in the style block. The class is set but has no visual effect.

SOP COMPLIANCE

  • Branch named after issue (55-feat-generic-checkout-page-dynamic-produ -> issue #55)
  • PR body has Summary, Changes, Test Plan, Related sections
  • Related references plan slug (plan-wkq)
  • No secrets committed (API_BASE uses public Tailscale hostname, no keys/tokens in code)
  • Commit messages are descriptive
  • No unnecessary file changes -- accessibility fixes and CI fix are legitimate scope additions

PROCESS OBSERVATIONS

  • Test infrastructure gap: westside-app has no test framework. This is a DORA Change Failure Rate risk. Recommend creating an issue to add vitest + basic component tests.
  • CI on PRs: The .woodpecker.yaml change to trigger on pull_request events is a solid improvement for deployment frequency -- PRs now get validated before merge.
  • Manual test dependency: Several Test Plan items depend on basketball-api #127 deploying first. The PR correctly documents this dependency.

VERDICT: APPROVED

## PR #56 Review ### DOMAIN REVIEW **Tech stack**: SvelteKit 5 (Svelte 5 runes), vanilla CSS, Keycloak auth, Stripe checkout integration. **Previous blockers resolved -- verification:** 1. **Unvalidated category parameter** -- FIXED. Line 40: `encodeURIComponent(category || 'jersey')` properly sanitizes the query parameter before URL interpolation. Prevents injection of arbitrary query strings or URL manipulation. 2. **formatPrice truncation bug** -- FIXED. Lines 117-121: Division-based approach (`cents / 100`) with modulo check correctly handles all cases: - `9950` -> `99.5` -> `$99.50` (fractional cents: `.toFixed(2)`) - `9000` -> `90` -> `$90` (whole dollar: no trailing zeros) - `0` -> `Free` **Checkout page (`+page.svelte`):** - Token validation gates the entire flow (line 30-33) -- good. - Error handling with try/catch/finally on product fetch -- good. - Required field validation before submit -- good. - Stripe redirect vs opt-out branching is clear (line 102-106). - `submitting` state prevents double-submit -- good. - Product fetch uses public endpoint (no auth header needed for catalog) while `create-session` passes token as query param -- consistent with the API design. **Cancel page (`cancel/+page.svelte`):** - Reconstructs retry link from sessionStorage -- functional approach. - Note: the "Try Again" href at line 24 interpolates `token` and `category` directly into the URL without encoding. Since `token` comes from the API (UUID/JWT format) and `category` was previously stored from a URL param, this is low risk but inconsistent with the `encodeURIComponent` fix on the main page. Not a blocker since these values originate from controlled sources. **Success page (`success/+page.svelte`):** - Simple confirmation. No dynamic data. Clean. **Accessibility fixes (register, billing, player detail pages):** - Proper `for`/`id` label-input pairing added across 15+ form fields. - `<label>` changed to `<span>` for non-input elements (Stripe mock fields, radio groups) -- correct semantic HTML. - `role="radiogroup"` and `aria-labelledby` added to radio button groups -- good ARIA patterns. **CI changes (`.woodpecker.yaml`):** - Added `event: pull_request` trigger -- enables CI on PRs, good practice. - Clone step updated from `git clone --branch` to `git init + git fetch SHA` -- fixes PR checkout where `CI_COMMIT_BRANCH` may not exist on the remote. This matches the pattern from pal-e-platform PR #118. ### BLOCKERS **None.** Both previously identified blockers are resolved. Regarding test coverage: this repo has **zero test infrastructure** (no vitest, no test runner, no test files). The `package.json` has no test script. This is a pre-existing repo-wide gap, not introduced by this PR. The PR's Test Plan appropriately identifies manual testing steps. Blocking a checkout page PR for the absence of a test framework the project has never had would be scope creep -- but this gap should be tracked as a separate issue. ### NITS 1. **Cancel page token encoding**: The retry link at `cancel/+page.svelte:24` does not `encodeURIComponent` the `token` or `category` values in the href. Low risk since both come from controlled sources, but worth making consistent with the main page's approach. 2. **Hardcoded logo URL**: `checkout/+page.svelte:143` hardcodes the MinIO logo URL. Other pages likely do the same, so this is a pre-existing pattern, but a shared constant or config would be cleaner. 3. **`.btn-secondary` defined but unused**: Line 435-438 in the checkout page style block defines `.btn-secondary` but it is not used in the template. Dead CSS. 4. **`checkout-card-featured` class**: Applied conditionally at line 157 but has no corresponding CSS rule in the style block. The class is set but has no visual effect. ### SOP COMPLIANCE - [x] Branch named after issue (`55-feat-generic-checkout-page-dynamic-produ` -> issue #55) - [x] PR body has Summary, Changes, Test Plan, Related sections - [x] Related references plan slug (`plan-wkq`) - [x] No secrets committed (API_BASE uses public Tailscale hostname, no keys/tokens in code) - [x] Commit messages are descriptive - [x] No unnecessary file changes -- accessibility fixes and CI fix are legitimate scope additions ### PROCESS OBSERVATIONS - **Test infrastructure gap**: westside-app has no test framework. This is a DORA Change Failure Rate risk. Recommend creating an issue to add vitest + basic component tests. - **CI on PRs**: The `.woodpecker.yaml` change to trigger on `pull_request` events is a solid improvement for deployment frequency -- PRs now get validated before merge. - **Manual test dependency**: Several Test Plan items depend on basketball-api #127 deploying first. The PR correctly documents this dependency. ### VERDICT: APPROVED
forgejo_admin deleted branch 55-feat-generic-checkout-page-dynamic-produ 2026-03-20 08:05:40 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
forgejo_admin/westside-app!56
No description provided.