create-nymbal-app 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/index.js +7 -8
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/scaffold/templates/astro/package.json +25 -26
  5. package/scaffold/templates/astro/src/components/Header.astro +7 -1
  6. package/scaffold/templates/astro/src/components/StockBadge.astro +5 -22
  7. package/scaffold/templates/astro/src/lib/api.ts +18 -4
  8. package/scaffold/templates/astro/src/lib/client.ts +2 -0
  9. package/scaffold/templates/astro/src/pages/category/[slug].astro +26 -7
  10. package/scaffold/templates/astro/src/pages/checkout.astro +12 -4
  11. package/scaffold/templates/astro/src/pages/product/[slug].astro +4 -1
  12. package/scaffold/templates/astro/src/pages/products/index.astro +26 -7
  13. package/scaffold/templates/astro/src/pages/search.astro +11 -5
  14. package/scaffold/templates/astro/src/scripts/cart-page.ts +10 -35
  15. package/scaffold/templates/astro/src/scripts/checkout.ts +48 -49
  16. package/scaffold/templates/astro/src/scripts/pdp.ts +35 -44
  17. package/scaffold/templates/astro/src/scripts/plp.ts +6 -5
  18. package/scaffold/templates/astro/src/scripts/search.ts +32 -8
  19. package/scaffold/templates/astro/src/styles/pages/cart.css +0 -28
  20. package/scaffold/templates/astro/src/styles/pages/pdp.css +6 -2
  21. package/scaffold/templates/astro/src/styles/pages/search.css +19 -0
  22. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +7 -4
  23. package/scaffold/templates/nextjs/app/checkout/page.tsx +9 -2
  24. package/scaffold/templates/nextjs/app/products/page.tsx +7 -7
  25. package/scaffold/templates/nextjs/components/client/CartPage.tsx +1 -0
  26. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +50 -91
  27. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +39 -50
  28. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +41 -20
  29. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +87 -47
  30. package/scaffold/templates/nextjs/components/server/Header.tsx +9 -4
  31. package/scaffold/templates/nextjs/lib/api.ts +24 -4
  32. package/scaffold/templates/nextjs/lib/client.ts +3 -0
  33. package/scaffold/templates/nextjs/package.json +26 -28
  34. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +6 -2
  35. package/scaffold/templates/nextjs/styles/pages/plp.module.css +3 -2
  36. package/scaffold/templates/nextjs/styles/pages/search.module.css +24 -0
  37. package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
  38. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
  39. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +0 -19
@@ -1,3 +1,5 @@
1
+ import type { PaymentConfirmContext, PaymentConfirmation } from '@nymbalhq/sdk'
2
+ import type { NymbalPaymentElement } from '@nymbalhq/web-components'
1
3
  import { client } from '../lib/client.js'
2
4
  import { formatPrice } from '../lib/format.js'
3
5
  import { submitOnce } from '../lib/submit-once.js'
@@ -152,60 +154,55 @@ shippingForm?.addEventListener('submit', (e) => {
152
154
  showStep('payment')
153
155
  })
154
156
 
155
- // Stripe setup. The env says only whether a Stripe client exists; the payment flow follows the server's answer.
156
- let stripe: any = null
157
- let cardElement: any = null
158
-
159
- const stripeKey = import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY
160
- const isStubPayments =
161
- import.meta.env.PUBLIC_NYMBAL_PAYMENTS_PROVIDER === 'native-stub' || !stripeKey
157
+ // The payment step is the store's: the element renders the payments adapter's client half through its driver.
158
+ const paymentForm = document.getElementById('payment-form') as HTMLFormElement | null
159
+ const paymentEl = paymentForm?.querySelector<NymbalPaymentElement>('nymbal-payment-element') ?? null
160
+ const placeOrderBtn = paymentForm?.querySelector<HTMLButtonElement>('[data-testid="place-order"]')
161
+ const paymentBlocked = () => paymentEl?.status === 'error'
162
162
 
