create-nymbal-app 0.0.0 → 0.3.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/bin/create-nymbal-app.mjs +9 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +419 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -7
- package/scaffold/project/astro/src/pages/index.astro +57 -0
- package/scaffold/templates/astro/README.md +26 -0
- package/scaffold/templates/astro/_dot.env.example +6 -0
- package/scaffold/templates/astro/_dot.gitignore +16 -0
- package/scaffold/templates/astro/_dot.node-version +1 -0
- package/scaffold/templates/astro/_dot.npmrc +1 -0
- package/scaffold/templates/astro/astro.config.mjs +14 -0
- package/scaffold/templates/astro/package.json +59 -0
- package/scaffold/templates/astro/src/components/AddressForm.astro +107 -0
- package/scaffold/templates/astro/src/components/Breadcrumb.astro +20 -0
- package/scaffold/templates/astro/src/components/CategoryNav.astro +27 -0
- package/scaffold/templates/astro/src/components/CheckoutSteps.astro +29 -0
- package/scaffold/templates/astro/src/components/Footer.astro +108 -0
- package/scaffold/templates/astro/src/components/Header.astro +192 -0
- package/scaffold/templates/astro/src/components/HeroSection.astro +17 -0
- package/scaffold/templates/astro/src/components/ImageGallery.astro +42 -0
- package/scaffold/templates/astro/src/components/NewsletterSignup.astro +39 -0
- package/scaffold/templates/astro/src/components/OrderSummary.astro +53 -0
- package/scaffold/templates/astro/src/components/Pagination.astro +13 -0
- package/scaffold/templates/astro/src/components/PriceDisplay.astro +25 -0
- package/scaffold/templates/astro/src/components/ProductCard.astro +44 -0
- package/scaffold/templates/astro/src/components/RelatedProducts.astro +28 -0
- package/scaffold/templates/astro/src/components/ReviewForm.astro +74 -0
- package/scaffold/templates/astro/src/components/ReviewList.astro +41 -0
- package/scaffold/templates/astro/src/components/SkipNav.astro +3 -0
- package/scaffold/templates/astro/src/components/SortDropdown.astro +26 -0
- package/scaffold/templates/astro/src/components/StockBadge.astro +32 -0
- package/scaffold/templates/astro/src/env.d.ts +2 -0
- package/scaffold/templates/astro/src/layouts/AccountLayout.astro +170 -0
- package/scaffold/templates/astro/src/layouts/BaseLayout.astro +55 -0
- package/scaffold/templates/astro/src/lib/api.ts +89 -0
- package/scaffold/templates/astro/src/lib/client.ts +13 -0
- package/scaffold/templates/astro/src/lib/format.ts +17 -0
- package/scaffold/templates/astro/src/lib/order-status.ts +14 -0
- package/scaffold/templates/astro/src/lib/seo.ts +62 -0
- package/scaffold/templates/astro/src/lib/submit-once.ts +44 -0
- package/scaffold/templates/astro/src/pages/404.astro +18 -0
- package/scaffold/templates/astro/src/pages/500.astro +17 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +110 -0
- package/scaffold/templates/astro/src/pages/account/index.astro +67 -0
- package/scaffold/templates/astro/src/pages/account/login.astro +55 -0
- package/scaffold/templates/astro/src/pages/account/orders/[orderNumber].astro +108 -0
- package/scaffold/templates/astro/src/pages/account/orders.astro +81 -0
- package/scaffold/templates/astro/src/pages/account/profile.astro +69 -0
- package/scaffold/templates/astro/src/pages/account/register.astro +94 -0
- package/scaffold/templates/astro/src/pages/cart.astro +16 -0
- package/scaffold/templates/astro/src/pages/category/[slug].astro +73 -0
- package/scaffold/templates/astro/src/pages/checkout.astro +100 -0
- package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/header.astro +5 -0
- package/scaffold/templates/astro/src/pages/fragments/nav.astro +9 -0
- package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +14 -0
- package/scaffold/templates/astro/src/pages/index.astro +49 -0
- package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +127 -0
- package/scaffold/templates/astro/src/pages/product/[slug].astro +107 -0
- package/scaffold/templates/astro/src/pages/products/index.astro +62 -0
- package/scaffold/templates/astro/src/pages/search.astro +37 -0
- package/scaffold/templates/astro/src/scripts/account-guard.ts +22 -0
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +82 -0
- package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
- package/scaffold/templates/astro/src/scripts/checkout.ts +272 -0
- package/scaffold/templates/astro/src/scripts/image-gallery.ts +22 -0
- package/scaffold/templates/astro/src/scripts/init-client.ts +16 -0
- package/scaffold/templates/astro/src/scripts/order-status.ts +27 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +67 -0
- package/scaffold/templates/astro/src/scripts/plp.ts +109 -0
- 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 +145 -0
- package/scaffold/templates/astro/src/styles/global.css +362 -0
- package/scaffold/templates/astro/src/styles/pages/account.css +331 -0
- package/scaffold/templates/astro/src/styles/pages/cart.css +243 -0
- package/scaffold/templates/astro/src/styles/pages/checkout.css +270 -0
- package/scaffold/templates/astro/src/styles/pages/confirmation.css +142 -0
- package/scaffold/templates/astro/src/styles/pages/error.css +69 -0
- package/scaffold/templates/astro/src/styles/pages/home.css +269 -0
- package/scaffold/templates/astro/src/styles/pages/pdp.css +272 -0
- package/scaffold/templates/astro/src/styles/pages/plp.css +213 -0
- package/scaffold/templates/astro/src/styles/pages/search.css +152 -0
- package/scaffold/templates/astro/tsconfig.json +3 -0
- package/scaffold/templates/nextjs/README.md +26 -0
- package/scaffold/templates/nextjs/_dot.env.example +6 -0
- package/scaffold/templates/nextjs/_dot.gitignore +17 -0
- package/scaffold/templates/nextjs/_dot.node-version +1 -0
- package/scaffold/templates/nextjs/_dot.npmrc +1 -0
- package/scaffold/templates/nextjs/app/account/addresses/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/layout.tsx +57 -0
- package/scaffold/templates/nextjs/app/account/login/page.tsx +23 -0
- package/scaffold/templates/nextjs/app/account/orders/[orderNumber]/page.tsx +192 -0
- package/scaffold/templates/nextjs/app/account/orders/page.tsx +123 -0
- package/scaffold/templates/nextjs/app/account/page.tsx +59 -0
- package/scaffold/templates/nextjs/app/account/profile/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/account/register/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/cart/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +58 -0
- package/scaffold/templates/nextjs/app/checkout/page.tsx +10 -0
- package/scaffold/templates/nextjs/app/error.tsx +49 -0
- package/scaffold/templates/nextjs/app/globals.css +473 -0
- package/scaffold/templates/nextjs/app/layout.tsx +29 -0
- package/scaffold/templates/nextjs/app/not-found.tsx +38 -0
- package/scaffold/templates/nextjs/app/order/[orderNumber]/page.tsx +127 -0
- package/scaffold/templates/nextjs/app/page.tsx +63 -0
- package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +111 -0
- package/scaffold/templates/nextjs/app/products/page.tsx +40 -0
- package/scaffold/templates/nextjs/app/search/page.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/AddressManager.tsx +181 -0
- package/scaffold/templates/nextjs/components/client/AuthGuard.tsx +55 -0
- package/scaffold/templates/nextjs/components/client/CartDrawerToggle.tsx +39 -0
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +120 -0
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +523 -0
- package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +68 -0
- package/scaffold/templates/nextjs/components/client/LoginForm.tsx +89 -0
- package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +12 -0
- package/scaffold/templates/nextjs/components/client/NewsletterSignup.tsx +74 -0
- package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +24 -0
- package/scaffold/templates/nextjs/components/client/OrderStatusRefresh.tsx +48 -0
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +148 -0
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +61 -0
- package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +112 -0
- package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +131 -0
- package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +116 -0
- package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +27 -0
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +141 -0
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +35 -0
- package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +36 -0
- package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +29 -0
- package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +60 -0
- package/scaffold/templates/nextjs/components/server/Footer.module.css +64 -0
- package/scaffold/templates/nextjs/components/server/Footer.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/Header.module.css +147 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +102 -0
- package/scaffold/templates/nextjs/components/server/HeroSection.tsx +18 -0
- package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +79 -0
- package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ProductCard.tsx +59 -0
- package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +31 -0
- package/scaffold/templates/nextjs/components/server/ReviewList.tsx +62 -0
- package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -0
- package/scaffold/templates/nextjs/components/server/StockBadge.tsx +19 -0
- package/scaffold/templates/nextjs/lib/api.ts +89 -0
- package/scaffold/templates/nextjs/lib/client.ts +23 -0
- package/scaffold/templates/nextjs/lib/format.ts +7 -0
- package/scaffold/templates/nextjs/lib/order-status.ts +14 -0
- package/scaffold/templates/nextjs/lib/seo.ts +73 -0
- package/scaffold/templates/nextjs/next.config.mjs +9 -0
- package/scaffold/templates/nextjs/package.json +64 -0
- package/scaffold/templates/nextjs/styles/pages/account.module.css +377 -0
- package/scaffold/templates/nextjs/styles/pages/cart.module.css +240 -0
- package/scaffold/templates/nextjs/styles/pages/checkout.module.css +404 -0
- package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +237 -0
- package/scaffold/templates/nextjs/styles/pages/error.module.css +132 -0
- package/scaffold/templates/nextjs/styles/pages/home.module.css +285 -0
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +319 -0
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +280 -0
- package/scaffold/templates/nextjs/styles/pages/search.module.css +192 -0
- package/scaffold/templates/nextjs/tsconfig.json +41 -0
- package/README.md +0 -4
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../styles/pages/search.css'
|
|
3
|
+
import '../styles/pages/home.css'
|
|
4
|
+
import BaseLayout from '../layouts/BaseLayout.astro'
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<BaseLayout title="Search | Nymbal" description="Search our product catalogue.">
|
|
8
|
+
<div class="search-hero">
|
|
9
|
+
<h1 class="search-hero-title">Search</h1>
|
|
10
|
+
<div class="search-bar-wrapper">
|
|
11
|
+
<input
|
|
12
|
+
type="search"
|
|
13
|
+
id="search-input"
|
|
14
|
+
class="search-input-large"
|
|
15
|
+
placeholder="Search for products..."
|
|
16
|
+
aria-label="Search products"
|
|
17
|
+
data-testid="search-page-input"
|
|
18
|
+
autocomplete="off"
|
|
19
|
+
/>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
<div class="container section">
|
|
24
|
+
<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">🔍</div>
|
|
28
|
+
<h2 class="search-empty-title">Search our store</h2>
|
|
29
|
+
<p class="search-empty-text">Type a keyword to find products.</p>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</BaseLayout>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
import '../scripts/search.js'
|
|
37
|
+
</script>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
|
|
3
|
+
export async function guardAuth(): Promise<boolean> {
|
|
4
|
+
let state = client.auth.getState()
|
|
5
|
+
|
|
6
|
+
if (!state.isAuthenticated) {
|
|
7
|
+
try {
|
|
8
|
+
await client.auth.loadProfile()
|
|
9
|
+
state = client.auth.getState()
|
|
10
|
+
} catch {
|
|
11
|
+
// will redirect below
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!state.isAuthenticated) {
|
|
16
|
+
const redirect = encodeURIComponent(window.location.pathname + window.location.search)
|
|
17
|
+
window.location.href = `/account/login?redirect=${redirect}`
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
import { submitOnce } from '../lib/submit-once.js'
|
|
3
|
+
|
|
4
|
+
function getRedirectUrl(): string {
|
|
5
|
+
const params = new URLSearchParams(window.location.search)
|
|
6
|
+
return params.get('redirect') ?? '/account'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function showFormError(formId: string, message: string) {
|
|
10
|
+
const errorEl = document.getElementById(`${formId}-error`)
|
|
11
|
+
if (errorEl) {
|
|
12
|
+
errorEl.textContent = message
|
|
13
|
+
errorEl.style.display = 'block'
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hideFormError(formId: string) {
|
|
18
|
+
const errorEl = document.getElementById(`${formId}-error`)
|
|
19
|
+
if (errorEl) {
|
|
20
|
+
errorEl.style.display = 'none'
|
|
21
|
+
errorEl.textContent = ''
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Login form handler
|
|
26
|
+
const loginForm = document.getElementById('login-form') as HTMLFormElement | null
|
|
27
|
+
submitOnce(loginForm, async () => {
|
|
28
|
+
hideFormError('login')
|
|
29
|
+
|
|
30
|
+
const email = (document.getElementById('login-email') as HTMLInputElement)?.value
|
|
31
|
+
const password = (document.getElementById('login-password') as HTMLInputElement)?.value
|
|
32
|
+
|
|
33
|
+
if (!email || !password) {
|
|
34
|
+
showFormError('login', 'Please enter your email and password.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const submitBtn = loginForm?.querySelector(
|
|
39
|
+
'[data-testid="login-submit"]',
|
|
40
|
+
) as HTMLButtonElement | null
|
|
41
|
+
if (submitBtn) submitBtn.textContent = 'Signing in...'
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
await client.auth.login(email, password)
|
|
45
|
+
window.location.href = getRedirectUrl()
|
|
46
|
+
return 'leaving'
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
showFormError('login', err.message ?? 'Invalid email or password.')
|
|
49
|
+
if (submitBtn) submitBtn.textContent = 'Sign In'
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Register form handler
|
|
54
|
+
const registerForm = document.getElementById('register-form') as HTMLFormElement | null
|
|
55
|
+
submitOnce(registerForm, async () => {
|
|
56
|
+
hideFormError('register')
|
|
57
|
+
|
|
58
|
+
const email = (document.getElementById('register-email') as HTMLInputElement)?.value
|
|
59
|
+
const password = (document.getElementById('register-password') as HTMLInputElement)?.value
|
|
60
|
+
const firstName = (document.getElementById('register-firstName') as HTMLInputElement)?.value
|
|
61
|
+
const lastName = (document.getElementById('register-lastName') as HTMLInputElement)?.value
|
|
62
|
+
const phone = (document.getElementById('register-phone') as HTMLInputElement)?.value
|
|
63
|
+
|
|
64
|
+
if (!email || !password) {
|
|
65
|
+
showFormError('register', 'Please enter your email and password.')
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const submitBtn = registerForm?.querySelector(
|
|
70
|
+
'[data-testid="register-submit"]',
|
|
71
|
+
) as HTMLButtonElement | null
|
|
72
|
+
if (submitBtn) submitBtn.textContent = 'Creating account...'
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await client.auth.register({ email, password, firstName, lastName, phone })
|
|
76
|
+
window.location.href = getRedirectUrl()
|
|
77
|
+
return 'leaving'
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
showFormError('register', err.message ?? 'Registration failed. Please try again.')
|
|
80
|
+
if (submitBtn) submitBtn.textContent = 'Create Account'
|
|
81
|
+
}
|
|
82
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
import { formatPrice } from '../lib/format.js'
|
|
3
|
+
|
|
4
|
+
const container = document.getElementById('cart-content')
|
|
5
|
+
if (!container) throw new Error('Missing #cart-content')
|
|
6
|
+
|
|
7
|
+
function renderCart() {
|
|
8
|
+
const state = client.cart.getState()
|
|
9
|
+
const items = state.items ?? []
|
|
10
|
+
const currency = state.currency || 'GBP'
|
|
11
|
+
|
|
12
|
+
if (items.length === 0) {
|
|
13
|
+
container!.innerHTML = `
|
|
14
|
+
<div class="cart-empty">
|
|
15
|
+
<h1 class="cart-empty-title">Your cart is empty</h1>
|
|
16
|
+
<p class="cart-empty-text">Looks like you haven't added anything yet.</p>
|
|
17
|
+
<a href="/products" class="btn btn-primary" data-testid="continue-shopping">Continue Shopping</a>
|
|
18
|
+
</div>
|
|
19
|
+
`
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const subtotal = items.reduce((sum: number, item: any) => sum + item.priceMinor * item.qty, 0)
|
|
24
|
+
|
|
25
|
+
const itemsHtml = items
|
|
26
|
+
.map(
|
|
27
|
+
(item: any) => `
|
|
28
|
+
<div class="cart-line-item" data-testid="cart-item-${item.variantId}">
|
|
29
|
+
<img
|
|
30
|
+
src="${item.imageUrl || `https://picsum.photos/seed/${item.productId}/200/200`}"
|
|
31
|
+
alt="${item.productName}"
|
|
32
|
+
class="cart-line-image"
|
|
33
|
+
width="100"
|
|
34
|
+
height="100"
|
|
35
|
+
loading="lazy"
|
|
36
|
+
/>
|
|
37
|
+
<div class="cart-line-info">
|
|
38
|
+
<a href="/product/${item.productId}" class="cart-line-name">${item.productName}</a>
|
|
39
|
+
${item.variantName ? `<span class="cart-line-variant">${item.variantName}</span>` : ''}
|
|
40
|
+
<span class="cart-line-price-each">${formatPrice(item.priceMinor, currency)} each</span>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
class="cart-line-remove"
|
|
44
|
+
data-variant-id="${item.variantId}"
|
|
45
|
+
data-testid="cart-remove-${item.variantId}"
|
|
46
|
+
>
|
|
47
|
+
Remove
|
|
48
|
+
</button>
|
|
49
|
+
</div>
|
|
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"
|
|
63
|
+
data-variant-id="${item.variantId}"
|
|
64
|
+
aria-label="Increase quantity"
|
|
65
|
+
>+</button>
|
|
66
|
+
</div>
|
|
67
|
+
<span class="cart-line-total">${formatPrice(item.priceMinor * item.qty, currency)}</span>
|
|
68
|
+
</div>
|
|
69
|
+
`,
|
|
70
|
+
)
|
|
71
|
+
.join('')
|
|
72
|
+
|
|
73
|
+
container!.innerHTML = `
|
|
74
|
+
<div class="cart-layout">
|
|
75
|
+
<div>
|
|
76
|
+
<div class="cart-header">
|
|
77
|
+
<h1 class="cart-title">Your Cart <span class="cart-item-count">(${items.length} ${items.length === 1 ? 'item' : 'items'})</span></h1>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="cart-items" data-testid="cart-items">
|
|
80
|
+
${itemsHtml}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
<div class="cart-summary" data-testid="cart-summary">
|
|
84
|
+
<h2 class="cart-summary-title">Order Summary</h2>
|
|
85
|
+
<dl>
|
|
86
|
+
<div class="cart-summary-row">
|
|
87
|
+
<dt>Subtotal</dt>
|
|
88
|
+
<dd>${formatPrice(subtotal, currency)}</dd>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="cart-summary-row">
|
|
91
|
+
<dt>Shipping</dt>
|
|
92
|
+
<dd>Calculated at checkout</dd>
|
|
93
|
+
</div>
|
|
94
|
+
</dl>
|
|
95
|
+
<div class="cart-summary-total">
|
|
96
|
+
<span>Estimated Total</span>
|
|
97
|
+
<span>${formatPrice(subtotal, currency)}</span>
|
|
98
|
+
</div>
|
|
99
|
+
<div class="cart-summary-actions">
|
|
100
|
+
<a href="/checkout" class="btn btn-primary" data-testid="checkout-btn">Proceed to Checkout</a>
|
|
101
|
+
<a href="/products" class="btn btn-secondary">Continue Shopping</a>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
`
|
|
106
|
+
|
|
107
|
+
bindCartActions()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bindCartActions() {
|
|
111
|
+
container!.querySelectorAll('.cart-line-remove').forEach((btn) => {
|
|
112
|
+
btn.addEventListener('click', () => {
|
|
113
|
+
const variantId = (btn as HTMLElement).dataset.variantId
|
|
114
|
+
if (variantId) client.cart.removeItem(variantId)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
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
|
+
})
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
client.cart.subscribe(renderCart)
|
|
143
|
+
renderCart()
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
import { formatPrice } from '../lib/format.js'
|
|
3
|
+
import { submitOnce } from '../lib/submit-once.js'
|
|
4
|
+
|
|
5
|
+
const steps = ['contact', 'shipping', 'payment'] as const
|
|
6
|
+
type Step = (typeof steps)[number]
|
|
7
|
+
|
|
8
|
+
let currentStep: Step = 'contact'
|
|
9
|
+
|
|
10
|
+
const stepsEl = document.getElementById('checkout-steps-container')
|
|
11
|
+
const contactSection = document.getElementById('checkout-contact')
|
|
12
|
+
const shippingSection = document.getElementById('checkout-shipping')
|
|
13
|
+
const paymentSection = document.getElementById('checkout-payment')
|
|
14
|
+
const summaryEl = document.getElementById('checkout-summary')
|
|
15
|
+
const errorEl = document.getElementById('checkout-error')
|
|
16
|
+
|
|
17
|
+
function showStep(step: Step) {
|
|
18
|
+
currentStep = step
|
|
19
|
+
const sections: Record<Step, HTMLElement | null> = {
|
|
20
|
+
contact: contactSection,
|
|
21
|
+
shipping: shippingSection,
|
|
22
|
+
payment: paymentSection,
|
|
23
|
+
}
|
|
24
|
+
steps.forEach((s) => {
|
|
25
|
+
const el = sections[s]
|
|
26
|
+
if (el) el.style.display = s === step ? '' : 'none'
|
|
27
|
+
})
|
|
28
|
+
updateStepIndicator()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function updateStepIndicator() {
|
|
32
|
+
if (!stepsEl) return
|
|
33
|
+
const currentIndex = steps.indexOf(currentStep)
|
|
34
|
+
steps.forEach((step, index) => {
|
|
35
|
+
const stepEl = stepsEl.querySelector(`[data-testid="checkout-step-${step}"]`)
|
|
36
|
+
const dividerBefore = stepEl?.previousElementSibling
|
|
37
|
+
if (!stepEl) return
|
|
38
|
+
stepEl.className = 'checkout-step'
|
|
39
|
+
if (index < currentIndex) stepEl.classList.add('completed')
|
|
40
|
+
else if (index === currentIndex) stepEl.classList.add('active')
|
|
41
|
+
if (dividerBefore?.classList.contains('checkout-step-divider')) {
|
|
42
|
+
dividerBefore.className = 'checkout-step-divider'
|
|
43
|
+
if (index <= currentIndex) dividerBefore.classList.add('completed')
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function showError(message: string) {
|
|
49
|
+
if (errorEl) {
|
|
50
|
+
errorEl.textContent = message
|
|
51
|
+
errorEl.style.display = 'block'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function hideError() {
|
|
56
|
+
if (errorEl) {
|
|
57
|
+
errorEl.style.display = 'none'
|
|
58
|
+
errorEl.textContent = ''
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getFormValues(form: HTMLFormElement): Record<string, string> {
|
|
63
|
+
const data = new FormData(form)
|
|
64
|
+
const result: Record<string, string> = {}
|
|
65
|
+
data.forEach((value, key) => {
|
|
66
|
+
result[key] = String(value)
|
|
67
|
+
})
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderSummary() {
|
|
72
|
+
if (!summaryEl) return
|
|
73
|
+
const cartState = client.cart.getState()
|
|
74
|
+
const items = cartState.items ?? []
|
|
75
|
+
const currency = cartState.currency ?? 'GBP'
|
|
76
|
+
|
|
77
|
+
summaryEl.innerHTML = `
|
|
78
|
+
<h2 class="checkout-summary-title">Order Summary</h2>
|
|
79
|
+
<div class="checkout-summary-items">
|
|
80
|
+
${items
|
|
81
|
+
.map(
|
|
82
|
+
(item) => `
|
|
83
|
+
<div class="checkout-summary-item">
|
|
84
|
+
<span class="checkout-summary-item-name">${item.productName}</span>
|
|
85
|
+
<span class="checkout-summary-item-qty">x${item.qty}</span>
|
|
86
|
+
<span class="checkout-summary-item-price">${formatPrice(item.priceMinor * item.qty, currency)}</span>
|
|
87
|
+
</div>
|
|
88
|
+
`,
|
|
89
|
+
)
|
|
90
|
+
.join('')}
|
|
91
|
+
</div>
|
|
92
|
+
<dl>
|
|
93
|
+
<div class="checkout-summary-row">
|
|
94
|
+
<dt>Subtotal</dt>
|
|
95
|
+
<dd>${formatPrice(cartState.subtotalMinor, currency)}</dd>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="checkout-summary-row">
|
|
98
|
+
<dt>Shipping</dt>
|
|
99
|
+
<dd>Calculated next</dd>
|
|
100
|
+
</div>
|
|
101
|
+
</dl>
|
|
102
|
+
<div class="checkout-summary-total">
|
|
103
|
+
<span>Total</span>
|
|
104
|
+
<span>${formatPrice(cartState.subtotalMinor, currency)}</span>
|
|
105
|
+
</div>
|
|
106
|
+
`
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Contact form
|
|
110
|
+
const contactForm = document.getElementById('contact-form') as HTMLFormElement | null
|
|
111
|
+
contactForm?.addEventListener('submit', (e) => {
|
|
112
|
+
e.preventDefault()
|
|
113
|
+
hideError()
|
|
114
|
+
const values = getFormValues(contactForm)
|
|
115
|
+
if (!values.email) {
|
|
116
|
+
showError('Please enter your email address.')
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
client.checkout.setEmail(values.email)
|
|
120
|
+
showStep('shipping')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Shipping form
|
|
124
|
+
const shippingForm = document.getElementById('shipping-form') as HTMLFormElement | null
|
|
125
|
+
shippingForm?.addEventListener('submit', (e) => {
|
|
126
|
+
e.preventDefault()
|
|
127
|
+
hideError()
|
|
128
|
+
const values = getFormValues(shippingForm)
|
|
129
|
+
|
|
130
|
+
if (!values.addressLine1 || !values.city || !values.postalCode || !values.country) {
|
|
131
|
+
showError('Please complete your shipping address.')
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
client.checkout.setShippingAddress({
|
|
136
|
+
firstName: values.firstName ?? '',
|
|
137
|
+
lastName: values.lastName ?? '',
|
|
138
|
+
addressLine1: values.addressLine1,
|
|
139
|
+
addressLine2: values.addressLine2 || undefined,
|
|
140
|
+
city: values.city,
|
|
141
|
+
region: values.region ?? '',
|
|
142
|
+
postalCode: values.postalCode,
|
|
143
|
+
country: values.country,
|
|
144
|
+
})
|
|
145
|
+
client.checkout.setShippingMethod({
|
|
146
|
+
carrier: 'Standard',
|
|
147
|
+
service: 'Standard Shipping',
|
|
148
|
+
amountMinor: 0,
|
|
149
|
+
currency: client.cart.getState().currency ?? 'GBP',
|
|
150
|
+
estimatedDays: 5,
|
|
151
|
+
})
|
|
152
|
+
showStep('payment')
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// Stripe setup. The env says only whether a Stripe client exists; the payment flow follows the server's answer.
|
|
156
|
+
let stripe: any = null
|
|
157
|
+
let cardElement: any = null
|
|
158
|
+
|
|
159
|
+
const stripeKey = import.meta.env.PUBLIC_STRIPE_PUBLISHABLE_KEY
|
|
160
|
+
const isStubPayments =
|
|
161
|
+
import.meta.env.PUBLIC_NYMBAL_PAYMENTS_PROVIDER === 'native-stub' || !stripeKey
|
|
162
|
+
|
|
163
|
+
async function mountStripe() {
|
|
164
|
+
if (isStubPayments) {
|
|
165
|
+
const cardContainer = document.getElementById('stripe-card-element')
|
|
166
|
+
if (cardContainer) {
|
|
167
|
+
cardContainer.innerHTML =
|
|
168
|
+
'<p style="color: var(--nymbal-color-text-muted, #71717a); font-size: 0.875rem; margin: 0;">Test mode — payment skipped</p>'
|
|
169
|
+
}
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const { loadStripe } = await import('@stripe/stripe-js')
|
|
174
|
+
stripe = await loadStripe(stripeKey)
|
|
175
|
+
if (!stripe) return
|
|
176
|
+
|
|
177
|
+
const elements = stripe.elements()
|
|
178
|
+
cardElement = elements.create('card', {
|
|
179
|
+
style: {
|
|
180
|
+
base: {
|
|
181
|
+
fontSize: '14px',
|
|
182
|
+
color: '#18181b',
|
|
183
|
+
'::placeholder': { color: '#71717a' },
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const cardContainer = document.getElementById('stripe-card-element')
|
|
189
|
+
if (cardContainer) cardElement.mount(cardContainer)
|
|
190
|
+
} catch {
|
|
191
|
+
// Stripe unavailable
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
mountStripe()
|
|
196
|
+
|
|
197
|
+
// Payment form
|
|
198
|
+
const paymentForm = document.getElementById('payment-form') as HTMLFormElement | null
|
|
199
|
+
submitOnce(paymentForm, async () => {
|
|
200
|
+
hideError()
|
|
201
|
+
|
|
202
|
+
const submitBtn = paymentForm?.querySelector(
|
|
203
|
+
'[data-testid="place-order"]',
|
|
204
|
+
) as HTMLButtonElement | null
|
|
205
|
+
if (submitBtn) submitBtn.textContent = 'Processing...'
|
|
206
|
+
const stay = (message: string) => {
|
|
207
|
+
showError(message)
|
|
208
|
+
if (submitBtn) submitBtn.textContent = 'Place Order'
|
|
209
|
+
}
|
|
210
|
+
const leaveFor = (orderNumber: string) => {
|
|
211
|
+
client.checkout.reset()
|
|
212
|
+
window.location.href = `/order/${encodeURIComponent(orderNumber)}`
|
|
213
|
+
return 'leaving' as const
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
const sameAsShipping = (document.getElementById('billing-same') as HTMLInputElement)?.checked
|
|
218
|
+
if (sameAsShipping) {
|
|
219
|
+
const shippingAddr = client.checkout.getState().shippingAddress
|
|
220
|
+
if (shippingAddr) client.checkout.setBillingAddress(shippingAddr)
|
|
221
|
+
} else {
|
|
222
|
+
const billingForm = document.getElementById('billing-form') as HTMLFormElement
|
|
223
|
+
const values = getFormValues(billingForm)
|
|
224
|
+
client.checkout.setBillingAddress({
|
|
225
|
+
firstName: values.firstName ?? '',
|
|
226
|
+
lastName: values.lastName ?? '',
|
|
227
|
+
addressLine1: values.addressLine1,
|
|
228
|
+
addressLine2: values.addressLine2 || undefined,
|
|
229
|
+
city: values.city,
|
|
230
|
+
region: values.region ?? '',
|
|
231
|
+
postalCode: values.postalCode,
|
|
232
|
+
country: values.country,
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const result = await client.checkout.submitPayment()
|
|
237
|
+
if (result === null) {
|
|
238
|
+
// A resubmit of a cart that already became an order (Back, then Place Order) goes to that order.
|
|
239
|
+
const { placedOrderNumber, error } = client.checkout.getState()
|
|
240
|
+
if (placedOrderNumber) return leaveFor(placedOrderNumber)
|
|
241
|
+
return stay(error ?? 'Payment failed. Please try again.')
|
|
242
|
+
}
|
|
243
|
+
if (result.payment.status === 'failed') {
|
|
244
|
+
return stay(client.checkout.getState().error ?? 'Payment failed. Please try again.')
|
|
245
|
+
}
|
|
246
|
+
// Without a Stripe client (a +stub-pending order) the order page shows it awaiting payment.
|
|
247
|
+
if (result.payment.status === 'requires_action' && stripe && cardElement) {
|
|
248
|
+
const { error } = await stripe.confirmCardPayment(result.paymentIntent.clientSecret, {
|
|
249
|
+
payment_method: { card: cardElement },
|
|
250
|
+
})
|
|
251
|
+
if (error) return stay(error.message ?? 'Payment failed.')
|
|
252
|
+
client.checkout.markConfirmed()
|
|
253
|
+
}
|
|
254
|
+
return leaveFor(result.orderNumber)
|
|
255
|
+
} catch (err: any) {
|
|
256
|
+
stay(err.message ?? 'Payment failed. Please try again.')
|
|
257
|
+
}
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
// Billing same-as-shipping toggle
|
|
261
|
+
const billingSameCheckbox = document.getElementById('billing-same') as HTMLInputElement | null
|
|
262
|
+
const billingFields = document.getElementById('billing-fields')
|
|
263
|
+
billingSameCheckbox?.addEventListener('change', () => {
|
|
264
|
+
if (billingFields) {
|
|
265
|
+
billingFields.style.display = billingSameCheckbox.checked ? 'none' : ''
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
// Init
|
|
270
|
+
showStep('contact')
|
|
271
|
+
renderSummary()
|
|
272
|
+
client.cart.subscribe(renderSummary)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const thumbnails = document.getElementById('gallery-thumbnails')
|
|
2
|
+
const mainImage = document.getElementById('gallery-main-image') as HTMLImageElement | null
|
|
3
|
+
|
|
4
|
+
if (thumbnails && mainImage) {
|
|
5
|
+
thumbnails.addEventListener('click', (e) => {
|
|
6
|
+
const target = e.target as HTMLElement
|
|
7
|
+
if (!target.classList.contains('pdp-thumbnail')) return
|
|
8
|
+
|
|
9
|
+
const url = target.dataset.url
|
|
10
|
+
const alt = target.dataset.alt
|
|
11
|
+
|
|
12
|
+
if (url) {
|
|
13
|
+
mainImage.src = url
|
|
14
|
+
mainImage.alt = alt ?? ''
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
thumbnails
|
|
18
|
+
.querySelectorAll('.pdp-thumbnail')
|
|
19
|
+
.forEach((t) => t.classList.remove('active'))
|
|
20
|
+
target.classList.add('active')
|
|
21
|
+
})
|
|
22
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import '../lib/client.js'
|
|
2
|
+
|
|
3
|
+
// Navigate to checkout when the cart drawer checkout button is clicked
|
|
4
|
+
document.addEventListener('nymbal:cart:checkout-clicked', () => {
|
|
5
|
+
window.location.href = '/checkout'
|
|
6
|
+
})
|
|
7
|
+
|
|
8
|
+
// Choosing a header search suggestion (click or Enter) opens that product
|
|
9
|
+
document.addEventListener('nymbal:search:selected', ((
|
|
10
|
+
e: CustomEvent<{ product?: { slug?: string } }>,
|
|
11
|
+
) => {
|
|
12
|
+
const slug = e.detail?.product?.slug
|
|
13
|
+
if (slug) {
|
|
14
|
+
window.location.href = `/product/${encodeURIComponent(slug)}`
|
|
15
|
+
}
|
|
16
|
+
}) as EventListener)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
|
|
3
|
+
// A pending order's page asks the API again for a while: once the payment lands (a Stripe webhook), the page is
|
|
4
|
+
// rendered afresh with the order's new state.
|
|
5
|
+
const POLL_EVERY_MS = 1_000
|
|
6
|
+
const POLL_FOR_MS = 10_000
|
|
7
|
+
|
|
8
|
+
const root = document.querySelector<HTMLElement>('[data-order-number][data-order-status]')
|
|
9
|
+
const orderNumber = root?.dataset.orderNumber
|
|
10
|
+
const shown = root?.dataset.orderStatus
|
|
11
|
+
|
|
12
|
+
if (orderNumber && shown === 'pending') {
|
|
13
|
+
const until = Date.now() + POLL_FOR_MS
|
|
14
|
+
const poll = async (): Promise<void> => {
|
|
15
|
+
try {
|
|
16
|
+
const order = await client.customer.orders.get(orderNumber)
|
|
17
|
+
if (order.status !== shown) {
|
|
18
|
+
window.location.reload()
|
|
19
|
+
return
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
// A failed read is asked again on the next tick, within the window.
|
|
23
|
+
}
|
|
24
|
+
if (Date.now() < until) setTimeout(poll, POLL_EVERY_MS)
|
|
25
|
+
}
|
|
26
|
+
setTimeout(poll, POLL_EVERY_MS)
|
|
27
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
import { formatPrice } from '../lib/format.js'
|
|
3
|
+
|
|
4
|
+
const slug = document.querySelector<HTMLElement>('[data-product-slug]')?.dataset.productSlug
|
|
5
|
+
|
|
6
|
+
if (slug) {
|
|
7
|
+
client.product.loadBySlug(slug)
|
|
8
|
+
|
|
9
|
+
client.product.subscribe(() => {
|
|
10
|
+
const state = client.product.getState()
|
|
11
|
+
if (!state.product) return
|
|
12
|
+
|
|
13
|
+
const product = state.product
|
|
14
|
+
const selectedVariant = state.selectedVariant ?? product.variants?.[0]
|
|
15
|
+
|
|
16
|
+
// Update price display
|
|
17
|
+
const priceEl = document.querySelector('[data-testid="product-price"]')
|
|
18
|
+
if (priceEl && selectedVariant) {
|
|
19
|
+
const price = formatPrice(
|
|
20
|
+
selectedVariant.priceMinor ?? product.priceMinor,
|
|
21
|
+
product.currency ?? 'GBP',
|
|
22
|
+
)
|
|
23
|
+
priceEl.innerHTML = `<span>${price}</span>`
|
|
24
|
+
}
|
|
25
|
+
|
|
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
|
+
}
|
|
43
|
+
|
|
44
|
+
stockEl.className = `pdp-stock ${statusClass}`
|
|
45
|
+
stockEl.innerHTML = `<span class="pdp-stock-dot" aria-hidden="true"></span>${label}`
|
|
46
|
+
}
|
|
47
|
+
|
|
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
|
+
}
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
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)
|
|
67
|
+
}
|