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.
- package/dist/index.js +7 -8
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/scaffold/templates/astro/package.json +25 -26
- package/scaffold/templates/astro/src/components/Header.astro +7 -1
- package/scaffold/templates/astro/src/components/StockBadge.astro +5 -22
- package/scaffold/templates/astro/src/lib/api.ts +18 -4
- package/scaffold/templates/astro/src/lib/client.ts +2 -0
- package/scaffold/templates/astro/src/pages/category/[slug].astro +26 -7
- package/scaffold/templates/astro/src/pages/checkout.astro +12 -4
- package/scaffold/templates/astro/src/pages/product/[slug].astro +4 -1
- package/scaffold/templates/astro/src/pages/products/index.astro +26 -7
- package/scaffold/templates/astro/src/pages/search.astro +11 -5
- package/scaffold/templates/astro/src/scripts/cart-page.ts +10 -35
- package/scaffold/templates/astro/src/scripts/checkout.ts +48 -49
- package/scaffold/templates/astro/src/scripts/pdp.ts +35 -44
- package/scaffold/templates/astro/src/scripts/plp.ts +6 -5
- package/scaffold/templates/astro/src/scripts/search.ts +32 -8
- package/scaffold/templates/astro/src/styles/pages/cart.css +0 -28
- package/scaffold/templates/astro/src/styles/pages/pdp.css +6 -2
- package/scaffold/templates/astro/src/styles/pages/search.css +19 -0
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +7 -4
- package/scaffold/templates/nextjs/app/checkout/page.tsx +9 -2
- package/scaffold/templates/nextjs/app/products/page.tsx +7 -7
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +1 -0
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +50 -91
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +39 -50
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +41 -20
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +87 -47
- package/scaffold/templates/nextjs/components/server/Header.tsx +9 -4
- package/scaffold/templates/nextjs/lib/api.ts +24 -4
- package/scaffold/templates/nextjs/lib/client.ts +3 -0
- package/scaffold/templates/nextjs/package.json +26 -28
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +6 -2
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +3 -2
- package/scaffold/templates/nextjs/styles/pages/search.module.css +24 -0
- package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
- 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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
const
|
|
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 (
|
|
248
|
+
if (paymentBlocked) return
|
|
241
249
|
|
|
242
250
|
setProcessing(true)
|
|
243
251
|
setError(null)
|
|
244
252
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
firstName:
|
|
248
|
-
lastName:
|
|
249
|
-
addressLine1:
|
|
250
|
-
addressLine2:
|
|
251
|
-
city:
|
|
252
|
-
region:
|
|
253
|
-
postalCode:
|
|
254
|
-
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
|
-
//
|
|
278
|
-
if (result.payment.status === 'requires_action'
|
|
279
|
-
const
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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={
|
|
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 {
|
|
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,
|
|
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
|
-
(
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
-
{
|
|
42
|
-
|
|
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
|
|
54
|
+
<VariantSelector />
|
|
53
55
|
</div>
|
|
54
56
|
)}
|
|
55
57
|
|
|
56
58
|
<div className={styles.addToCartSection}>
|
|
57
|
-
<
|
|
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
|
|
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
|
-
|
|
18
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
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"
|