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,19 +1,14 @@
1
1
  'use client'
2
2
 
3
- import { useState, useCallback, type FormEvent } from 'react'
3
+ import { useState, useCallback, useEffect, useRef, type FormEvent } from 'react'
4
4
  import { useRouter } from 'next/navigation'
5
- import { useCheckout, useCart, useNymbalClient } from '@nymbalhq/react'
6
- import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'
7
- import { loadStripe } from '@stripe/stripe-js'
5
+ import { PaymentElement, useCheckout, useCart, useNymbalClient } from '@nymbalhq/react'
6
+ import type { PaymentConfirmation } from '@nymbalhq/sdk'
7
+ import type { NymbalPaymentElement } from '@nymbalhq/web-components'
8
8
  import { formatPrice } from '@/lib/format'
9
9
  import styles from '@/styles/pages/checkout.module.css'
10
10
 
11
- // The env says only whether a Stripe client exists; the payment flow follows the server's answer.
12
- const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
13
- const isStub = process.env.NEXT_PUBLIC_NYMBAL_PAYMENTS_PROVIDER === 'native-stub' || !stripeKey
14
- const stripePromise = isStub ? null : loadStripe(stripeKey)
15
-
16
- import type { Address } from '@nymbalhq/types'
11
+ import type { Address, StoreCapabilities } from '@nymbalhq/types'
17
12
  type AddressFields = Pick<
18
13
  Address,
19
14
  'firstName' | 'lastName' | 'addressLine1' | 'city' | 'region' | 'postalCode' | 'country'
@@ -159,13 +154,16 @@ function AddressFormFields({
159
154
  )
160
155
  }
161
156
 
