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.
- package/dist/index.js +9 -10
- 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/ReviewForm.astro +1 -27
- package/scaffold/templates/astro/src/components/StockBadge.astro +5 -22
- package/scaffold/templates/astro/src/layouts/AccountLayout.astro +2 -0
- 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/lib/submit-once.ts +44 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +2 -2
- package/scaffold/templates/astro/src/pages/account/profile.astro +1 -63
- 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/auth-forms.ts +15 -22
- package/scaffold/templates/astro/src/scripts/cart-page.ts +10 -35
- package/scaffold/templates/astro/src/scripts/checkout.ts +52 -58
- 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/profile-form.ts +57 -0
- package/scaffold/templates/astro/src/scripts/review-form.ts +27 -0
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
`${
|
|
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
|
-
//
|
|
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
|
|
152
|
+
.pdp-stock[data-level='in'] {
|
|
153
153
|
color: var(--nymbal-color-success, #16a34a);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
.pdp-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
|
|
29
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
|
18
|
-
const
|
|
19
|
-
|
|
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
|
-
|
|
34
|
+
initialFacets={facets ?? []}
|
|
35
|
+
sortKeys={capabilities?.catalog.sortKeys ?? []}
|
|
36
|
+
defaultSort={capabilities?.catalog.defaultSort}
|
|
37
37
|
/>
|
|
38
38
|
</div>
|
|
39
39
|
)
|
|
@@ -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 ? (
|