163
- async function mountStripe() {
164
- if (isStubPayments) {
165
- const cardContainer = document.getElementById('stripe-card-element')
166
- if (cardContainer) {
167
- cardContainer.innerHTML =
168
- '<p style="color: var(--nymbal-color-text-muted, #71717a); font-size: 0.875rem; margin: 0;">Test mode — payment skipped</p>'
169
- }
170
- return
171
- }
172
- try {
173
- const { loadStripe } = await import('@stripe/stripe-js')
174
- stripe = await loadStripe(stripeKey)
175
- if (!stripe) return
163
+ // While the element cannot take a payment (an unknown driver, a form that failed to load), no order is placed.
164
+ let heldByPayment = false
165
+ function syncPlaceOrder() {
166
+ if (!placeOrderBtn) return
167
+ const blocked = paymentBlocked()
168
+ if (blocked) placeOrderBtn.disabled = true
169
+ else if (heldByPayment) placeOrderBtn.disabled = false
170
+ heldByPayment = blocked
171
+ }
172
+ paymentEl?.addEventListener('nymbal:payment:status', syncPlaceOrder)
173
+ syncPlaceOrder()
174
+ paymentForm?.addEventListener(
175
+ 'submit',
176
+ (e) => {
177
+ if (!paymentBlocked()) return
178
+ e.preventDefault()
179
+ e.stopImmediatePropagation()
180
+ },
181
+ true,
182
+ )
176
183
 
177
- const elements = stripe.elements()
178
- cardElement = elements.create('card', {
179
- style: {
180
- base: {
181
- fontSize: '14px',
182
- color: '#18181b',
183
- '::placeholder': { color: '#71717a' },
184
- },
185
- },
186
- })
184
+ // Place Order renders where Continue was, so the second click of a double-click on Continue lands on it. A click that
185
+ // continues a multi-click is not a decision to pay: the first click of one on Place Order has already submitted.
186
+ placeOrderBtn?.addEventListener('click', (e) => {
187
+ if (e.detail > 1) e.preventDefault()
188
+ })
187
189
 
188
- const cardContainer = document.getElementById('stripe-card-element')
189
- if (cardContainer) cardElement.mount(cardContainer)
190
- } catch {
191
- // Stripe unavailable
192
- }
190
+ /** No element (a page without a payment step) has nothing to confirm, as a null client does. */
191
+ function confirmPayment(
192
+ clientSecret: string,
193
+ context: PaymentConfirmContext,
194
+ ): Promise<PaymentConfirmation> {
195
+ return paymentEl?.confirm(clientSecret, context) ?? Promise.resolve({ ok: true, confirmed: false })
193
196
  }
194
197
 
195
- mountStripe()
196
-
197
198
  // Payment form
198
- const paymentForm = document.getElementById('payment-form') as HTMLFormElement | null
199
199
  submitOnce(paymentForm, async () => {
200
200
  hideError()
201
201
 
202
- const submitBtn = paymentForm?.querySelector(
203
- '[data-testid="place-order"]',
204
- ) as HTMLButtonElement | null
205
- if (submitBtn) submitBtn.textContent = 'Processing...'
202
+ if (placeOrderBtn) placeOrderBtn.textContent = 'Processing...'
206
203
  const stay = (message: string) => {
207
204
  showError(message)
208
- if (submitBtn) submitBtn.textContent = 'Place Order'
205
+ if (placeOrderBtn) placeOrderBtn.textContent = 'Place Order'
209
206
  }
210
207
  const leaveFor = (orderNumber: string) => {
211
208
  client.checkout.reset()
@@ -243,13 +240,15 @@ submitOnce(paymentForm, async () => {
243
240
  if (result.payment.status === 'failed') {
244
241
  return stay(client.checkout.getState().error ?? 'Payment failed. Please try again.')
245
242
  }
246
- // Without a Stripe client (a +stub-pending order) the order page shows it awaiting payment.
247
- if (result.payment.status === 'requires_action' && stripe && cardElement) {
248
- const { error } = await stripe.confirmCardPayment(result.paymentIntent.clientSecret, {
249
- payment_method: { card: cardElement },
243
+ // Confirmed only when the driver says the payment went through; otherwise the order page shows it awaiting payment.
244
+ if (result.payment.status === 'requires_action') {
245
+ const { email, billingAddress } = client.checkout.getState()
246
+ const confirmation = await confirmPayment(result.paymentIntent.clientSecret, {
247
+ email: email || undefined,
248
+ billing: billingAddress ?? undefined,
250
249
  })
251
- if (error) return stay(error.message ?? 'Payment failed.')
252
- client.checkout.markConfirmed()
250
+ if (!confirmation.ok) return stay(confirmation.error)
251
+ if (confirmation.confirmed) client.checkout.markConfirmed()
253
252
  }
254
253
  return leaveFor(result.orderNumber)
255
254
  } catch (err: any) {
@@ -1,67 +1,58 @@
1
+ import { STOCK_LABELS, stockLevel } from '@nymbalhq/types'
1
2
  import { client } from '../lib/client.js'
2
3
  import { formatPrice } from '../lib/format.js'
3
4
 
4
5
  const slug = document.querySelector<HTMLElement>('[data-product-slug]')?.dataset.productSlug
6
+ const addRow = document.querySelector<HTMLElement>('.pdp-add-row')
7
+ const quantity = addRow?.querySelector('nymbal-quantity-selector')
8
+ const addToCart = addRow?.querySelector('nymbal-add-to-cart')
9
+
10
+ function setQuantity(qty: number): void {
11
+ quantity?.setAttribute('value', String(qty))
12
+ addToCart?.setAttribute('qty', String(qty))
13
+ }
5
14
 
6
15
  if (slug) {
7
- client.product.loadBySlug(slug)
16
+ // The server rendered variants[0]; a change from it resets the quantity.
17
+ let shownVariantId = addToCart?.getAttribute('variant-id') ?? null
8
18
 
9
- client.product.subscribe(() => {
10
- const state = client.product.getState()
11
- if (!state.product) return
19
+ addRow?.addEventListener('nymbal:quantity:changed', ((e: CustomEvent<{ value: number }>) => {
20
+ addToCart?.setAttribute('qty', String(e.detail.value))
21
+ }) as EventListener)
12
22
 
13
- const product = state.product
14
- const selectedVariant = state.selectedVariant ?? product.variants?.[0]
23
+ client.product.subscribe(() => {
24
+ const { product, selectedVariant } = client.product.getState()
25
+ if (!product) return
26
+ const variant = selectedVariant ?? product.variants?.[0]
15
27
 
16
- // Update price display
17
28
  const priceEl = document.querySelector('[data-testid="product-price"]')
18
- if (priceEl && selectedVariant) {
29
+ if (priceEl) {
19
30
  const price = formatPrice(
20
- selectedVariant.priceMinor ?? product.priceMinor,
31
+ variant?.priceMinor ?? product.priceMinor ?? 0,
21
32
  product.currency ?? 'GBP',
22
33
  )
23
34
  priceEl.innerHTML = `<span>${price}</span>`
24
35
  }
25
36
 
26
- // Update stock badge
27
- const stockEl = document.querySelector('[data-testid="stock-badge"]')
28
- if (stockEl && selectedVariant) {
29
- const stock = selectedVariant.stock ?? 0
30
- let statusClass: string
31
- let label: string
32
-
33
- if (stock <= 0) {
34
- statusClass = 'out-of-stock'
35
- label = 'Out of Stock'
36
- } else if (stock < 5) {
37
- statusClass = 'low-stock'
38
- label = 'Low Stock'
39
- } else {
40
- statusClass = 'in-stock'
41
- label = 'In Stock'
42
- }
37
+ const stock = variant?.stock ?? 0
38
+ const level = stockLevel(stock)
39
+ const badge = document.querySelector<HTMLElement>('[data-testid="stock-badge"]')
40
+ if (badge) {
41
+ badge.dataset.level = level
42
+ const label = badge.querySelector('[data-stock-label]')
43
+ if (label) label.textContent = STOCK_LABELS[level]
44
+ }
43
45
 
44
- stockEl.className = `pdp-stock ${statusClass}`
45
- stockEl.innerHTML = `<span class="pdp-stock-dot" aria-hidden="true"></span>${label}`
46
+ if (addToCart && variant) {
47
+ addToCart.setAttribute('variant-id', variant.id)
48
+ addToCart.toggleAttribute('disabled', stock <= 0)
46
49
  }
47
50
 
48
- // Update add-to-cart variant-id
49
- const addToCart = document.querySelector('nymbal-add-to-cart')
50
- if (addToCart && selectedVariant) {
51
- addToCart.setAttribute('variant-id', selectedVariant.id)
52
- if (selectedVariant.stock <= 0) {
53
- addToCart.setAttribute('disabled', '')
54
- } else {
55
- addToCart.removeAttribute('disabled')
56
- }
51
+ if (variant && variant.id !== shownVariantId) {
52
+ shownVariantId = variant.id
53
+ setQuantity(1)
57
54
  }
58
55
  })
59
56
 
60
- // Listen for variant selection from the web component
61
- document.addEventListener('nymbal:variant:selected', ((e: CustomEvent) => {
62
- const variantId = e.detail?.variantId
63
- if (variantId) {
64
- client.product.selectVariant(variantId)
65
- }
66
- }) as EventListener)
57
+ client.product.loadBySlug(slug)
67
58
  }
@@ -3,15 +3,15 @@ import { client } from '../lib/client.js'
3
3
  import { escapeHtml, formatPrice } from '../lib/format.js'
4
4
 
5
5
  const gridContainer = document.getElementById('product-grid')
6
- const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null
6
+ const sortDropdown = document.querySelector('nymbal-sort-dropdown')
7
7
  const loadMoreBtn = document.getElementById('load-more-btn')
8
8
  const countEl = document.getElementById('product-count')
9
9
 
10
10
  // A category page carries its category in the path, which the server render puts on the grid; /products may carry
11
- // one in the query. The server render also selected the URL's sort in the dropdown.
11
+ // one in the query. The server render puts the sort it resolved from the URL on the dropdown's `value`.
12
12
  const urlParams = new URLSearchParams(window.location.search)
13
13
  const initialCategory = gridContainer?.dataset.category ?? urlParams.get('category') ?? undefined
14
- const initialSort = (sortSelect?.value ?? 'newest') as ProductSortKey
14
+ const initialSort = (sortDropdown?.getAttribute('value') || 'newest') as ProductSortKey
15
15
 
16
16
  function renderProductCard(product: any): string {
17
17
  const imageUrl =
@@ -94,9 +94,10 @@ async function init() {
94
94
 
95
95
  void init()
96
96
 
97
+ // The element's event, not its select's `change`: the select may be the element's own rather than the server's.
97
98
  // The URL carries the sort, so a reload or a shared link renders the same order server-side.
98
- sortSelect?.addEventListener('change', () => {
99
- const sort = sortSelect.value as ProductSortKey
99
+ sortDropdown?.addEventListener('nymbal:sort:changed', (e) => {
100
+ const sort = (e as CustomEvent<{ sort: ProductSortKey }>).detail.sort
100
101
  const url = new URL(window.location.href)
101
102
  url.searchParams.set('sort', sort)
102
103
  window.history.replaceState(window.history.state, '', url)
@@ -57,7 +57,8 @@ function renderResults() {
57
57
  const state = client.search.getState()
58
58
  const results = state.results ?? []
59
59
  const query = state.query ?? ''
60
- const isLoading = state.loading ?? false
60
+ // A load-more or category change keeps the results shown until its answer replaces them.
61
+ const isLoading = (state.loading ?? false) && results.length === 0
61
62
 
62
63
  if (!resultsContainer) return
63
64
 
@@ -100,34 +101,55 @@ function renderResults() {
100
101
  return
101
102
  }
102
103
 
103
- resultsContainer.replaceChildren(
104
+ const total = state.total
105
+ const children: HTMLElement[] = [
104
106
  el(
105
107
  'div',
106
108
  { class: 'search-results-header' },
107
109
  el(
108
110
  'span',
109
- { class: 'search-result-count' },
110
- `${results.length} result${results.length === 1 ? '' : 's'}`,
111
+ { class: 'search-result-count', 'data-testid': 'search-total' },
112
+ `${total} result${total === 1 ? '' : 's'}`,
111
113
  ),
112
114
  ),
113
115
  el('div', { class: 'product-grid' }, ...results.map(renderProductCard)),
114
- )
116
+ ]
117
+ if (state.nextCursor !== null) {
118
+ const loadMore = el(
119
+ 'button',
120
+ { type: 'button', class: 'btn btn-secondary', 'data-testid': 'search-load-more' },
121
+ state.loading ? 'Loading...' : 'Load more',
122
+ ) as HTMLButtonElement
123
+ loadMore.disabled = state.loading
124
+ loadMore.addEventListener('click', () => void client.search.loadMore())
125
+ children.push(el('div', { class: 'plp-pagination' }, loadMore))
126
+ }
127
+ resultsContainer.replaceChildren(...children)
115
128
  }
116
129
 
117
130
  client.search.subscribe(renderResults)
118
131
 
119
- // Read initial query from URL
132
+ // Read initial query and category from URL
120
133
  const urlParams = new URLSearchParams(window.location.search)
121
134
  const initialQuery = urlParams.get('q')
122
135
 
123
136
  if (initialQuery) {
124
137
  if (searchInput) searchInput.value = initialQuery
125
- client.search.search(initialQuery)
138
+ client.search.search(initialQuery, { category: urlParams.get('category') })
126
139
  } else {
127
140
  renderResults()
128
141
  }
129
142
 
130
- // Listen for search input changes
143
+ // The category filter has narrowed the search; the URL follows, beside ?q.
144
+ document.addEventListener('nymbal:filter:changed', (event) => {
145
+ const { value } = (event as CustomEvent<{ name: string; value: string }>).detail
146
+ const url = new URL(window.location.href)
147
+ if (value) url.searchParams.set('category', value)
148
+ else url.searchParams.delete('category')
149
+ window.history.replaceState({}, '', url.toString())
150
+ })
151
+
152
+ // Listen for search input changes: a new query searches every category again.
131
153
  searchInput?.addEventListener('input', () => {
132
154
  const value = searchInput.value.trim()
133
155
  if (value.length >= 2) {
@@ -135,11 +157,13 @@ searchInput?.addEventListener('input', () => {
135
157
  // Update URL without reload
136
158
  const url = new URL(window.location.href)
137
159
  url.searchParams.set('q', value)
160
+ url.searchParams.delete('category')
138
161
  window.history.replaceState({}, '', url.toString())
139
162
  } else if (value.length === 0) {
140
163
  client.search.clearSearch()
141
164
  const url = new URL(window.location.href)
142
165
  url.searchParams.delete('q')
166
+ url.searchParams.delete('category')
143
167
  window.history.replaceState({}, '', url.toString())
144
168
  }
145
169
  })
@@ -110,34 +110,6 @@
110
110
  gap: var(--nymbal-spacing-sm, 0.5rem);
111
111
  }
112
112
 
113
- .cart-line-quantity-btn {
114
- display: flex;
115
- align-items: center;
116
- justify-content: center;
117
- width: 32px;
118
- height: 32px;
119
- border: 1px solid var(--nymbal-color-border, #e4e4e7);
120
- border-radius: var(--nymbal-radius-md, 0.5rem);
121
- background: var(--nymbal-color-surface, #fff);
122
- color: var(--nymbal-color-text, #18181b);
123
- font-size: var(--nymbal-font-size-base, 1rem);
124
- cursor: pointer;
125
- transition: border-color var(--nymbal-transition-fast, 150ms) ease,
126
- background-color var(--nymbal-transition-fast, 150ms) ease;
127
- }
128
-
129
- .cart-line-quantity-btn:hover {
130
- border-color: var(--nymbal-color-primary, #18181b);
131
- background-color: #f4f4f5;
132
- }
133
-
134
- .cart-line-quantity-value {
135
- font-size: var(--nymbal-font-size-sm, 0.875rem);
136
- font-weight: var(--nymbal-font-weight-medium, 500);
137
- min-width: 24px;
138
- text-align: center;
139
- }
140
-
141
113
  .cart-line-total {
142
114
  font-size: var(--nymbal-font-size-sm, 0.875rem);
143
115
  font-weight: var(--nymbal-font-weight-bold, 700);
@@ -149,11 +149,15 @@
149
149
  font-weight: var(--nymbal-font-weight-medium, 500);
150
150
  }
151
151
 
152
- .pdp-stock.in-stock {
152
+ .pdp-stock[data-level='in'] {
153
153
  color: var(--nymbal-color-success, #16a34a);
154
154
  }
155
155
 
156
- .pdp-stock.out-of-stock {
156
+ .pdp-stock[data-level='low'] {
157
+ color: var(--nymbal-color-warning, #d97706);
158
+ }
159
+
160
+ .pdp-stock[data-level='out'] {
157
161
  color: var(--nymbal-color-error, #dc2626);
158
162
  }
159
163
 
@@ -44,6 +44,25 @@
44
44
  box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
45
45
  }
46
46
 
47
+ /* ---- Category filter beside the results ---- */
48
+
49
+ .search-layout {
50
+ display: grid;
51
+ grid-template-columns: minmax(0, 1fr);
52
+ gap: var(--nymbal-spacing-lg, 1.5rem);
53
+ }
54
+
55
+ /* The filter renders nothing when the search has no facets: the results take the width. */
56
+ @media (min-width: 769px) {
57
+ .search-layout:has(nymbal-product-filter:not(:empty)) {
58
+ grid-template-columns: 240px minmax(0, 1fr);
59
+ }
60
+ }
61
+
62
+ .search-sidebar:has(nymbal-product-filter:empty) {
63
+ display: none;
64
+ }
65
+
47
66
  /* ---- Results area ---- */
48
67
 
49
68
  .search-results {
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
- import { fetchProducts, fetchCategories, sortFromSearchParams } from '@/lib/api'
3
+ import { fetchProducts, fetchCategories, fetchCapabilities, sortFromSearchParams } from '@/lib/api'
4
4
  import { Breadcrumb } from '@/components/server/Breadcrumb'
5
5
  import { ProductGrid } from '@/components/client/ProductGrid'
6
6
  import styles from '@/styles/pages/plp.module.css'
@@ -25,8 +25,9 @@ export async function generateMetadata({
25
25
 
26
26
  export default async function CategoryPage({ params, searchParams }: CategoryPageProps) {
27
27
  const { slug } = await params
28
- const sort = sortFromSearchParams(await searchParams)
29
- const [{ items, nextCursor }, categories] = await Promise.all([
28
+ const capabilities = await fetchCapabilities()
29
+ const sort = sortFromSearchParams(await searchParams, capabilities?.catalog)
30
+ const [{ items, nextCursor, facets }, categories] = await Promise.all([
30
31
  fetchProducts({ limit: 24, category: slug, sort }),
31
32
  fetchCategories(),
32
33
  ])
@@ -50,8 +51,10 @@ export default async function CategoryPage({ params, searchParams }: CategoryPag
50
51
  initialProducts={items}
51
52
  initialCursor={nextCursor}
52
53
  initialSort={sort}
54
+ initialFacets={facets ?? []}
55
+ sortKeys={capabilities?.catalog.sortKeys ?? []}
56
+ defaultSort={capabilities?.catalog.defaultSort}
53
57
  categorySlug={slug}
54
- categories={categories}
55
58
  />
56
59
  </div>
57
60
  )
@@ -1,10 +1,17 @@
1
1
  import type { Metadata } from 'next'
2
2
  import { CheckoutForm } from '@/components/client/CheckoutForm'
3
+ import { fetchCapabilities } from '@/lib/api'
3
4
 
4
5
  export const metadata: Metadata = {
5
6
  title: 'Checkout',
6
7
  }
7
8
 
8
- export default function CheckoutRoute() {
9
- return <CheckoutForm />
9
+ export default async function CheckoutRoute() {
10
+ const capabilities = await fetchCapabilities()
11
+ if (capabilities === null) {
12
+ throw new Error(
13
+ 'The store did not answer GET /api/capabilities, so checkout cannot tell how to take a payment.',
14
+ )
15
+ }
16
+ return <CheckoutForm capabilities={capabilities} />
10
17
  }
@@ -1,5 +1,5 @@
1
1
  import type { Metadata } from 'next'
2
- import { fetchProducts, fetchCategories, sortFromSearchParams } from '@/lib/api'
2
+ import { fetchProducts, fetchCapabilities, sortFromSearchParams } from '@/lib/api'
3
3
  import { Breadcrumb } from '@/components/server/Breadcrumb'
4
4
  import { ProductGrid } from '@/components/client/ProductGrid'
5
5
  import styles from '@/styles/pages/plp.module.css'
@@ -14,11 +14,9 @@ interface ProductsPageProps {
14
14
  }
15
15
 
16
16
  export default async function ProductsPage({ searchParams }: ProductsPageProps) {
17
- const sort = sortFromSearchParams(await searchParams)
18
- const [{ items, nextCursor }, categories] = await Promise.all([
19
- fetchProducts({ limit: 24, sort }),
20
- fetchCategories(),
21
- ])
17
+ const capabilities = await fetchCapabilities()
18
+ const sort = sortFromSearchParams(await searchParams, capabilities?.catalog)
19
+ const { items, nextCursor, facets } = await fetchProducts({ limit: 24, sort })
22
20
 
23
21
  return (
24
22
  <div className={`container ${styles.page}`}>
@@ -33,7 +31,9 @@ export default async function ProductsPage({ searchParams }: ProductsPageProps)
33
31
  initialProducts={items}
34
32
  initialCursor={nextCursor}
35
33
  initialSort={sort}
36
- categories={categories}
34
+ initialFacets={facets ?? []}
35
+ sortKeys={capabilities?.catalog.sortKeys ?? []}
36
+ defaultSort={capabilities?.catalog.defaultSort}
37
37
  />
38
38
  </div>
39
39
  )
@@ -76,6 +76,7 @@ export function CartPage() {
76
76
  type="button"
77
77
  className={styles.removeButton}
78
78
  onClick={() => removeItem(item.variantId)}
79
+ data-testid={`cart-remove-${item.variantId}`}
79
80
  aria-label={`Remove ${item.productName} from cart`}
80
81
  >
81
82
  Remove