162
- function CheckoutFormInner() {
157
+ /** The checkout, given the store's capabilities as the page's server component read them. */
158
+ export function CheckoutForm({ capabilities }: { capabilities: StoreCapabilities }) {
163
159
  const router = useRouter()
164
160
  const client = useNymbalClient()
165
161
  const checkout = useCheckout()
166
162
  const cart = useCart()
167
- const stripe = useStripe()
168
- const elements = useElements()
163
+ const paymentClient = capabilities.payments.client
164
+ const paymentRef = useRef<NymbalPaymentElement | null>(null)
165
+ // An element that cannot take a payment (an unknown driver, a form that failed to load) holds Place Order.
166
+ const [paymentBlocked, setPaymentBlocked] = useState(false)
169
167
 
170
168
  const [step, setStep] = useState(0)
171
169
  const [email, setEmail] = useState('')
@@ -177,6 +175,16 @@ function CheckoutFormInner() {
177
175
 
178
176
  const cartCurrency = cart.currency ?? 'GBP'
179
177
 
178
+ // The element renders on the payment step; its status may already be settled when this runs.
179
+ useEffect(() => {
180
+ const el = paymentRef.current
181
+ if (step !== 2 || el === null) return
182
+ const sync = () => setPaymentBlocked(el.status === 'error')
183
+ sync()
184
+ el.addEventListener('nymbal:payment:status', sync)
185
+ return () => el.removeEventListener('nymbal:payment:status', sync)
186
+ }, [step])
187
+
180
188
  const updateShipping = useCallback((field: keyof AddressFields, value: string) => {
181
189
  setShippingAddress((prev) => ({ ...prev, [field]: value }))
182
190
  }, [])
@@ -237,22 +245,23 @@ function CheckoutFormInner() {
237
245
  const handlePlaceOrder = useCallback(
238
246
  async (e: FormEvent) => {
239
247
  e.preventDefault()
240
- if (!isStub && (!stripe || !elements)) return
248
+ if (paymentBlocked) return
241
249
 
242
250
  setProcessing(true)
243
251
  setError(null)
244
252
 
245
- const billing = sameAsShipping ? shippingAddress : billingAddress
246
- checkout.setBillingAddress({
247
- firstName: billing.firstName,
248
- lastName: billing.lastName,
249
- addressLine1: billing.addressLine1,
250
- addressLine2: billing.addressLine2 || undefined,
251
- city: billing.city,
252
- region: billing.region,
253
- postalCode: billing.postalCode,
254
- country: billing.country,
255
- })
253
+ const fields = sameAsShipping ? shippingAddress : billingAddress
254
+ const billing = {
255
+ firstName: fields.firstName,
256
+ lastName: fields.lastName,
257
+ addressLine1: fields.addressLine1,
258
+ addressLine2: fields.addressLine2 || undefined,
259
+ city: fields.city,
260
+ region: fields.region,
261
+ postalCode: fields.postalCode,
262
+ country: fields.country,
263
+ }
264
+ checkout.setBillingAddress(billing)
256
265
 
257
266
  const stay = (message: string) => {
258
267
  setError(message)
@@ -274,33 +283,14 @@ function CheckoutFormInner() {
274
283
  if (result.payment.status === 'failed') {
275
284
  return stay(client.checkout.getState().error ?? 'Payment failed.')
276
285
  }
277
- // Without a Stripe client (a +stub-pending order) the order page shows it awaiting payment.
278
- if (result.payment.status === 'requires_action' && !isStub && stripe && elements) {
279
- const cardEl = elements.getElement(CardElement)
280
- if (!cardEl) return stay('Card element not found.')
281
-
282
- const { error: stripeError } = await stripe.confirmCardPayment(
286
+ // Confirmed only when the driver says the payment went through; otherwise the order page shows it pending.
287
+ if (result.payment.status === 'requires_action') {
288
+ const confirmation: PaymentConfirmation = (await paymentRef.current?.confirm(
283
289
  result.paymentIntent.clientSecret,
284
- {
285
- payment_method: {
286
- card: cardEl,
287
- billing_details: {
288
- email,
289
- address: {
290
- line1: billing.addressLine1,
291
- line2: billing.addressLine2 || undefined,
292
- city: billing.city,
293
- state: billing.region,
294
- postal_code: billing.postalCode,
295
- country: billing.country,
296
- },
297
- },
298
- },
299
- },
300
- )
301
-
302
- if (stripeError) return stay(stripeError.message ?? 'Payment failed.')
303
- checkout.markConfirmed()
290
+ { email: email || undefined, billing },
291
+ )) ?? { ok: true, confirmed: false }
292
+ if (!confirmation.ok) return stay(confirmation.error)
293
+ if (confirmation.confirmed) checkout.markConfirmed()
304
294
  }
305
295
  leaveFor(result.orderNumber)
306
296
  } catch (err) {
@@ -308,8 +298,7 @@ function CheckoutFormInner() {
308
298
  }
309
299
  },
310
300
  [
311
- stripe,
312
- elements,
301
+ paymentBlocked,
313
302
  email,
314
303
  shippingAddress,
315
304
  billingAddress,
@@ -328,7 +317,7 @@ function CheckoutFormInner() {
328
317
  <p className={styles.processingText}>Processing your order...</p>
329
318
  </div>
330
319
  )}
331
- {/* Hidden, not unmounted, while processing: Stripe confirms with the mounted card element. */}
320
+ {/* Hidden, not unmounted, while processing: the payment driver confirms with what it mounted. */}
332
321
  <div
333
322
  className={`container ${styles.page}`}
334
323
  style={processing ? { display: 'none' } : undefined}
@@ -430,34 +419,12 @@ function CheckoutFormInner() {
430
419
  className={styles.formSection}
431
420
  style={{ marginTop: 'var(--nymbal-spacing-lg)' }}
432
421
  >
433
- <h2 className={styles.formSectionTitle}>Payment</h2>
434
- {isStub ? (
435
- <div className={styles.paymentElement} data-testid="card-element">
436
- <p
437
- style={{
438
- color: 'var(--nymbal-color-text-muted, #9ca3af)',
439
- fontSize: '0.875rem',
440
- margin: 0,
441
- }}
442
- >
443
- Test mode — payment skipped
444
- </p>
445
- </div>
446
- ) : (
447
- <div className={styles.paymentElement} data-testid="card-element">
448
- <CardElement
449
- options={{
450
- style: {
451
- base: {
452
- fontSize: '16px',
453
- color: '#1a1a1a',
454
- '::placeholder': { color: '#9ca3af' },
455
- },
456
- },
457
- }}
458
- />
459
- </div>
460
- )}
422
+ {paymentClient !== null && <h2 className={styles.formSectionTitle}>Payment</h2>}
423
+ <PaymentElement
424
+ ref={paymentRef}
425
+ client={paymentClient}
426
+ data-testid="payment-element"
427
+ />
461
428
  </div>
462
429
 
463
430
  <div className={styles.formActions}>
@@ -467,7 +434,7 @@ function CheckoutFormInner() {
467
434
  <button
468
435
  type="submit"
469
436
  className="btn btn-primary"
470
- disabled={(!isStub && !stripe) || processing}
437
+ disabled={paymentBlocked || processing}
471
438
  data-testid="place-order"
472
439
  >
473
440
  Place Order
@@ -513,11 +480,3 @@ function CheckoutFormInner() {
513
480
  </>
514
481
  )
515
482
  }
516
-
517
- export function CheckoutForm() {
518
- return (
519
- <Elements stripe={stripePromise}>
520
- <CheckoutFormInner />
521
- </Elements>
522
- )
523
- }
@@ -2,44 +2,35 @@
2
2
 
3
3
  import { useEffect, useState, useCallback } from 'react'
4
4
  import type { DenormalisedProduct } from '@nymbalhq/sdk'
5
- import type { Category, Facet, ProductSortKey } from '@nymbalhq/types'
6
- import { useProductList } from '@nymbalhq/react'
7
- import { ProductFilter } from '@nymbalhq/react'
5
+ import type { Facet, ProductSortKey } from '@nymbalhq/types'
6
+ import { ProductFilter, SortDropdown, useProductList } from '@nymbalhq/react'
8
7
  import { ProductCard } from '@/components/server/ProductCard'
9
- import { SortDropdown } from './SortDropdown'
10
8
  import styles from '@/styles/pages/plp.module.css'
11
9
 
12
10
  interface ProductGridProps {
13
11
  initialProducts: DenormalisedProduct[]
14
12
  initialCursor: string | null
15
13
  initialSort: ProductSortKey
14
+ /** The server-rendered listing's facets: none when the store's search cannot facet, and then no filter. */
15
+ initialFacets: Facet[]
16
+ /** The sort keys the store reports, in order, and the one it lists by when none is asked. */
17
+ sortKeys: ProductSortKey[]
18
+ defaultSort?: ProductSortKey | undefined
16
19
  categorySlug?: string
17
- categories: Category[]
18
20
  }
19
21
 
20
22
  export function ProductGrid({
21
23
  initialProducts,
22
24
  initialCursor,
23
25
  initialSort,
26
+ initialFacets,
27
+ sortKeys,
28
+ defaultSort,
24
29
  categorySlug,
25
- categories,
26
30
  }: ProductGridProps) {
27
- const { products, facets, pagination, loading, sort, load, loadMore, setSort } = useProductList()
31
+ const { products, pagination, loading, sort, load, loadMore, setSort } = useProductList()
28
32
  const [initialized, setInitialized] = useState(false)
29
33
  const [showMobileFilters, setShowMobileFilters] = useState(false)
30
- // Preserve the widest facet list seen so filter options never disappear during a
31
- // filtered query (the API always returns full facets, but this guards against regressions).
32
- const [stableFacets, setStableFacets] = useState<Facet[]>(
33
- categories.length > 0
34
- ? [
35
- {
36
- field: 'category',
37
- label: 'Category',
38
- values: categories.map((c) => ({ value: c.slug, label: c.name })),
39
- },
40
- ]
41
- : [],
42
- )
43
34
 
44
35
  useEffect(() => {
45
36
  load({ limit: 24, category: categorySlug, sort: initialSort })
@@ -48,7 +39,8 @@ export function ProductGrid({
48
39
 
49
40
  // The URL carries the sort, so a reload or a shared link renders the same order server-side.
50
41
  const handleSort = useCallback(
51
- (key: ProductSortKey) => {
42
+ (event: CustomEvent<{ sort: string }>) => {
43
+ const key = event.detail.sort as ProductSortKey
52
44
  const url = new URL(window.location.href)
53
45
  url.searchParams.set('sort', key)
54
46
  window.history.replaceState(window.history.state, '', url)
@@ -57,17 +49,6 @@ export function ProductGrid({
57
49
  [setSort],
58
50
  )
59
51
 
60
- // Keep stableFacets updated whenever the store returns a wider facet list.
61
- useEffect(() => {
62
- if (facets.length > 0) {
63
- const incomingValues = facets.reduce((acc, f) => acc + f.values.length, 0)
64
- const stableValues = stableFacets.reduce((acc, f) => acc + f.values.length, 0)
65
- if (incomingValues >= stableValues) {
66
- setStableFacets(facets)
67
- }
68
- }
69
- }, [facets, stableFacets])
70
-
71
52
  const displayProducts = initialized && products.length > 0 ? products : initialProducts
72
53
  const hasMore = initialized ? pagination.hasMore : !!initialCursor
73
54
 
@@ -77,26 +58,26 @@ export function ProductGrid({
77
58
  }
78
59
  }, [hasMore, loading, loadMore])
79
60
 
80
- // Use stableFacets (which always contains the full category list) so all filter
81
- // options remain visible after a category filter is applied.
82
- const displayFacets = stableFacets
83
-
84
61
  return (
85
62
  <div className={styles.layout}>
86
63
  <aside className={styles.sidebar}>
87
- <button
88
- className={styles.mobileFilterToggle}
89
- onClick={() => setShowMobileFilters((v) => !v)}
90
- type="button"
91
- >
92
- {showMobileFilters ? 'Hide Filters' : 'Show Filters'}
93
- </button>
94
- <div
95
- className={styles.filterSection}
96
- style={{ display: showMobileFilters ? 'block' : undefined }}
97
- >
98
- <ProductFilter facets={displayFacets} />
99
- </div>
64
+ {initialFacets.length > 0 && (
65
+ <>
66
+ <button
67
+ className={styles.mobileFilterToggle}
68
+ onClick={() => setShowMobileFilters((v) => !v)}
69
+ type="button"
70
+ >
71
+ {showMobileFilters ? 'Hide Filters' : 'Show Filters'}
72
+ </button>
73
+ <div
74
+ className={styles.filterSection}
75
+ style={{ display: showMobileFilters ? 'block' : undefined }}
76
+ >
77
+ <ProductFilter facets={initialFacets} />
78
+ </div>
79
+ </>
80
+ )}
100
81
  </aside>
101
82
 
102
83
  <div className={styles.content}>
@@ -104,7 +85,15 @@ export function ProductGrid({
104
85
  <span className={styles.resultCount}>
105
86
  {displayProducts.length} product{displayProducts.length !== 1 ? 's' : ''}
106
87
  </span>
107
- <SortDropdown value={initialized ? sort : initialSort} onChange={handleSort} />
88
+ {sortKeys.length > 0 && (
89
+ <SortDropdown
90
+ className={styles.sortSelect}
91
+ sortKeys={sortKeys}
92
+ value={initialized ? sort : initialSort}
93
+ defaultSort={defaultSort}
94
+ onSortChange={handleSort}
95
+ />
96
+ )}
108
97
  </div>
109
98
 
110
99
  {displayProducts.length > 0 ? (
@@ -1,7 +1,9 @@
1
1
  'use client'
2
2
 
3
- import type { DenormalisedProduct, DenormalisedVariant } from '@nymbalhq/sdk'
4
- import { useProduct, VariantSelector, AddToCart } from '@nymbalhq/react'
3
+ import { useRef, useState } from 'react'
4
+ import type { DenormalisedProduct } from '@nymbalhq/sdk'
5
+ import { STOCK_LABELS, stockLevel } from '@nymbalhq/types'
6
+ import { useProduct, VariantSelector, AddToCart, QuantitySelector } from '@nymbalhq/react'
5
7
  import { formatPrice } from '@/lib/format'
6
8
  import styles from '@/styles/pages/pdp.module.css'
7
9
 
@@ -15,18 +17,23 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
15
17
  const variant = selectedVariant ?? product.variants?.[0]
16
18
  const price = variant?.priceMinor ?? product.priceMinor ?? 0
17
19
  const currency = product.currency ?? 'GBP'
18
- const stock = variant?.stock ?? (product.inStock ? 10 : 0)
20
+ // A product with no variants has nothing to sell, so it reads as out of stock.
21
+ const stock = variant?.stock ?? 0
22
+ const level = stockLevel(stock)
23
+
24
+ // The quantity belongs to the variant it was chosen for, so a variant change starts again at 1.
25
+ const [chosen, setChosen] = useState<{ variantId: string | undefined; qty: number }>({
26
+ variantId: variant?.id,
27
+ qty: 1,
28
+ })
29
+ // Reset on the change itself, so a quantity left on a variant is not there again when the shopper comes back to it.
30
+ if (chosen.variantId !== variant?.id) setChosen({ variantId: variant?.id, qty: 1 })
31
+ const qty = chosen.variantId === variant?.id ? chosen.qty : 1
32
+ const addToCartRef = useRef<HTMLElement>(null)
19
33
 
20
34
  // The selector derives its option groups from the product's variants.
21
35
  const hasVariantChoice = (product.variants?.length ?? 0) > 1
22
36
 
23
- const handleVariantSelected = (e: CustomEvent<{ variant: unknown }>) => {
24
- const v = e.detail.variant as DenormalisedVariant | undefined
25
- if (v) {
26
- // The product store handles variant selection internally
27
- }
28
- }
29
-
30
37
  return (
31
38
  <div>
32
39
  <h1 className={styles.productTitle} data-testid="product-title">
@@ -37,24 +44,38 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
37
44
  {formatPrice(price, currency)}
38
45
  </div>
39
46
 
40
- <div className={styles.stockBadge} data-testid="stock-badge">
41
- {stock === 0 ? (
42
- <span style={{ color: 'var(--nymbal-color-error)' }}>Out of Stock</span>
43
- ) : stock < 5 ? (
44
- <span style={{ color: 'var(--nymbal-color-warning)' }}>Low Stock — {stock} left</span>
45
- ) : (
46
- <span style={{ color: 'var(--nymbal-color-success)' }}>In Stock</span>
47
- )}
47
+ <div className={styles.stockBadge} data-testid="stock-badge" data-level={level}>
48
+ <span className={styles.stockDot} aria-hidden="true" />
49
+ {STOCK_LABELS[level]}
48
50
  </div>
49
51
 
50
52
  {hasVariantChoice && (
51
53
  <div className={styles.variantSection}>
52
- <VariantSelector onVariantSelected={handleVariantSelected} />
54
+ <VariantSelector />
53
55
  </div>
54
56
  )}
55
57
 
56
58
  <div className={styles.addToCartSection}>
57
- <AddToCart variantId={variant?.id ?? product.id} disabled={stock === 0} />
59
+ <div className={styles.quantityWrapper}>
60
+ <QuantitySelector
61
+ key={variant?.id}
62
+ value={1}
63
+ min={1}
64
+ max={99}
65
+ onQuantityChange={(e) => {
66
+ // A typed quantity changes on blur, and the press on Add is what blurs it: the wrapper's effect lands
67
+ // after a fast click has already read `qty`, so the element gets it here, in the same event.
68
+ addToCartRef.current?.setAttribute('qty', String(e.detail.value))
69
+ setChosen({ variantId: variant?.id, qty: e.detail.value })
70
+ }}
71
+ />
72
+ </div>
73
+ <AddToCart
74
+ ref={addToCartRef}
75
+ variantId={variant?.id ?? product.id}
76
+ qty={qty}
77
+ disabled={stock <= 0}
78
+ />
58
79
  </div>
59
80
  </div>
60
81
  )
@@ -1,23 +1,42 @@
1
1
  'use client'
2
2
 
3
- import { useEffect } from 'react'
3
+ import { useCallback, useEffect } from 'react'
4
4
  import { useSearchParams } from 'next/navigation'
5
5
  import Link from 'next/link'
6
6
  import Image from 'next/image'
7
- import { useSearch } from '@nymbalhq/react'
7
+ import { ProductFilter, useSearch } from '@nymbalhq/react'
8
8
  import { formatPrice } from '@/lib/format'
9
9
  import styles from '@/styles/pages/search.module.css'
10
10
 
11
11
  export function SearchResults() {
12
12
  const searchParams = useSearchParams()
13
13
  const query = searchParams.get('q') ?? ''
14
- const { results, loading, search } = useSearch()
14
+ const category = searchParams.get('category')
15
+ const {
16
+ results,
17
+ total,
18
+ nextCursor,
19
+ facets,
20
+ loading,
21
+ query: searched,
22
+ category: searchedCategory,
23
+ search,
24
+ loadMore,
25
+ } = useSearch()
15
26
 
16
27
  useEffect(() => {
17
- if (query) {
18
- search(query)
28
+ // Runs when the URL changes; the filter has already searched the category it writes there.
29
+ if (query && (query !== searched || category !== searchedCategory)) {
30
+ void search(query, { category })
19
31
  }
20
- }, [query, search])
32
+ }, [query, category, search])
33
+
34
+ const onFilterChange = useCallback((event: CustomEvent<{ name: string; value: string }>) => {
35
+ const url = new URL(window.location.href)
36
+ if (event.detail.value) url.searchParams.set('category', event.detail.value)
37
+ else url.searchParams.delete('category')
38
+ window.history.replaceState(null, '', url.toString())
39
+ }, [])
21
40
 
22
41
  return (
23
42
  <div className={`container ${styles.page}`}>
@@ -30,7 +49,7 @@ export function SearchResults() {
30
49
  )}
31
50
  </div>
32
51
 
33
- {loading ? (
52
+ {loading && results.length === 0 ? (
34
53
  <div className={styles.loading}>
35
54
  <div className="spinner spinner-lg" role="status">
36
55
  <span className="visually-hidden">Searching...</span>
@@ -57,48 +76,69 @@ export function SearchResults() {
57
76
  Use the search bar above to find what you are looking for.
58
77
  </p>
59
78
  </div>
60
- ) : results && results.length > 0 ? (
61
- <>
62
- <p className={styles.resultCount}>
63
- {results.length} result{results.length !== 1 ? 's' : ''} found
64
- </p>
65
- <div className={styles.grid}>
66
- {results.map((product) => {
67
- const media = Array.isArray(product.media) ? product.media : []
68
- const imageUrl = media.length > 0 && typeof (media[0] as Record<string, unknown>)?.url === 'string'
69
- ? (media[0] as Record<string, string>).url
70
- : null
71
- return (
72
- <Link
73
- key={product.id}
74
- href={`/product/${product.slug}`}
75
- className={styles.productCard}
76
- data-testid={`product-card-${product.slug}`}
79
+ ) : results.length > 0 ? (
80
+ <div className={facets.length > 0 ? styles.layout : undefined}>
81
+ {facets.length > 0 && (
82
+ <aside className={styles.sidebar}>
83
+ <ProductFilter source="search" onFilterChange={onFilterChange} />
84
+ </aside>
85
+ )}
86
+ <div>
87
+ <p className={styles.resultCount} data-testid="search-total">
88
+ {total} result{total !== 1 ? 's' : ''} found
89
+ </p>
90
+ <div className={styles.grid}>
91
+ {results.map((product) => {
92
+ const media = Array.isArray(product.media) ? product.media : []
93
+ const imageUrl =
94
+ media.length > 0 && typeof (media[0] as Record<string, unknown>)?.url === 'string'
95
+ ? (media[0] as Record<string, string>).url
96
+ : null
97
+ return (
98
+ <Link
99
+ key={product.id}
100
+ href={`/product/${product.slug}`}
101
+ className={styles.productCard}
102
+ data-testid={`product-card-${product.slug}`}
103
+ >
104
+ <div className={styles.productImage}>
105
+ {imageUrl && (
106
+ <Image
107
+ src={imageUrl}
108
+ alt={product.name}
109
+ width={400}
110
+ height={400}
111
+ sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 25vw"
112
+ />
113
+ )}
114
+ </div>
115
+ <div className={styles.productInfo}>
116
+ <p className={styles.productName}>{product.name}</p>
117
+ {product.priceMinor != null && product.currency && (
118
+ <p className={styles.productPrice}>
119
+ {formatPrice(product.priceMinor, product.currency)}
120
+ </p>
121
+ )}
122
+ </div>
123
+ </Link>
124
+ )
125
+ })}
126
+ </div>
127
+ {nextCursor !== null && (
128
+ <div className={styles.pagination}>
129
+ <button
130
+ type="button"
131
+ className="btn btn-secondary"
132
+ onClick={() => void loadMore()}
133
+ disabled={loading}
134
+ data-testid="search-load-more"
77
135
  >
78
- <div className={styles.productImage}>
79
- {imageUrl && (
80
- <Image
81
- src={imageUrl}
82
- alt={product.name}
83
- width={400}
84
- height={400}
85
- sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 25vw"
86
- />
87
- )}
88
- </div>
89
- <div className={styles.productInfo}>
90
- <p className={styles.productName}>{product.name}</p>
91
- {product.priceMinor != null && product.currency && (
92
- <p className={styles.productPrice}>
93
- {formatPrice(product.priceMinor, product.currency)}
94
- </p>
95
- )}
96
- </div>
97
- </Link>
98
- )
99
- })}
136
+ {loading ? 'Loading...' : 'Load more'}
137
+ </button>
138
+ </div>
139
+ )}
100
140
  </div>
101
- </>
141
+ </div>
102
142
  ) : (
103
143
  <div className={styles.empty}>
104
144
  <div className={styles.emptyIcon}>
@@ -1,6 +1,7 @@
1
1
  import Link from 'next/link'
2
2
  import { SearchBarWrapper } from '@/components/client/SearchBarWrapper'
3
3
  import { MiniCartWrapper } from '@/components/client/MiniCartWrapper'
4
+ import { fetchCapabilities } from '@/lib/api'
4
5
  import styles from './Header.module.css'
5
6
 
6
7
  const NAV_LINKS = [
@@ -9,7 +10,9 @@ const NAV_LINKS = [
9
10
  { href: '/products?category=sale', label: 'Sale' },
10
11
  ]
11
12
 
12
- export function Header() {
13
+ export async function Header() {
14
+ const capabilities = await fetchCapabilities()
15
+ const canSearch = capabilities?.search.capabilities.includes('search') ?? false
13
16
  return (
14
17
  <header className={styles.header} data-testid="site-header">
15
18
  <div className={`container ${styles.inner}`}>
@@ -30,9 +33,11 @@ export function Header() {
30
33
  </nav>
31
34
 
32
35
  <div className={styles.actions}>
33
- <div className={styles.search}>
34
- <SearchBarWrapper />
35
- </div>
36
+ {canSearch && (
37
+ <div className={styles.search}>
38
+ <SearchBarWrapper />
39
+ </div>
40
+ )}
36
41
  <Link href="/account" className={styles.iconLink} aria-label="Account">
37
42
  <svg
38
43
  width="20"