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