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
@@ -3,6 +3,13 @@ import '../styles/pages/checkout.css'
3
3
  import BaseLayout from '../layouts/BaseLayout.astro'
4
4
  import CheckoutSteps from '../components/CheckoutSteps.astro'
5
5
  import AddressForm from '../components/AddressForm.astro'
6
+ import { fetchCapabilities } from '../lib/api'
7
+
8
+ const capabilities = await fetchCapabilities()
9
+ if (capabilities === null) {
10
+ throw new Error('The store did not answer GET /api/capabilities, so checkout cannot tell how to take a payment.')
11
+ }
12
+ const paymentClient = capabilities.payments.client
6
13
  ---
7
14
 
8
15
  <BaseLayout title="Checkout | Nymbal" description="Complete your purchase.">
@@ -69,10 +76,11 @@ import AddressForm from '../components/AddressForm.astro'
69
76
  </div>
70
77
 
71
78
  <div class="payment-section" style="margin-top: var(--nymbal-spacing-md, 1rem);">
72
- <label style="margin-bottom: var(--nymbal-spacing-sm, 0.5rem);">Card Details</label>
73
- <div id="stripe-card-element" style="padding: var(--nymbal-spacing-sm, 0.5rem) var(--nymbal-spacing-md, 1rem); border: 1px solid var(--nymbal-color-border, #e4e4e7); border-radius: var(--nymbal-radius-md, 0.5rem); background: var(--nymbal-color-surface, #fff); min-height: 40px;" data-testid="stripe-card-element">
74
- <!-- Stripe CardElement mounts here -->
75
- </div>
79
+ {paymentClient !== null && (
80
+ <p class="checkout-section-title" style="font-size: 1rem; margin-bottom: var(--nymbal-spacing-sm, 0.5rem);">Payment Details</p>
81
+ )}
82
+ <!-- The payments adapter's client half; a driver the project registers in src/lib/client.ts renders it. -->
83
+ <nymbal-payment-element data-testid="payment-element" client={JSON.stringify(paymentClient)}></nymbal-payment-element>
76
84
  </div>
77
85
 
78
86
  <div class="checkout-submit">
@@ -35,7 +35,8 @@ const images = Array.isArray(product.media) && product.media.length > 0
35
35
  : [{ url: `https://picsum.photos/seed/${product.slug}/600/600`, alt: product.name }]
36
36
 
37
37
  const defaultVariant = product.variants?.[0]
38
- const stock = defaultVariant?.stock ?? (product.inStock ? 10 : 0)
38
+ // A product with no variants has nothing to sell, so it reads as out of stock.
39
+ const stock = defaultVariant?.stock ?? 0
39
40
 
40
41
  const jsonLd = productJsonLd(product, Astro.url.origin)
