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,3 +1,5 @@
|
|
|
1
|
+
import type { PaymentConfirmContext, PaymentConfirmation } from '@nymbalhq/sdk'
|
|
2
|
+
import type { NymbalPaymentElement } from '@nymbalhq/web-components'
|
|
1
3
|
import { client } from '../lib/client.js'
|
|
2
4
|
import { formatPrice } from '../lib/format.js'
|
|
3
5
|
import { submitOnce } from '../lib/submit-once.js'
|
|
@@ -152,60 +154,55 @@ shippingForm?.addEventListener('submit', (e) => {
|
|
|
152
154
|
showStep('payment')
|
|
153
155
|
})
|
|
154
156
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const isStubPayments =
|
|
161
|
-
import.meta.env.PUBLIC_NYMBAL_PAYMENTS_PROVIDER === 'native-stub' || !stripeKey
|
|
157
|
+
// The payment step is the store's: the element renders the payments adapter's client half through its driver.
|
|
158
|
+
const paymentForm = document.getElementById('payment-form') as HTMLFormElement | null
|
|
159
|
+
const paymentEl = paymentForm?.querySelector<NymbalPaymentElement>('nymbal-payment-element') ?? null
|
|
160
|
+
const placeOrderBtn = paymentForm?.querySelector<HTMLButtonElement>('[data-testid="place-order"]')
|
|
161
|
+
const paymentBlocked = () => paymentEl?.status === 'error'
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
163
|
+
// While the element cannot take a payment (an unknown driver, a form that failed to load), no order is placed.
|
|
164
|
+
let heldByPayment = false
|
|
165
|
+
function syncPlaceOrder() {
|
|
166
|
+
if (!placeOrderBtn) return
|
|
167
|
+
const blocked = paymentBlocked()
|
|
168
|
+
if (blocked) placeOrderBtn.disabled = true
|
|
169
|
+
else if (heldByPayment) placeOrderBtn.disabled = false
|
|
170
|
+
heldByPayment = blocked
|
|
171
|
+
}
|
|
172
|
+
paymentEl?.addEventListener('nymbal:payment:status', syncPlaceOrder)
|
|
173
|
+
syncPlaceOrder()
|
|
174
|
+
paymentForm?.addEventListener(
|
|
175
|
+
'submit',
|
|
176
|
+
(e) => {
|
|
177
|
+
if (!paymentBlocked()) return
|
|
178
|
+
e.preventDefault()
|
|
179
|
+
e.stopImmediatePropagation()
|
|
180
|
+
},
|
|
181
|
+
true,
|
|
182
|
+
)
|
|
176
183
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
color: '#18181b',
|
|
183
|
-
'::placeholder': { color: '#71717a' },
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
})
|
|
184
|
+
// Place Order renders where Continue was, so the second click of a double-click on Continue lands on it. A click that
|
|
185
|
+
// continues a multi-click is not a decision to pay: the first click of one on Place Order has already submitted.
|
|
186
|
+
placeOrderBtn?.addEventListener('click', (e) => {
|
|
187
|
+
if (e.detail > 1) e.preventDefault()
|
|
188
|
+
})
|
|
187
189
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
/** No element (a page without a payment step) has nothing to confirm, as a null client does. */
|
|
191
|
+
function confirmPayment(
|
|
192
|
+
clientSecret: string,
|
|
193
|
+
context: PaymentConfirmContext,
|
|
194
|
+
): Promise<PaymentConfirmation> {
|
|
195
|
+
return paymentEl?.confirm(clientSecret, context) ?? Promise.resolve({ ok: true, confirmed: false })
|
|
193
196
|
}
|
|
194
197
|
|
|
195
|
-
mountStripe()
|
|
196
|
-
|
|
197
198
|
// Payment form
|
|
198
|
-
const paymentForm = document.getElementById('payment-form') as HTMLFormElement | null
|
|
199
199
|
submitOnce(paymentForm, async () => {
|
|
200
200
|
hideError()
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
'[data-testid="place-order"]',
|
|
204
|
-
) as HTMLButtonElement | null
|
|
205
|
-
if (submitBtn) submitBtn.textContent = 'Processing...'
|
|
202
|
+
if (placeOrderBtn) placeOrderBtn.textContent = 'Processing...'
|
|
206
203
|
const stay = (message: string) => {
|
|
207
204
|
showError(message)
|
|
208
|
-
if (
|
|
205
|
+
if (placeOrderBtn) placeOrderBtn.textContent = 'Place Order'
|
|
209
206
|
}
|
|
210
207
|
const leaveFor = (orderNumber: string) => {
|
|
211
208
|
client.checkout.reset()
|
|
@@ -243,13 +240,15 @@ submitOnce(paymentForm, async () => {
|
|
|
243
240
|
if (result.payment.status === 'failed') {
|
|
244
241
|
return stay(client.checkout.getState().error ?? 'Payment failed. Please try again.')
|
|
245
242
|
}
|
|
246
|
-
//
|
|
247
|
-
if (result.payment.status === 'requires_action'
|
|
248
|
-
const {
|
|
249
|
-
|
|
243
|
+
// Confirmed only when the driver says the payment went through; otherwise the order page shows it awaiting payment.
|
|
244
|
+
if (result.payment.status === 'requires_action') {
|
|
245
|
+
const { email, billingAddress } = client.checkout.getState()
|
|
246
|
+
const confirmation = await confirmPayment(result.paymentIntent.clientSecret, {
|
|
247
|
+
email: email || undefined,
|
|
248
|
+
billing: billingAddress ?? undefined,
|
|
250
249
|
})
|
|
251
|
-
if (
|
|
252
|
-
client.checkout.markConfirmed()
|
|
250
|
+
if (!confirmation.ok) return stay(confirmation.error)
|
|
251
|
+
if (confirmation.confirmed) client.checkout.markConfirmed()
|
|
253
252
|
}
|
|
254
253
|
return leaveFor(result.orderNumber)
|
|
255
254
|
} catch (err: any) {
|
|
@@ -1,67 +1,58 @@
|
|
|
1
|
+
import { STOCK_LABELS, stockLevel } from '@nymbalhq/types'
|
|
1
2
|
import { client } from '../lib/client.js'
|
|
2
3
|
import { formatPrice } from '../lib/format.js'
|
|
3
4
|
|
|
4
5
|
const slug = document.querySelector<HTMLElement>('[data-product-slug]')?.dataset.productSlug
|
|
6
|
+
const addRow = document.querySelector<HTMLElement>('.pdp-add-row')
|
|
7
|
+
const quantity = addRow?.querySelector('nymbal-quantity-selector')
|
|
8
|
+
const addToCart = addRow?.querySelector('nymbal-add-to-cart')
|
|
9
|
+
|
|
10
|
+
function setQuantity(qty: number): void {
|
|
11
|
+
quantity?.setAttribute('value', String(qty))
|
|
12
|
+
addToCart?.setAttribute('qty', String(qty))
|
|
13
|
+
}
|
|
5
14
|
|
|
6
15
|
if (slug) {
|
|
7
|
-
|
|
16
|
+
// The server rendered variants[0]; a change from it resets the quantity.
|
|
17
|
+
let shownVariantId = addToCart?.getAttribute('variant-id') ?? null
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
addRow?.addEventListener('nymbal:quantity:changed', ((e: CustomEvent<{ value: number }>) => {
|
|
20
|
+
addToCart?.setAttribute('qty', String(e.detail.value))
|
|
21
|
+
}) as EventListener)
|
|
12
22
|
|
|
13
|
-
|
|
14
|
-
const selectedVariant =
|
|
23
|
+
client.product.subscribe(() => {
|
|
24
|
+
const { product, selectedVariant } = client.product.getState()
|
|
25
|
+
if (!product) return
|
|
26
|
+
const variant = selectedVariant ?? product.variants?.[0]
|
|
15
27
|
|
|
16
|
-
// Update price display
|
|
17
28
|
const priceEl = document.querySelector('[data-testid="product-price"]')
|
|
18
|
-
if (priceEl
|
|
29
|
+
if (priceEl) {
|
|
19
30
|
const price = formatPrice(
|
|
20
|
-
|
|
31
|
+
variant?.priceMinor ?? product.priceMinor ?? 0,
|
|
21
32
|
product.currency ?? 'GBP',
|
|
22
33
|
)
|
|
23
34
|
priceEl.innerHTML = `<span>${price}</span>`
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
statusClass = 'out-of-stock'
|
|
35
|
-
label = 'Out of Stock'
|
|
36
|
-
} else if (stock < 5) {
|
|
37
|
-
statusClass = 'low-stock'
|
|
38
|
-
label = 'Low Stock'
|
|
39
|
-
} else {
|
|
40
|
-
statusClass = 'in-stock'
|
|
41
|
-
label = 'In Stock'
|
|
42
|
-
}
|
|
37
|
+
const stock = variant?.stock ?? 0
|
|
38
|
+
const level = stockLevel(stock)
|
|
39
|
+
const badge = document.querySelector<HTMLElement>('[data-testid="stock-badge"]')
|
|
40
|
+
if (badge) {
|
|
41
|
+
badge.dataset.level = level
|
|
42
|
+
const label = badge.querySelector('[data-stock-label]')
|
|
43
|
+
if (label) label.textContent = STOCK_LABELS[level]
|
|
44
|
+
}
|
|
43
45
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
if (addToCart && variant) {
|
|
47
|
+
addToCart.setAttribute('variant-id', variant.id)
|
|
48
|
+
addToCart.toggleAttribute('disabled', stock <= 0)
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
addToCart.setAttribute('variant-id', selectedVariant.id)
|
|
52
|
-
if (selectedVariant.stock <= 0) {
|
|
53
|
-
addToCart.setAttribute('disabled', '')
|
|
54
|
-
} else {
|
|
55
|
-
addToCart.removeAttribute('disabled')
|
|
56
|
-
}
|
|
51
|
+
if (variant && variant.id !== shownVariantId) {
|
|
52
|
+
shownVariantId = variant.id
|
|
53
|
+
setQuantity(1)
|
|
57
54
|
}
|
|
58
55
|
})
|
|
59
56
|
|
|
60
|
-
|
|
61
|
-
document.addEventListener('nymbal:variant:selected', ((e: CustomEvent) => {
|
|
62
|
-
const variantId = e.detail?.variantId
|
|
63
|
-
if (variantId) {
|
|
64
|
-
client.product.selectVariant(variantId)
|
|
65
|
-
}
|
|
66
|
-
}) as EventListener)
|
|
57
|
+
client.product.loadBySlug(slug)
|
|
67
58
|
}
|
|
@@ -3,15 +3,15 @@ import { client } from '../lib/client.js'
|
|
|
3
3
|
import { escapeHtml, formatPrice } from '../lib/format.js'
|
|
4
4
|
|
|
5
5
|
const gridContainer = document.getElementById('product-grid')
|
|
6
|
-
const
|
|
6
|
+
const sortDropdown = document.querySelector('nymbal-sort-dropdown')
|
|
7
7
|
const loadMoreBtn = document.getElementById('load-more-btn')
|
|
8
8
|
const countEl = document.getElementById('product-count')
|
|
9
9
|
|
|
10
10
|
// A category page carries its category in the path, which the server render puts on the grid; /products may carry
|
|
11
|
-
// one in the query. The server render
|
|
11
|
+
// one in the query. The server render puts the sort it resolved from the URL on the dropdown's `value`.
|
|
12
12
|
const urlParams = new URLSearchParams(window.location.search)
|
|
13
13
|
const initialCategory = gridContainer?.dataset.category ?? urlParams.get('category') ?? undefined
|
|
14
|
-
const initialSort = (
|
|
14
|
+
const initialSort = (sortDropdown?.getAttribute('value') || 'newest') as ProductSortKey
|
|
15
15
|
|
|
16
16
|
function renderProductCard(product: any): string {
|
|
17
17
|
const imageUrl =
|
|
@@ -94,9 +94,10 @@ async function init() {
|
|
|
94
94
|
|
|
95
95
|
void init()
|
|
96
96
|
|
|
97
|
+
// The element's event, not its select's `change`: the select may be the element's own rather than the server's.
|
|
97
98
|
// The URL carries the sort, so a reload or a shared link renders the same order server-side.
|
|
98
|
-
|
|
99
|
-
const sort =
|
|
99
|
+
sortDropdown?.addEventListener('nymbal:sort:changed', (e) => {
|
|
100
|
+
const sort = (e as CustomEvent<{ sort: ProductSortKey }>).detail.sort
|
|
100
101
|
const url = new URL(window.location.href)
|
|
101
102
|
url.searchParams.set('sort', sort)
|
|
102
103
|
window.history.replaceState(window.history.state, '', url)
|
|
@@ -57,7 +57,8 @@ function renderResults() {
|
|
|
57
57
|
const state = client.search.getState()
|
|
58
58
|
const results = state.results ?? []
|
|
59
59
|
const query = state.query ?? ''
|
|
60
|
-
|
|
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
|
)
|