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.
Files changed (159) hide show
  1. package/bin/create-nymbal-app.mjs +9 -0
  2. package/dist/index.d.ts +37 -0
  3. package/dist/index.js +419 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +41 -7
  6. package/scaffold/project/astro/src/pages/index.astro +57 -0
  7. package/scaffold/templates/astro/README.md +26 -0
  8. package/scaffold/templates/astro/_dot.env.example +6 -0
  9. package/scaffold/templates/astro/_dot.gitignore +16 -0
  10. package/scaffold/templates/astro/_dot.node-version +1 -0
  11. package/scaffold/templates/astro/_dot.npmrc +1 -0
  12. package/scaffold/templates/astro/astro.config.mjs +14 -0
  13. package/scaffold/templates/astro/package.json +59 -0
  14. package/scaffold/templates/astro/src/components/AddressForm.astro +107 -0
  15. package/scaffold/templates/astro/src/components/Breadcrumb.astro +20 -0
  16. package/scaffold/templates/astro/src/components/CategoryNav.astro +27 -0
  17. package/scaffold/templates/astro/src/components/CheckoutSteps.astro +29 -0
  18. package/scaffold/templates/astro/src/components/Footer.astro +108 -0
  19. package/scaffold/templates/astro/src/components/Header.astro +192 -0
  20. package/scaffold/templates/astro/src/components/HeroSection.astro +17 -0
  21. package/scaffold/templates/astro/src/components/ImageGallery.astro +42 -0
  22. package/scaffold/templates/astro/src/components/NewsletterSignup.astro +39 -0
  23. package/scaffold/templates/astro/src/components/OrderSummary.astro +53 -0
  24. package/scaffold/templates/astro/src/components/Pagination.astro +13 -0
  25. package/scaffold/templates/astro/src/components/PriceDisplay.astro +25 -0
  26. package/scaffold/templates/astro/src/components/ProductCard.astro +44 -0
  27. package/scaffold/templates/astro/src/components/RelatedProducts.astro +28 -0
  28. package/scaffold/templates/astro/src/components/ReviewForm.astro +100 -0
  29. package/scaffold/templates/astro/src/components/ReviewList.astro +41 -0
  30. package/scaffold/templates/astro/src/components/SkipNav.astro +3 -0
  31. package/scaffold/templates/astro/src/components/SortDropdown.astro +26 -0
  32. package/scaffold/templates/astro/src/components/StockBadge.astro +32 -0
  33. package/scaffold/templates/astro/src/env.d.ts +2 -0
  34. package/scaffold/templates/astro/src/layouts/AccountLayout.astro +168 -0
  35. package/scaffold/templates/astro/src/layouts/BaseLayout.astro +55 -0
  36. package/scaffold/templates/astro/src/lib/api.ts +89 -0
  37. package/scaffold/templates/astro/src/lib/client.ts +13 -0
  38. package/scaffold/templates/astro/src/lib/format.ts +17 -0
  39. package/scaffold/templates/astro/src/lib/order-status.ts +14 -0
  40. package/scaffold/templates/astro/src/lib/seo.ts +62 -0
  41. package/scaffold/templates/astro/src/pages/404.astro +18 -0
  42. package/scaffold/templates/astro/src/pages/500.astro +17 -0
  43. package/scaffold/templates/astro/src/pages/account/addresses.astro +110 -0
  44. package/scaffold/templates/astro/src/pages/account/index.astro +67 -0
  45. package/scaffold/templates/astro/src/pages/account/login.astro +55 -0
  46. package/scaffold/templates/astro/src/pages/account/orders/[orderNumber].astro +108 -0
  47. package/scaffold/templates/astro/src/pages/account/orders.astro +81 -0
  48. package/scaffold/templates/astro/src/pages/account/profile.astro +131 -0
  49. package/scaffold/templates/astro/src/pages/account/register.astro +94 -0
  50. package/scaffold/templates/astro/src/pages/cart.astro +16 -0
  51. package/scaffold/templates/astro/src/pages/category/[slug].astro +73 -0
  52. package/scaffold/templates/astro/src/pages/checkout.astro +100 -0
  53. package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -0
  54. package/scaffold/templates/astro/src/pages/fragments/header.astro +5 -0
  55. package/scaffold/templates/astro/src/pages/fragments/nav.astro +9 -0
  56. package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +14 -0
  57. package/scaffold/templates/astro/src/pages/index.astro +49 -0
  58. package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +127 -0
  59. package/scaffold/templates/astro/src/pages/product/[slug].astro +107 -0
  60. package/scaffold/templates/astro/src/pages/products/index.astro +62 -0
  61. package/scaffold/templates/astro/src/pages/search.astro +37 -0
  62. package/scaffold/templates/astro/src/scripts/account-guard.ts +22 -0
  63. package/scaffold/templates/astro/src/scripts/auth-forms.ts +89 -0
  64. package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
  65. package/scaffold/templates/astro/src/scripts/checkout.ts +277 -0
  66. package/scaffold/templates/astro/src/scripts/image-gallery.ts +22 -0
  67. package/scaffold/templates/astro/src/scripts/init-client.ts +16 -0
  68. package/scaffold/templates/astro/src/scripts/order-status.ts +27 -0
  69. package/scaffold/templates/astro/src/scripts/pdp.ts +67 -0
  70. package/scaffold/templates/astro/src/scripts/plp.ts +109 -0
  71. package/scaffold/templates/astro/src/scripts/search.ts +145 -0
  72. package/scaffold/templates/astro/src/styles/global.css +362 -0
  73. package/scaffold/templates/astro/src/styles/pages/account.css +331 -0
  74. package/scaffold/templates/astro/src/styles/pages/cart.css +243 -0
  75. package/scaffold/templates/astro/src/styles/pages/checkout.css +270 -0
  76. package/scaffold/templates/astro/src/styles/pages/confirmation.css +142 -0
  77. package/scaffold/templates/astro/src/styles/pages/error.css +69 -0
  78. package/scaffold/templates/astro/src/styles/pages/home.css +269 -0
  79. package/scaffold/templates/astro/src/styles/pages/pdp.css +272 -0
  80. package/scaffold/templates/astro/src/styles/pages/plp.css +213 -0
  81. package/scaffold/templates/astro/src/styles/pages/search.css +152 -0
  82. package/scaffold/templates/astro/tsconfig.json +3 -0
  83. package/scaffold/templates/nextjs/README.md +26 -0
  84. package/scaffold/templates/nextjs/_dot.env.example +6 -0
  85. package/scaffold/templates/nextjs/_dot.gitignore +17 -0
  86. package/scaffold/templates/nextjs/_dot.node-version +1 -0
  87. package/scaffold/templates/nextjs/_dot.npmrc +1 -0
  88. package/scaffold/templates/nextjs/app/account/addresses/page.tsx +10 -0
  89. package/scaffold/templates/nextjs/app/account/layout.tsx +57 -0
  90. package/scaffold/templates/nextjs/app/account/login/page.tsx +23 -0
  91. package/scaffold/templates/nextjs/app/account/orders/[orderNumber]/page.tsx +192 -0
  92. package/scaffold/templates/nextjs/app/account/orders/page.tsx +123 -0
  93. package/scaffold/templates/nextjs/app/account/page.tsx +59 -0
  94. package/scaffold/templates/nextjs/app/account/profile/page.tsx +10 -0
  95. package/scaffold/templates/nextjs/app/account/register/page.tsx +10 -0
  96. package/scaffold/templates/nextjs/app/cart/page.tsx +10 -0
  97. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +58 -0
  98. package/scaffold/templates/nextjs/app/checkout/page.tsx +10 -0
  99. package/scaffold/templates/nextjs/app/error.tsx +49 -0
  100. package/scaffold/templates/nextjs/app/globals.css +473 -0
  101. package/scaffold/templates/nextjs/app/layout.tsx +29 -0
  102. package/scaffold/templates/nextjs/app/not-found.tsx +38 -0
  103. package/scaffold/templates/nextjs/app/order/[orderNumber]/page.tsx +127 -0
  104. package/scaffold/templates/nextjs/app/page.tsx +63 -0
  105. package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +111 -0
  106. package/scaffold/templates/nextjs/app/products/page.tsx +40 -0
  107. package/scaffold/templates/nextjs/app/search/page.tsx +24 -0
  108. package/scaffold/templates/nextjs/components/client/AddressManager.tsx +181 -0
  109. package/scaffold/templates/nextjs/components/client/AuthGuard.tsx +55 -0
  110. package/scaffold/templates/nextjs/components/client/CartDrawerToggle.tsx +39 -0
  111. package/scaffold/templates/nextjs/components/client/CartPage.tsx +120 -0
  112. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +523 -0
  113. package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +68 -0
  114. package/scaffold/templates/nextjs/components/client/LoginForm.tsx +89 -0
  115. package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +12 -0
  116. package/scaffold/templates/nextjs/components/client/NewsletterSignup.tsx +74 -0
  117. package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +24 -0
  118. package/scaffold/templates/nextjs/components/client/OrderStatusRefresh.tsx +48 -0
  119. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +148 -0
  120. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +61 -0
  121. package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +112 -0
  122. package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +131 -0
  123. package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +116 -0
  124. package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +27 -0
  125. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +141 -0
  126. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +35 -0
  127. package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +36 -0
  128. package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +29 -0
  129. package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +60 -0
  130. package/scaffold/templates/nextjs/components/server/Footer.module.css +64 -0
  131. package/scaffold/templates/nextjs/components/server/Footer.tsx +79 -0
  132. package/scaffold/templates/nextjs/components/server/Header.module.css +147 -0
  133. package/scaffold/templates/nextjs/components/server/Header.tsx +102 -0
  134. package/scaffold/templates/nextjs/components/server/HeroSection.tsx +18 -0
  135. package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +79 -0
  136. package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +31 -0
  137. package/scaffold/templates/nextjs/components/server/ProductCard.tsx +59 -0
  138. package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +31 -0
  139. package/scaffold/templates/nextjs/components/server/ReviewList.tsx +62 -0
  140. package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -0
  141. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +19 -0
  142. package/scaffold/templates/nextjs/lib/api.ts +89 -0
  143. package/scaffold/templates/nextjs/lib/client.ts +23 -0
  144. package/scaffold/templates/nextjs/lib/format.ts +7 -0
  145. package/scaffold/templates/nextjs/lib/order-status.ts +14 -0
  146. package/scaffold/templates/nextjs/lib/seo.ts +73 -0
  147. package/scaffold/templates/nextjs/next.config.mjs +9 -0
  148. package/scaffold/templates/nextjs/package.json +64 -0
  149. package/scaffold/templates/nextjs/styles/pages/account.module.css +377 -0
  150. package/scaffold/templates/nextjs/styles/pages/cart.module.css +240 -0
  151. package/scaffold/templates/nextjs/styles/pages/checkout.module.css +404 -0
  152. package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +237 -0
  153. package/scaffold/templates/nextjs/styles/pages/error.module.css +132 -0
  154. package/scaffold/templates/nextjs/styles/pages/home.module.css +285 -0
  155. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +319 -0
  156. package/scaffold/templates/nextjs/styles/pages/plp.module.css +280 -0
  157. package/scaffold/templates/nextjs/styles/pages/search.module.css +192 -0
  158. package/scaffold/templates/nextjs/tsconfig.json +41 -0
  159. 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">&#128269;</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">&#128269;</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
+ })