create-nymbal-app 0.2.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 (47) hide show
  1. package/dist/index.js +9 -10
  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/ReviewForm.astro +1 -27
  7. package/scaffold/templates/astro/src/components/StockBadge.astro +5 -22
  8. package/scaffold/templates/astro/src/layouts/AccountLayout.astro +2 -0
  9. package/scaffold/templates/astro/src/lib/api.ts +18 -4
  10. package/scaffold/templates/astro/src/lib/client.ts +2 -0
  11. package/scaffold/templates/astro/src/lib/submit-once.ts +44 -0
  12. package/scaffold/templates/astro/src/pages/account/addresses.astro +2 -2
  13. package/scaffold/templates/astro/src/pages/account/profile.astro +1 -63
  14. package/scaffold/templates/astro/src/pages/category/[slug].astro +26 -7
  15. package/scaffold/templates/astro/src/pages/checkout.astro +12 -4
  16. package/scaffold/templates/astro/src/pages/product/[slug].astro +4 -1
  17. package/scaffold/templates/astro/src/pages/products/index.astro +26 -7
  18. package/scaffold/templates/astro/src/pages/search.astro +11 -5
  19. package/scaffold/templates/astro/src/scripts/auth-forms.ts +15 -22
  20. package/scaffold/templates/astro/src/scripts/cart-page.ts +10 -35
  21. package/scaffold/templates/astro/src/scripts/checkout.ts +52 -58
  22. package/scaffold/templates/astro/src/scripts/pdp.ts +35 -44
  23. package/scaffold/templates/astro/src/scripts/plp.ts +6 -5
  24. package/scaffold/templates/astro/src/scripts/profile-form.ts +57 -0
  25. package/scaffold/templates/astro/src/scripts/review-form.ts +27 -0
  26. package/scaffold/templates/astro/src/scripts/search.ts +32 -8
  27. package/scaffold/templates/astro/src/styles/pages/cart.css +0 -28
  28. package/scaffold/templates/astro/src/styles/pages/pdp.css +6 -2
  29. package/scaffold/templates/astro/src/styles/pages/search.css +19 -0
  30. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +7 -4
  31. package/scaffold/templates/nextjs/app/checkout/page.tsx +9 -2
  32. package/scaffold/templates/nextjs/app/products/page.tsx +7 -7
  33. package/scaffold/templates/nextjs/components/client/CartPage.tsx +1 -0
  34. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +50 -91
  35. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +39 -50
  36. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +41 -20
  37. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +87 -47
  38. package/scaffold/templates/nextjs/components/server/Header.tsx +9 -4
  39. package/scaffold/templates/nextjs/lib/api.ts +24 -4
  40. package/scaffold/templates/nextjs/lib/client.ts +3 -0
  41. package/scaffold/templates/nextjs/package.json +26 -28
  42. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +6 -2
  43. package/scaffold/templates/nextjs/styles/pages/plp.module.css +3 -2
  44. package/scaffold/templates/nextjs/styles/pages/search.module.css +24 -0
  45. package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
  46. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
  47. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +0 -19
@@ -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
@@ -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 ? (