create-nymbal-app 0.0.0 → 0.2.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 +100 -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 +168 -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/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 +131 -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 +89 -0
- package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
- package/scaffold/templates/astro/src/scripts/checkout.ts +277 -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/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,277 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
import { formatPrice } from '../lib/format.js'
|
|
3
|
+
|
|
4
|
+
const steps = ['contact', 'shipping', 'payment'] as const
|
|
5
|
+
type Step = (typeof steps)[number]
|
|
6
|
+
|
|
7
|
+
let currentStep: Step = 'contact'
|
|
8
|
+
|
|
9
|
+
const stepsEl = document.getElementById('checkout-steps-container')
|
|
10
|
+
const contactSection = document.getElementById('checkout-contact')
|
|
11
|
+
const shippingSection = document.getElementById('checkout-shipping')
|
|
12
|
+
const paymentSection = document.getElementById('checkout-payment')
|
|
13
|
+
const summaryEl = document.getElementById('checkout-summary')
|
|
14
|
+
const errorEl = document.getElementById('checkout-error')
|
|
15
|
+
|
|
16
|
+
function showStep(step: Step) {
|
|
17
|
+
currentStep = step
|
|
18
|
+
const sections: Record<Step, HTMLElement | null> = {
|
|
19
|
+
contact: contactSection,
|
|
20
|
+
shipping: shippingSection,
|
|
21
|
+
payment: paymentSection,
|
|
22
|
+
}
|
|
23
|
+
steps.forEach((s) => {
|
|
24
|
+
const el = sections[s]
|
|
25
|
+
if (el) el.style.display = s === step ? '' : 'none'
|
|
26
|
+
})
|
|
27
|
+
updateStepIndicator()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function updateStepIndicator() {
|
|
31
|
+
if (!stepsEl) return
|
|
32
|
+
const currentIndex = steps.indexOf(currentStep)
|
|
33
|
+
steps.forEach((step, index) => {
|
|
34
|
+
const stepEl = stepsEl.querySelector(`[data-testid="checkout-step-${step}"]`)
|
|
35
|
+
const dividerBefore = stepEl?.previousElementSibling
|
|
36
|
+
if (!stepEl) return
|
|
37
|
+
stepEl.className = 'checkout-step'
|
|
38
|
+
if (index < currentIndex) stepEl.classList.add('completed')
|
|
39
|
+
else if (index === currentIndex) stepEl.classList.add('active')
|
|
40
|
+
if (dividerBefore?.classList.contains('checkout-step-divider')) {
|
|
41
|
+
dividerBefore.className = 'checkout-step-divider'
|
|
42
|
+
if (index <= currentIndex) dividerBefore.classList.add('completed')
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function showError(message: string) {
|
|
48
|
+
if (errorEl) {
|
|
49
|
+
errorEl.textContent = message
|
|
50
|
+
errorEl.style.display = 'block'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hideError() {
|
|
55
|
+
if (errorEl) {
|
|
56
|
+
errorEl.style.display = 'none'
|
|
57
|
+
errorEl.textContent = ''
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getFormValues(form: HTMLFormElement): Record<string, string> {
|
|
62
|
+
const data = new FormData(form)
|
|
63
|
+
const result: Record<string, string> = {}
|
|
64
|
+
data.forEach((value, key) => {
|
|
65
|
+
result[key] = String(value)
|
|
66
|
+
})
|
|
67
|
+
return result
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderSummary() {
|
|
71
|
+
if (!summaryEl) return
|
|
72
|
+
const cartState = client.cart.getState()
|
|
73
|
+
const items = cartState.items ?? []
|
|
74
|
+
const currency = cartState.currency ?? 'GBP'
|
|
75
|
+
|
|
76
|
+
summaryEl.innerHTML = `
|
|
77
|
+
<h2 class="checkout-summary-title">Order Summary</h2>
|
|
78
|
+
<div class="checkout-summary-items">
|
|
79
|
+
${items
|
|
80
|
+
.map(
|
|
81
|
+
(item) => `
|
|
82
|
+
<div class="checkout-summary-item">
|
|
83
|
+
<span class="checkout-summary-item-name">${item.productName}</span>
|
|
84
|
+
<span class="checkout-summary-item-qty">x${item.qty}</span>
|
|
85
|
+
<span class="checkout-summary-item-price">${formatPrice(item.priceMinor * item.qty, currency)}</span>
|
|
86
|
+
</div>
|
|
87
|
+
`,
|
|
88
|
+
)
|
|
89
|
+
.join('')}
|
|
90
|
+
</div>
|
|
91
|
+
<dl>
|
|
92
|
+
<div class="checkout-summary-row">
|
|
93
|
+
<dt>Subtotal</dt>
|
|
94
|
+
<dd>${formatPrice(cartState.subtotalMinor, currency)}</dd>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="checkout-summary-row">
|
|
97
|
+
<dt>Shipping</dt>
|
|
98
|
+
<dd>Calculated next</dd>
|
|
99
|
+
</div>
|
|
100
|
+
</dl>
|
|
101
|
+
<div class="checkout-summary-total">
|
|
102
|
+
<span>Total</span>
|
|
103
|
+
<span>${formatPrice(cartState.subtotalMinor, currency)}</span>
|
|
104
|
+
</div>
|
|
105
|
+
`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Contact form
|
|
109
|
+
const contactForm = document.getElementById('contact-form') as HTMLFormElement | null
|
|
110
|
+
contactForm?.addEventListener('submit', (e) => {
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
hideError()
|
|
113
|
+
const values = getFormValues(contactForm)
|
|
114
|
+
if (!values.email) {
|
|
115
|
+
showError('Please enter your email address.')
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
client.checkout.setEmail(values.email)
|
|
119
|
+
showStep('shipping')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// Shipping form
|
|
123
|
+
const shippingForm = document.getElementById('shipping-form') as HTMLFormElement | null
|
|
124
|
+
shippingForm?.addEventListener('submit', (e) => {
|
|
125
|
+
e.preventDefault()
|
|
126
|
+
hideError()
|
|
127
|
+
const values = getFormValues(shippingForm)
|
|
128
|
+
|
|
129
|
+
if (!values.addressLine1 || !values.city || !values.postalCode || !values.country) {
|
|
130
|
+
showError('Please complete your shipping address.')
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
client.checkout.setShippingAddress({
|
|
135
|
+
firstName: values.firstName ?? '',
|
|
136
|
+
lastName: values.lastName ?? '',
|
|
137
|
+
addressLine1: values.addressLine1,
|
|
138
|
+
addressLine2: values.addressLine2 || undefined,
|
|
139
|
+
city: values.city,
|
|
140
|
+
region: values.region ?? '',
|
|
141
|
+
postalCode: values.postalCode,
|
|
142
|
+
country: values.country,
|
|
143
|
+
})
|
|
144
|
+
client.checkout.setShippingMethod({
|
|
145
|
+
carrier: 'Standard',
|
|
146
|
+
service: 'Standard Shipping',
|
|
147
|
+
amountMinor: 0,
|
|
148
|
+
currency: client.cart.getState().currency ?? 'GBP',
|
|
149
|
+
estimatedDays: 5,
|
|
150
|
+
})
|
|
151
|
+
showStep('payment')
|
|
152
|
+
})
|
|
153
|
+
|
|
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
|
|
161
|
+
|
|
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
|
|
175
|
+
|
|
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
|
+
})
|
|
186
|
+
|
|
187
|
+
const cardContainer = document.getElementById('stripe-card-element')
|
|
188
|
+
if (cardContainer) cardElement.mount(cardContainer)
|
|
189
|
+
} catch {
|
|
190
|
+
// Stripe unavailable
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
mountStripe()
|
|
195
|
+
|
|
196
|
+
// Payment form
|
|
197
|
+
const paymentForm = document.getElementById('payment-form') as HTMLFormElement | null
|
|
198
|
+
paymentForm?.addEventListener('submit', async (e) => {
|
|
199
|
+
e.preventDefault()
|
|
200
|
+
hideError()
|
|
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
|
+
}
|
|
209
|
+
const stay = (message: string) => {
|
|
210
|
+
showError(message)
|
|
211
|
+
if (submitBtn) {
|
|
212
|
+
submitBtn.disabled = false
|
|
213
|
+
submitBtn.textContent = 'Place Order'
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const leaveFor = (orderNumber: string) => {
|
|
217
|
+
client.checkout.reset()
|
|
218
|
+
window.location.href = `/order/${encodeURIComponent(orderNumber)}`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const sameAsShipping = (document.getElementById('billing-same') as HTMLInputElement)?.checked
|
|
223
|
+
if (sameAsShipping) {
|
|
224
|
+
const shippingAddr = client.checkout.getState().shippingAddress
|
|
225
|
+
if (shippingAddr) client.checkout.setBillingAddress(shippingAddr)
|
|
226
|
+
} else {
|
|
227
|
+
const billingForm = document.getElementById('billing-form') as HTMLFormElement
|
|
228
|
+
const values = getFormValues(billingForm)
|
|
229
|
+
client.checkout.setBillingAddress({
|
|
230
|
+
firstName: values.firstName ?? '',
|
|
231
|
+
lastName: values.lastName ?? '',
|
|
232
|
+
addressLine1: values.addressLine1,
|
|
233
|
+
addressLine2: values.addressLine2 || undefined,
|
|
234
|
+
city: values.city,
|
|
235
|
+
region: values.region ?? '',
|
|
236
|
+
postalCode: values.postalCode,
|
|
237
|
+
country: values.country,
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const result = await client.checkout.submitPayment()
|
|
242
|
+
if (result === null) {
|
|
243
|
+
// A resubmit of a cart that already became an order (Back, then Place Order) goes to that order.
|
|
244
|
+
const { placedOrderNumber, error } = client.checkout.getState()
|
|
245
|
+
if (placedOrderNumber) return leaveFor(placedOrderNumber)
|
|
246
|
+
return stay(error ?? 'Payment failed. Please try again.')
|
|
247
|
+
}
|
|
248
|
+
if (result.payment.status === 'failed') {
|
|
249
|
+
return stay(client.checkout.getState().error ?? 'Payment failed. Please try again.')
|
|
250
|
+
}
|
|
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 },
|
|
255
|
+
})
|
|
256
|
+
if (error) return stay(error.message ?? 'Payment failed.')
|
|
257
|
+
client.checkout.markConfirmed()
|
|
258
|
+
}
|
|
259
|
+
leaveFor(result.orderNumber)
|
|
260
|
+
} catch (err: any) {
|
|
261
|
+
stay(err.message ?? 'Payment failed. Please try again.')
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Billing same-as-shipping toggle
|
|
266
|
+
const billingSameCheckbox = document.getElementById('billing-same') as HTMLInputElement | null
|
|
267
|
+
const billingFields = document.getElementById('billing-fields')
|
|
268
|
+
billingSameCheckbox?.addEventListener('change', () => {
|
|
269
|
+
if (billingFields) {
|
|
270
|
+
billingFields.style.display = billingSameCheckbox.checked ? 'none' : ''
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Init
|
|
275
|
+
showStep('contact')
|
|
276
|
+
renderSummary()
|
|
277
|
+
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
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ProductSortKey } from '@nymbalhq/types'
|
|
2
|
+
import { client } from '../lib/client.js'
|
|
3
|
+
import { escapeHtml, formatPrice } from '../lib/format.js'
|
|
4
|
+
|
|
5
|
+
const gridContainer = document.getElementById('product-grid')
|
|
6
|
+
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null
|
|
7
|
+
const loadMoreBtn = document.getElementById('load-more-btn')
|
|
8
|
+
const countEl = document.getElementById('product-count')
|
|
9
|
+
|
|
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.
|
|
12
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
13
|
+
const initialCategory = gridContainer?.dataset.category ?? urlParams.get('category') ?? undefined
|
|
14
|
+
const initialSort = (sortSelect?.value ?? 'newest') as ProductSortKey
|
|
15
|
+
|
|
16
|
+
function renderProductCard(product: any): string {
|
|
17
|
+
const imageUrl =
|
|
18
|
+
product.imageUrl ??
|
|
19
|
+
(Array.isArray(product.media) && product.media[0]?.url) ??
|
|
20
|
+
`https://picsum.photos/seed/${product.slug}/600/600`
|
|
21
|
+
// The "from" price, which the price sorts order by.
|
|
22
|
+
const fromMinor = product.priceRange?.minMinor ?? product.priceMinor
|
|
23
|
+
const price = fromMinor != null ? formatPrice(fromMinor, product.currency ?? 'GBP') : ''
|
|
24
|
+
|
|
25
|
+
const slug = escapeHtml(product.slug)
|
|
26
|
+
const name = escapeHtml(product.name)
|
|
27
|
+
|
|
28
|
+
return `
|
|
29
|
+
<a href="/product/${slug}" class="product-card" data-testid="product-card-${slug}">
|
|
30
|
+
<img
|
|
31
|
+
src="${escapeHtml(imageUrl)}"
|
|
32
|
+
alt="${name}"
|
|
33
|
+
class="product-card-image"
|
|
34
|
+
loading="lazy"
|
|
35
|
+
width="600"
|
|
36
|
+
height="600"
|
|
37
|
+
/>
|
|
38
|
+
<div class="product-card-body">
|
|
39
|
+
<span class="product-card-name">${name}</span>
|
|
40
|
+
${price ? `<span class="product-card-price">${escapeHtml(price)}</span>` : ''}
|
|
41
|
+
</div>
|
|
42
|
+
</a>
|
|
43
|
+
`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderGrid() {
|
|
47
|
+
const state = client.productList.getState()
|
|
48
|
+
const products = state.products ?? []
|
|
49
|
+
|
|
50
|
+
if (!gridContainer) return
|
|
51
|
+
|
|
52
|
+
// Don't overwrite the grid with an empty state while a load is in flight.
|
|
53
|
+
// This prevents the SSR content from being replaced by "No products found"
|
|
54
|
+
// during the brief window between applyFilter setting filters and the API
|
|
55
|
+
// responding with the filtered set.
|
|
56
|
+
if (products.length === 0 && state.loading) return
|
|
57
|
+
|
|
58
|
+
if (products.length === 0) {
|
|
59
|
+
gridContainer.innerHTML = `
|
|
60
|
+
<div class="plp-empty">
|
|
61
|
+
<h2 class="plp-empty-title">No products found</h2>
|
|
62
|
+
<p class="plp-empty-text">Try adjusting your filters or search terms.</p>
|
|
63
|
+
</div>
|
|
64
|
+
`
|
|
65
|
+
} else {
|
|
66
|
+
gridContainer.innerHTML = products.map(renderProductCard).join('')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Update count
|
|
70
|
+
if (countEl) {
|
|
71
|
+
countEl.textContent = `${products.length} product${products.length === 1 ? '' : 's'}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Toggle load more
|
|
75
|
+
if (loadMoreBtn) {
|
|
76
|
+
loadMoreBtn.style.display = state.pagination.hasMore ? '' : 'none'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The first load's own answer is the server-rendered grid, so it is not re-rendered: subscribing after it keeps the
|
|
81
|
+
// subscription to re-renders (sort, filter, load-more). A sort or filter chosen while it was in flight reached the
|
|
82
|
+
// store with nothing subscribed, so that state is rendered here.
|
|
83
|
+
async function init() {
|
|
84
|
+
await client.productList.load({ limit: 24, category: initialCategory, sort: initialSort })
|
|
85
|
+
client.productList.subscribe(renderGrid)
|
|
86
|
+
const state = client.productList.getState()
|
|
87
|
+
const filters = Object.entries(state.filters)
|
|
88
|
+
const changed =
|
|
89
|
+
state.sort !== initialSort ||
|
|
90
|
+
filters.length !== (initialCategory === undefined ? 0 : 1) ||
|
|
91
|
+
filters.some(([name, value]) => name !== 'category' || value !== initialCategory)
|
|
92
|
+
if (state.loading || changed) renderGrid()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
void init()
|
|
96
|
+
|
|
97
|
+
// 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
|
|
100
|
+
const url = new URL(window.location.href)
|
|
101
|
+
url.searchParams.set('sort', sort)
|
|
102
|
+
window.history.replaceState(window.history.state, '', url)
|
|
103
|
+
void client.productList.setSort(sort)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Load more handler
|
|
107
|
+
loadMoreBtn?.addEventListener('click', () => {
|
|
108
|
+
client.productList.loadMore()
|
|
109
|
+
})
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { client } from '../lib/client.js'
|
|
2
|
+
import { formatPrice } from '../lib/format.js'
|
|
3
|
+
|
|
4
|
+
const resultsContainer = document.getElementById('search-results')
|
|
5
|
+
const queryLabel = document.getElementById('search-query-label')
|
|
6
|
+
const searchInput = document.getElementById('search-input') as HTMLInputElement | null
|
|
7
|
+
|
|
8
|
+
/** Builds an element; dynamic values go in through setAttribute and text nodes, never through markup. */
|
|
9
|
+
function el(
|
|
10
|
+
tag: string,
|
|
11
|
+
attrs: Record<string, string> = {},
|
|
12
|
+
...children: (Node | string)[]
|
|
13
|
+
): HTMLElement {
|
|
14
|
+
const node = document.createElement(tag)
|
|
15
|
+
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value)
|
|
16
|
+
node.append(...children)
|
|
17
|
+
return node
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderProductCard(product: any): HTMLElement {
|
|
21
|
+
const slug = String(product.slug ?? '')
|
|
22
|
+
const name = String(product.name ?? '')
|
|
23
|
+
const imageUrl =
|
|
24
|
+
product.imageUrl ??
|
|
25
|
+
(Array.isArray(product.media) && product.media[0]?.url) ??
|
|
26
|
+
`https://picsum.photos/seed/${encodeURIComponent(slug)}/600/600`
|
|
27
|
+
const price =
|
|
28
|
+
product.priceMinor != null ? formatPrice(product.priceMinor, product.currency ?? 'GBP') : ''
|
|
29
|
+
|
|
30
|
+
const body = el(
|
|
31
|
+
'div',
|
|
32
|
+
{ class: 'product-card-body' },
|
|
33
|
+
el('span', { class: 'product-card-name' }, name),
|
|
34
|
+
)
|
|
35
|
+
if (price) body.append(el('span', { class: 'product-card-price' }, price))
|
|
36
|
+
|
|
37
|
+
return el(
|
|
38
|
+
'a',
|
|
39
|
+
{
|
|
40
|
+
href: `/product/${encodeURIComponent(slug)}`,
|
|
41
|
+
class: 'product-card',
|
|
42
|
+
'data-testid': `product-card-${slug}`,
|
|
43
|
+
},
|
|
44
|
+
el('img', {
|
|
45
|
+
src: String(imageUrl),
|
|
46
|
+
alt: name,
|
|
47
|
+
class: 'product-card-image',
|
|
48
|
+
loading: 'lazy',
|
|
49
|
+
width: '600',
|
|
50
|
+
height: '600',
|
|
51
|
+
}),
|
|
52
|
+
body,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function renderResults() {
|
|
57
|
+
const state = client.search.getState()
|
|
58
|
+
const results = state.results ?? []
|
|
59
|
+
const query = state.query ?? ''
|
|
60
|
+
const isLoading = state.loading ?? false
|
|
61
|
+
|
|
62
|
+
if (!resultsContainer) return
|
|
63
|
+
|
|
64
|
+
// Update query label
|
|
65
|
+
if (queryLabel) {
|
|
66
|
+
if (query) {
|
|
67
|
+
queryLabel.replaceChildren('Results for ', el('strong', {}, `"${query}"`))
|
|
68
|
+
queryLabel.style.display = ''
|
|
69
|
+
} else {
|
|
70
|
+
queryLabel.style.display = 'none'
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (isLoading) {
|
|
75
|
+
resultsContainer.innerHTML = `
|
|
76
|
+
<div class="search-loading">Searching...</div>
|
|
77
|
+
`
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!query) {
|
|
82
|
+
resultsContainer.innerHTML = `
|
|
83
|
+
<div class="search-empty">
|
|
84
|
+
<div class="search-empty-icon" aria-hidden="true">🔍</div>
|
|
85
|
+
<h2 class="search-empty-title">Search our store</h2>
|
|
86
|
+
<p class="search-empty-text">Type a keyword to find products.</p>
|
|
87
|
+
</div>
|
|
88
|
+
`
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (results.length === 0) {
|
|
93
|
+
resultsContainer.innerHTML = `
|
|
94
|
+
<div class="search-empty">
|
|
95
|
+
<div class="search-empty-icon" aria-hidden="true">🔍</div>
|
|
96
|
+
<h2 class="search-empty-title">No results found</h2>
|
|
97
|
+
<p class="search-empty-text">Try a different search term or browse our categories.</p>
|
|
98
|
+
</div>
|
|
99
|
+
`
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
resultsContainer.replaceChildren(
|
|
104
|
+
el(
|
|
105
|
+
'div',
|
|
106
|
+
{ class: 'search-results-header' },
|
|
107
|
+
el(
|
|
108
|
+
'span',
|
|
109
|
+
{ class: 'search-result-count' },
|
|
110
|
+
`${results.length} result${results.length === 1 ? '' : 's'}`,
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
el('div', { class: 'product-grid' }, ...results.map(renderProductCard)),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
client.search.subscribe(renderResults)
|
|
118
|
+
|
|
119
|
+
// Read initial query from URL
|
|
120
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
121
|
+
const initialQuery = urlParams.get('q')
|
|
122
|
+
|
|
123
|
+
if (initialQuery) {
|
|
124
|
+
if (searchInput) searchInput.value = initialQuery
|
|
125
|
+
client.search.search(initialQuery)
|
|
126
|
+
} else {
|
|
127
|
+
renderResults()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Listen for search input changes
|
|
131
|
+
searchInput?.addEventListener('input', () => {
|
|
132
|
+
const value = searchInput.value.trim()
|
|
133
|
+
if (value.length >= 2) {
|
|
134
|
+
client.search.search(value)
|
|
135
|
+
// Update URL without reload
|
|
136
|
+
const url = new URL(window.location.href)
|
|
137
|
+
url.searchParams.set('q', value)
|
|
138
|
+
window.history.replaceState({}, '', url.toString())
|
|
139
|
+
} else if (value.length === 0) {
|
|
140
|
+
client.search.clearSearch()
|
|
141
|
+
const url = new URL(window.location.href)
|
|
142
|
+
url.searchParams.delete('q')
|
|
143
|
+
window.history.replaceState({}, '', url.toString())
|
|
144
|
+
}
|
|
145
|
+
})
|