41
42
  const breadcrumb = breadcrumbJsonLd([
@@ -80,6 +81,8 @@ const hasVariantChoice = (product.variants?.length ?? 0) > 1
80
81
  <nymbal-quantity-selector value="1" min="1" max="99"></nymbal-quantity-selector>
81
82
  <nymbal-add-to-cart
82
83
  variant-id={defaultVariant?.id ?? product.id}
84
+ qty="1"
85
+ disabled={stock <= 0}
83
86
  ></nymbal-add-to-cart>
84
87
  </div>
85
88
  </div>
@@ -4,11 +4,13 @@ import '../../styles/pages/home.css'
4
4
  import BaseLayout from '../../layouts/BaseLayout.astro'
5
5
  import Breadcrumb from '../../components/Breadcrumb.astro'
6
6
  import ProductCard from '../../components/ProductCard.astro'
7
- import SortDropdown from '../../components/SortDropdown.astro'
8
7
  import Pagination from '../../components/Pagination.astro'
9
- import { fetchProducts, fetchCategories, sortFromUrl } from '../../lib/api'
8
+ import { PRODUCT_SORT_LABELS } from '@nymbalhq/types'
9
+ import { fetchProducts, fetchCategories, fetchCapabilities, sortFromUrl } from '../../lib/api'
10
10
 
11
- const sort = sortFromUrl(Astro.url)
11
+ const capabilities = await fetchCapabilities()
12
+ const sortKeys = capabilities?.catalog.sortKeys ?? []
13
+ const sort = sortFromUrl(Astro.url, capabilities?.catalog)
12
14
 
13
15
  const [productResult, categories] = await Promise.all([
14
16
  fetchProducts({ limit: 24, sort }),
@@ -30,13 +32,30 @@ const facets = productResult.facets ?? []
30
32
 
31
33
  <div class="plp-layout">
32
34
  <aside class="plp-sidebar">
33
- <nymbal-product-filter
34
- facets={JSON.stringify(facets)}
35
- ></nymbal-product-filter>
35
+ {facets.length > 0 && (
36
+ <nymbal-product-filter
37
+ facets={JSON.stringify(facets)}
38
+ ></nymbal-product-filter>
39
+ )}
36
40
  </aside>
37
41
 
38
42
  <div class="plp-grid-area">
39
- <SortDropdown sort={sort} />
43
+ {sortKeys.length > 0 && (
44
+ <div class="plp-sort-bar">
45
+ <label for="sort-select">Sort by</label>
46
+ <nymbal-sort-dropdown
47
+ sort-keys={JSON.stringify(sortKeys)}
48
+ value={sort}
49
+ default-sort={capabilities?.catalog.defaultSort}
50
+ >
51
+ <select id="sort-select" data-testid="sort-dropdown">
52
+ {sortKeys.map((key) => (
53
+ <option value={key} selected={key === sort}>{PRODUCT_SORT_LABELS[key]}</option>
54
+ ))}
55
+ </select>
56
+ </nymbal-sort-dropdown>
57
+ </div>
58
+ )}
40
59
 
41
60
  <div class="product-grid" id="product-grid">
42
61
  {products.map((product) => (
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import '../styles/pages/search.css'
3
3
  import '../styles/pages/home.css'
4
+ import '../styles/pages/plp.css'
4
5
  import BaseLayout from '../layouts/BaseLayout.astro'
5
6
  ---
6
7
 
@@ -22,11 +23,16 @@ import BaseLayout from '../layouts/BaseLayout.astro'
22
23
 
23
24
  <div class="container section">
24
25
  <p class="search-query-label" id="search-query-label" style="display:none; margin-bottom: var(--nymbal-spacing-md, 1rem);"></p>
25
- <div id="search-results" data-testid="search-results">
26
- <div class="search-empty">
27
- <div class="search-empty-icon" aria-hidden="true">&#128269;</div>
28
- <h2 class="search-empty-title">Search our store</h2>
29
- <p class="search-empty-text">Type a keyword to find products.</p>
26
+ <div class="search-layout">
27
+ <aside class="search-sidebar">
28
+ <nymbal-product-filter source="search"></nymbal-product-filter>
29
+ </aside>
30
+ <div id="search-results" data-testid="search-results">
31
+ <div class="search-empty">
32
+ <div class="search-empty-icon" aria-hidden="true">&#128269;</div>
33
+ <h2 class="search-empty-title">Search our store</h2>
34
+ <p class="search-empty-text">Type a keyword to find products.</p>
35
+ </div>
30
36
  </div>
31
37
  </div>
32
38
  </div>
@@ -1,4 +1,5 @@
1
1
  import { client } from '../lib/client.js'
2
+ import { submitOnce } from '../lib/submit-once.js'
2
3
 
3
4
  function getRedirectUrl(): string {
4
5
  const params = new URLSearchParams(window.location.search)
@@ -23,8 +24,7 @@ function hideFormError(formId: string) {
23
24
 
24
25
  // Login form handler
25
26
  const loginForm = document.getElementById('login-form') as HTMLFormElement | null
26
- loginForm?.addEventListener('submit', async (e) => {
27
- e.preventDefault()
27
+ submitOnce(loginForm, async () => {
28
28
  hideFormError('login')
29
29
 
30
30
  const email = (document.getElementById('login-email') as HTMLInputElement)?.value
@@ -35,28 +35,24 @@ loginForm?.addEventListener('submit', async (e) => {
35
35
  return
36
36
  }
37
37
 
38
- const submitBtn = loginForm.querySelector('[data-testid="login-submit"]') as HTMLButtonElement | null
39
- if (submitBtn) {
40
- submitBtn.disabled = true
41
- submitBtn.textContent = 'Signing in...'
42
- }
38
+ const submitBtn = loginForm?.querySelector(
39
+ '[data-testid="login-submit"]',
40
+ ) as HTMLButtonElement | null
41
+ if (submitBtn) submitBtn.textContent = 'Signing in...'
43
42
 
44
43
  try {
45
44
  await client.auth.login(email, password)
46
45
  window.location.href = getRedirectUrl()
46
+ return 'leaving'
47
47
  } catch (err: any) {
48
48
  showFormError('login', err.message ?? 'Invalid email or password.')
49
- if (submitBtn) {
50
- submitBtn.disabled = false
51
- submitBtn.textContent = 'Sign In'
52
- }
49
+ if (submitBtn) submitBtn.textContent = 'Sign In'
53
50
  }
54
51
  })
55
52
 
56
53
  // Register form handler
57
54
  const registerForm = document.getElementById('register-form') as HTMLFormElement | null
58
- registerForm?.addEventListener('submit', async (e) => {
59
- e.preventDefault()
55
+ submitOnce(registerForm, async () => {
60
56
  hideFormError('register')
61
57
 
62
58
  const email = (document.getElementById('register-email') as HTMLInputElement)?.value
@@ -70,20 +66,17 @@ registerForm?.addEventListener('submit', async (e) => {
70
66
  return
71
67
  }
72
68
 
73
- const submitBtn = registerForm.querySelector('[data-testid="register-submit"]') as HTMLButtonElement | null
74
- if (submitBtn) {
75
- submitBtn.disabled = true
76
- submitBtn.textContent = 'Creating account...'
77
- }
69
+ const submitBtn = registerForm?.querySelector(
70
+ '[data-testid="register-submit"]',
71
+ ) as HTMLButtonElement | null
72
+ if (submitBtn) submitBtn.textContent = 'Creating account...'
78
73
 
79
74
  try {
80
75
  await client.auth.register({ email, password, firstName, lastName, phone })
81
76
  window.location.href = getRedirectUrl()
77
+ return 'leaving'
82
78
  } catch (err: any) {
83
79
  showFormError('register', err.message ?? 'Registration failed. Please try again.')
84
- if (submitBtn) {
85
- submitBtn.disabled = false
86
- submitBtn.textContent = 'Create Account'
87
- }
80
+ if (submitBtn) submitBtn.textContent = 'Create Account'
88
81
  }
89
82
  })
@@ -48,21 +48,12 @@ function renderCart() {
48
48
  </button>
49
49
  </div>
50
50
  <div class="cart-line-quantity">
51
- <button
52
- type="button"
53
- class="cart-line-quantity-btn"
54
- data-action="decrement"
55
- data-variant-id="${item.variantId}"
56
- aria-label="Decrease quantity"
57
- >-</button>
58
- <span class="cart-line-quantity-value">${item.qty}</span>
59
- <button
60
- type="button"
61
- class="cart-line-quantity-btn"
62
- data-action="increment"
51
+ <nymbal-quantity-selector
52
+ value="${item.qty}"
53
+ min="1"
54
+ max="99"
63
55
  data-variant-id="${item.variantId}"
64
- aria-label="Increase quantity"
65
- >+</button>
56
+ ></nymbal-quantity-selector>
66
57
  </div>
67
58
  <span class="cart-line-total">${formatPrice(item.priceMinor * item.qty, currency)}</span>
68
59
  </div>
@@ -115,27 +106,11 @@ function bindCartActions() {
115
106
  })
116
107
  })
117
108
 
118
- container!.querySelectorAll('.cart-line-quantity-btn').forEach((btn) => {
119
- btn.addEventListener('click', () => {
120
- const el = btn as HTMLElement
121
- const variantId = el.dataset.variantId
122
- const action = el.dataset.action
123
- if (!variantId) return
124
-
125
- const state = client.cart.getState()
126
- const item = (state.items ?? []).find((i: any) => i.variantId === variantId)
127
- if (!item) return
128
-
129
- if (action === 'increment') {
130
- client.cart.updateQuantity(variantId, item.qty + 1)
131
- } else if (action === 'decrement') {
132
- if (item.qty <= 1) {
133
- client.cart.removeItem(variantId)
134
- } else {
135
- client.cart.updateQuantity(variantId, item.qty - 1)
136
- }
137
- }
138
- })
109
+ container!.querySelectorAll<HTMLElement>('nymbal-quantity-selector').forEach((selector) => {
110
+ selector.addEventListener('nymbal:quantity:changed', ((e: CustomEvent<{ value: number }>) => {
111
+ const variantId = selector.dataset.variantId
112
+ if (variantId) client.cart.updateQuantity(variantId, e.detail.value)
113
+ }) as EventListener)
139
114
  })
140
115
  }
141
116
 
@@ -1,5 +1,8 @@
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'
5
+ import { submitOnce } from '../lib/submit-once.js'
3
6
 
4
7
  const steps = ['contact', 'shipping', 'payment'] as const
5
8
  type Step = (typeof steps)[number]
@@ -151,71 +154,60 @@ shippingForm?.addEventListener('submit', (e) => {
151
154
  showStep('payment')
152
155
  })
153
156
 
154
- // Stripe setup. The env says only whether a Stripe client exists; the payment flow follows the server's answer.
155
- let stripe: any = null
156
- let cardElement: any = null
157
-
158
- const stripeKey = import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY
159
- const isStubPayments =
160
- 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'
161
162
 
162
- async function mountStripe() {
163
- if (isStubPayments) {
164
- const cardContainer = document.getElementById('stripe-card-element')
165
- if (cardContainer) {
166
- cardContainer.innerHTML =
167
- '<p style="color: var(--nymbal-color-text-muted, #71717a); font-size: 0.875rem; margin: 0;">Test mode — payment skipped</p>'
168
- }
169
- return
170
- }
171
- try {
172
- const { loadStripe } = await import('@stripe/stripe-js')
173
- stripe = await loadStripe(stripeKey)
174
- if (!stripe) return
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
+ )
175
183
 
176
- const elements = stripe.elements()
177
- cardElement = elements.create('card', {
178
- style: {
179
- base: {
180
- fontSize: '14px',
181
- color: '#18181b',
182
- '::placeholder': { color: '#71717a' },
183
- },
184
- },
185
- })
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
+ })
186
189
 
187
- const cardContainer = document.getElementById('stripe-card-element')
188
- if (cardContainer) cardElement.mount(cardContainer)
189
- } catch {
190
- // Stripe unavailable
191
- }
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 })
192
196
  }
193
197
 
194
- mountStripe()
195
-
196
198
  // Payment form
197
- const paymentForm = document.getElementById('payment-form') as HTMLFormElement | null
198
- paymentForm?.addEventListener('submit', async (e) => {
199
- e.preventDefault()
199
+ submitOnce(paymentForm, async () => {
200
200
  hideError()
201
201
 
202
- const submitBtn = paymentForm.querySelector(
203
- '[data-testid="place-order"]',
204
- ) as HTMLButtonElement | null
205
- if (submitBtn) {
206
- submitBtn.disabled = true
207
- submitBtn.textContent = 'Processing...'
208
- }
202
+ if (placeOrderBtn) placeOrderBtn.textContent = 'Processing...'
209
203
  const stay = (message: string) => {
210
204
  showError(message)
211
- if (submitBtn) {
212
- submitBtn.disabled = false
213
- submitBtn.textContent = 'Place Order'
214
- }
205
+ if (placeOrderBtn) placeOrderBtn.textContent = 'Place Order'
215
206
  }
216
207
  const leaveFor = (orderNumber: string) => {
217
208
  client.checkout.reset()
218
209
  window.location.href = `/order/${encodeURIComponent(orderNumber)}`
210
+ return 'leaving' as const
219
211
  }
220
212
 
221
213
  try {
@@ -248,15 +240,17 @@ paymentForm?.addEventListener('submit', async (e) => {
248
240
  if (result.payment.status === 'failed') {
249
241
  return stay(client.checkout.getState().error ?? 'Payment failed. Please try again.')
250
242
  }
251
- // Without a Stripe client (a +stub-pending order) the order page shows it awaiting payment.
252
- if (result.payment.status === 'requires_action' && stripe && cardElement) {
253
- const { error } = await stripe.confirmCardPayment(result.paymentIntent.clientSecret, {
254
- payment_method: { card: cardElement },
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,
255
249
  })
256
- if (error) return stay(error.message ?? 'Payment failed.')
257
- client.checkout.markConfirmed()
250
+ if (!confirmation.ok) return stay(confirmation.error)
251
+ if (confirmation.confirmed) client.checkout.markConfirmed()
258
252
  }
259
- leaveFor(result.orderNumber)
253
+ return leaveFor(result.orderNumber)
260
254
  } catch (err: any) {
261
255
  stay(err.message ?? 'Payment failed. Please try again.')
262
256
  }
@@ -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
- client.product.loadBySlug(slug)
16
+ // The server rendered variants[0]; a change from it resets the quantity.
17
+ let shownVariantId = addToCart?.getAttribute('variant-id') ?? null
8
18
 
9
- client.product.subscribe(() => {
10
- const state = client.product.getState()
11
- if (!state.product) return
19
+ addRow?.addEventListener('nymbal:quantity:changed', ((e: CustomEvent<{ value: number }>) => {
20
+ addToCart?.setAttribute('qty', String(e.detail.value))
21
+ }) as EventListener)
12
22
 
13
- const product = state.product
14
- const selectedVariant = state.selectedVariant ?? product.variants?.[0]
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 && selectedVariant) {
29
+ if (priceEl) {
19
30
  const price = formatPrice(
20
- selectedVariant.priceMinor ?? product.priceMinor,
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
- // Update stock badge
27
- const stockEl = document.querySelector('[data-testid="stock-badge"]')
28
- if (stockEl && selectedVariant) {
29
- const stock = selectedVariant.stock ?? 0
30
- let statusClass: string
31
- let label: string
32
-
33
- if (stock <= 0) {
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
- stockEl.className = `pdp-stock ${statusClass}`
45
- stockEl.innerHTML = `<span class="pdp-stock-dot" aria-hidden="true"></span>${label}`
46
+ if (addToCart && variant) {
47
+ addToCart.setAttribute('variant-id', variant.id)
48
+ addToCart.toggleAttribute('disabled', stock <= 0)
46
49
  }
47
50
 
48
- // Update add-to-cart variant-id
49
- const addToCart = document.querySelector('nymbal-add-to-cart')
50
- if (addToCart && selectedVariant) {
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
- // Listen for variant selection from the web component
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 sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null
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 also selected the URL's sort in the dropdown.
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 = (sortSelect?.value ?? 'newest') as ProductSortKey
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
- sortSelect?.addEventListener('change', () => {
99
- const sort = sortSelect.value as ProductSortKey
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)
@@ -0,0 +1,57 @@
1
+ import { client } from '../lib/client.js'
2
+ import { submitOnce } from '../lib/submit-once.js'
3
+
4
+ const form = document.getElementById('profile-form') as HTMLFormElement | null
5
+ const messageEl = document.getElementById('profile-message')
6
+
7
+ function populateForm() {
8
+ const profile = client.auth.getState().customer
9
+ if (!profile) return
10
+
11
+ const firstNameEl = document.getElementById('profile-firstName') as HTMLInputElement
12
+ const lastNameEl = document.getElementById('profile-lastName') as HTMLInputElement
13
+ const emailEl = document.getElementById('profile-email') as HTMLInputElement
14
+ const phoneEl = document.getElementById('profile-phone') as HTMLInputElement
15
+
16
+ if (firstNameEl) firstNameEl.value = profile.firstName ?? ''
17
+ if (lastNameEl) lastNameEl.value = profile.lastName ?? ''
18
+ if (emailEl) emailEl.value = profile.email ?? ''
19
+ if (phoneEl) phoneEl.value = profile.phone ?? ''
20
+ }
21
+
22
+ // Load profile data
23
+ async function init() {
24
+ try {
25
+ await client.auth.loadProfile()
26
+ } catch {
27
+ // ignore
28
+ }
29
+ populateForm()
30
+ }
31
+
32
+ client.auth.subscribe(populateForm)
33
+ init()
34
+
35
+ submitOnce(form, async () => {
36
+ if (!form || !messageEl) return
37
+
38
+ const firstName = (document.getElementById('profile-firstName') as HTMLInputElement)?.value
39
+ const lastName = (document.getElementById('profile-lastName') as HTMLInputElement)?.value
40
+ const phone = (document.getElementById('profile-phone') as HTMLInputElement)?.value
41
+
42
+ const submitBtn = form.querySelector('[data-testid="profile-save"]') as HTMLButtonElement | null
43
+ if (submitBtn) submitBtn.textContent = 'Saving...'
44
+
45
+ try {
46
+ await client.auth.updateProfile({ firstName, lastName, phone })
47
+ messageEl.textContent = 'Profile updated successfully.'
48
+ messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
49
+ messageEl.style.display = 'block'
50
+ } catch (err) {
51
+ messageEl.textContent = err instanceof Error ? err.message : 'Failed to update profile.'
52
+ messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
53
+ messageEl.style.display = 'block'
54
+ } finally {
55
+ if (submitBtn) submitBtn.textContent = 'Save Changes'
56
+ }
57
+ })
@@ -0,0 +1,27 @@
1
+ import { client } from '../lib/client.js'
2
+ import { submitOnce } from '../lib/submit-once.js'
3
+
4
+ const form = document.getElementById('review-form') as HTMLFormElement | null
5
+ const messageEl = document.getElementById('review-form-message')
6
+
7
+ submitOnce(form, async () => {
8
+ if (!form || !messageEl) return
9
+
10
+ const productId = form.dataset.productId ?? ''
11
+ const formData = new FormData(form)
12
+ const rating = Number(formData.get('rating'))
13
+ const title = String(formData.get('title'))
14
+ const body = String(formData.get('body'))
15
+
16
+ try {
17
+ await client.adapter.reviews.submit({ productId, rating, title, body })
18
+ messageEl.textContent = 'Thank you for your review!'
19
+ messageEl.style.display = 'block'
20
+ messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
21
+ form.reset()
22
+ } catch {
23
+ messageEl.textContent = 'Something went wrong. Please try again.'
24
+ messageEl.style.display = 'block'
25
+ messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
26
+ }
27
+ })