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
|
@@ -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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 {
|
|
8
|
+
import { PRODUCT_SORT_LABELS } from '@nymbalhq/types'
|
|
9
|
+
import { fetchProducts, fetchCategories, fetchCapabilities, sortFromUrl } from '../../lib/api'
|
|
10
10
|
|
|
11
|
-
const
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
26
|
-
<
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
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">🔍</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
|
|
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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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('
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
198
|
-
paymentForm?.addEventListener('submit', async (e) => {
|
|
199
|
-
e.preventDefault()
|
|
199
|
+
submitOnce(paymentForm, async () => {
|
|
200
200
|
hideError()
|
|
201
201
|
|
|
202
|
-
|
|
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 (
|
|
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
|
-
//
|
|
252
|
-
if (result.payment.status === 'requires_action'
|
|
253
|
-
const {
|
|
254
|
-
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
+
})
|