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,523 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, type FormEvent } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useCheckout, useCart, useNymbalClient } from '@nymbalhq/react'
6
+ import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'
7
+ import { loadStripe } from '@stripe/stripe-js'
8
+ import { formatPrice } from '@/lib/format'
9
+ import styles from '@/styles/pages/checkout.module.css'
10
+
11
+ // The env says only whether a Stripe client exists; the payment flow follows the server's answer.
12
+ const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? ''
13
+ const isStub = process.env.NEXT_PUBLIC_NYMBAL_PAYMENTS_PROVIDER === 'native-stub' || !stripeKey
14
+ const stripePromise = isStub ? null : loadStripe(stripeKey)
15
+
16
+ import type { Address } from '@nymbalhq/types'
17
+ type AddressFields = Pick<
18
+ Address,
19
+ 'firstName' | 'lastName' | 'addressLine1' | 'city' | 'region' | 'postalCode' | 'country'
20
+ > & { addressLine2: string }
21
+
22
+ const emptyAddress: AddressFields = {
23
+ firstName: '',
24
+ lastName: '',
25
+ addressLine1: '',
26
+ addressLine2: '',
27
+ city: '',
28
+ region: '',
29
+ postalCode: '',
30
+ country: '',
31
+ }
32
+
33
+ const STEP_LABELS = ['Contact', 'Shipping', 'Payment'] as const
34
+
35
+ function StepIndicator({ current }: { current: number }) {
36
+ return (
37
+ <div className={styles.steps} data-testid="checkout-steps">
38
+ {STEP_LABELS.map((label, i) => (
39
+ <div
40
+ key={label}
41
+ className={`${styles.step} ${i < current ? styles.stepCompleted : ''} ${i === current ? styles.stepActive : ''}`}
42
+ >
43
+ <span className={styles.stepNumber}>{i + 1}</span>
44
+ <span className={styles.stepLabel}>{label}</span>
45
+ </div>
46
+ ))}
47
+ </div>
48
+ )
49
+ }
50
+
51
+ function AddressFormFields({
52
+ prefix,
53
+ values,
54
+ onChange,
55
+ }: {
56
+ prefix: string
57
+ values: AddressFields
58
+ onChange: (field: keyof AddressFields, value: string) => void
59
+ }) {
60
+ return (
61
+ <div className={styles.formGrid}>
62
+ <div>
63
+ <label htmlFor={`${prefix}-firstName`}>First Name</label>
64
+ <input
65
+ id={`${prefix}-firstName`}
66
+ data-testid={`${prefix}-firstName`}
67
+ type="text"
68
+ value={values.firstName}
69
+ onChange={(e) => onChange('firstName', e.target.value)}
70
+ required
71
+ autoComplete="given-name"
72
+ />
73
+ </div>
74
+ <div>
75
+ <label htmlFor={`${prefix}-lastName`}>Last Name</label>
76
+ <input
77
+ id={`${prefix}-lastName`}
78
+ data-testid={`${prefix}-lastName`}
79
+ type="text"
80
+ value={values.lastName}
81
+ onChange={(e) => onChange('lastName', e.target.value)}
82
+ required
83
+ autoComplete="family-name"
84
+ />
85
+ </div>
86
+ <div className={styles.formGridFull}>
87
+ <label htmlFor={`${prefix}-addressLine1`}>Address Line 1</label>
88
+ <input
89
+ id={`${prefix}-addressLine1`}
90
+ data-testid={`${prefix}-addressLine1`}
91
+ type="text"
92
+ value={values.addressLine1}
93
+ onChange={(e) => onChange('addressLine1', e.target.value)}
94
+ required
95
+ autoComplete="address-line1"
96
+ />
97
+ </div>
98
+ <div className={styles.formGridFull}>
99
+ <label htmlFor={`${prefix}-addressLine2`}>Address Line 2 (optional)</label>
100
+ <input
101
+ id={`${prefix}-addressLine2`}
102
+ data-testid={`${prefix}-addressLine2`}
103
+ type="text"
104
+ value={values.addressLine2}
105
+ onChange={(e) => onChange('addressLine2', e.target.value)}
106
+ autoComplete="address-line2"
107
+ />
108
+ </div>
109
+ <div>
110
+ <label htmlFor={`${prefix}-city`}>City</label>
111
+ <input
112
+ id={`${prefix}-city`}
113
+ data-testid={`${prefix}-city`}
114
+ type="text"
115
+ value={values.city}
116
+ onChange={(e) => onChange('city', e.target.value)}
117
+ required
118
+ autoComplete="address-level2"
119
+ />
120
+ </div>
121
+ <div>
122
+ <label htmlFor={`${prefix}-region`}>State / County</label>
123
+ <input
124
+ id={`${prefix}-region`}
125
+ data-testid={`${prefix}-region`}
126
+ type="text"
127
+ value={values.region}
128
+ onChange={(e) => onChange('region', e.target.value)}
129
+ required
130
+ autoComplete="address-level1"
131
+ />
132
+ </div>
133
+ <div>
134
+ <label htmlFor={`${prefix}-postalCode`}>Postcode</label>
135
+ <input
136
+ id={`${prefix}-postalCode`}
137
+ data-testid={`${prefix}-postalCode`}
138
+ type="text"
139
+ value={values.postalCode}
140
+ onChange={(e) => onChange('postalCode', e.target.value)}
141
+ required
142
+ autoComplete="postal-code"
143
+ />
144
+ </div>
145
+ <div>
146
+ <label htmlFor={`${prefix}-country`}>Country (2-letter code)</label>
147
+ <input
148
+ id={`${prefix}-country`}
149
+ data-testid={`${prefix}-country`}
150
+ type="text"
151
+ value={values.country}
152
+ onChange={(e) => onChange('country', e.target.value)}
153
+ required
154
+ autoComplete="country"
155
+ placeholder="GB"
156
+ />
157
+ </div>
158
+ </div>
159
+ )
160
+ }
161
+
162
+ function CheckoutFormInner() {
163
+ const router = useRouter()
164
+ const client = useNymbalClient()
165
+ const checkout = useCheckout()
166
+ const cart = useCart()
167
+ const stripe = useStripe()
168
+ const elements = useElements()
169
+
170
+ const [step, setStep] = useState(0)
171
+ const [email, setEmail] = useState('')
172
+ const [shippingAddress, setShippingAddress] = useState<AddressFields>(emptyAddress)
173
+ const [billingAddress, setBillingAddress] = useState<AddressFields>(emptyAddress)
174
+ const [sameAsShipping, setSameAsShipping] = useState(true)
175
+ const [error, setError] = useState<string | null>(null)
176
+ const [processing, setProcessing] = useState(false)
177
+
178
+ const cartCurrency = cart.currency ?? 'GBP'
179
+
180
+ const updateShipping = useCallback((field: keyof AddressFields, value: string) => {
181
+ setShippingAddress((prev) => ({ ...prev, [field]: value }))
182
+ }, [])
183
+
184
+ const updateBilling = useCallback((field: keyof AddressFields, value: string) => {
185
+ setBillingAddress((prev) => ({ ...prev, [field]: value }))
186
+ }, [])
187
+
188
+ const handleContact = useCallback(
189
+ (e: FormEvent) => {
190
+ e.preventDefault()
191
+ setError(null)
192
+ if (!email) {
193
+ setError('Please enter your email address.')
194
+ return
195
+ }
196
+ checkout.setEmail(email)
197
+ setStep(1)
198
+ },
199
+ [email, checkout],
200
+ )
201
+
202
+ const handleShipping = useCallback(
203
+ (e: FormEvent) => {
204
+ e.preventDefault()
205
+ setError(null)
206
+ if (
207
+ !shippingAddress.addressLine1 ||
208
+ !shippingAddress.city ||
209
+ !shippingAddress.postalCode ||
210
+ !shippingAddress.country
211
+ ) {
212
+ setError('Please complete your shipping address.')
213
+ return
214
+ }
215
+ checkout.setShippingAddress({
216
+ firstName: shippingAddress.firstName,
217
+ lastName: shippingAddress.lastName,
218
+ addressLine1: shippingAddress.addressLine1,
219
+ addressLine2: shippingAddress.addressLine2 || undefined,
220
+ city: shippingAddress.city,
221
+ region: shippingAddress.region,
222
+ postalCode: shippingAddress.postalCode,
223
+ country: shippingAddress.country,
224
+ })
225
+ checkout.setShippingMethod({
226
+ carrier: 'Standard',
227
+ service: 'Standard Shipping',
228
+ amountMinor: 0,
229
+ currency: cartCurrency,
230
+ estimatedDays: 5,
231
+ })
232
+ setStep(2)
233
+ },
234
+ [shippingAddress, checkout, cartCurrency],
235
+ )
236
+
237
+ const handlePlaceOrder = useCallback(
238
+ async (e: FormEvent) => {
239
+ e.preventDefault()
240
+ if (!isStub && (!stripe || !elements)) return
241
+
242
+ setProcessing(true)
243
+ setError(null)
244
+
245
+ const billing = sameAsShipping ? shippingAddress : billingAddress
246
+ checkout.setBillingAddress({
247
+ firstName: billing.firstName,
248
+ lastName: billing.lastName,
249
+ addressLine1: billing.addressLine1,
250
+ addressLine2: billing.addressLine2 || undefined,
251
+ city: billing.city,
252
+ region: billing.region,
253
+ postalCode: billing.postalCode,
254
+ country: billing.country,
255
+ })
256
+
257
+ const stay = (message: string) => {
258
+ setError(message)
259
+ setProcessing(false)
260
+ }
261
+ const leaveFor = (orderNumber: string) => {
262
+ router.push(`/order/${encodeURIComponent(orderNumber)}`)
263
+ }
264
+
265
+ try {
266
+ // The submit's own result, never the render-time snapshot, which still holds the state from before it.
267
+ const result = await checkout.submitPayment()
268
+ if (result === null) {
269
+ // A resubmit of a cart that already became an order (Back, then Place Order) goes to that order.
270
+ const { placedOrderNumber, error: submitError } = client.checkout.getState()
271
+ if (placedOrderNumber) return leaveFor(placedOrderNumber)
272
+ return stay(submitError ?? 'Something went wrong.')
273
+ }
274
+ if (result.payment.status === 'failed') {
275
+ return stay(client.checkout.getState().error ?? 'Payment failed.')
276
+ }
277
+ // Without a Stripe client (a +stub-pending order) the order page shows it awaiting payment.
278
+ if (result.payment.status === 'requires_action' && !isStub && stripe && elements) {
279
+ const cardEl = elements.getElement(CardElement)
280
+ if (!cardEl) return stay('Card element not found.')
281
+
282
+ const { error: stripeError } = await stripe.confirmCardPayment(
283
+ result.paymentIntent.clientSecret,
284
+ {
285
+ payment_method: {
286
+ card: cardEl,
287
+ billing_details: {
288
+ email,
289
+ address: {
290
+ line1: billing.addressLine1,
291
+ line2: billing.addressLine2 || undefined,
292
+ city: billing.city,
293
+ state: billing.region,
294
+ postal_code: billing.postalCode,
295
+ country: billing.country,
296
+ },
297
+ },
298
+ },
299
+ },
300
+ )
301
+
302
+ if (stripeError) return stay(stripeError.message ?? 'Payment failed.')
303
+ checkout.markConfirmed()
304
+ }
305
+ leaveFor(result.orderNumber)
306
+ } catch (err) {
307
+ stay(err instanceof Error ? err.message : 'Something went wrong.')
308
+ }
309
+ },
310
+ [
311
+ stripe,
312
+ elements,
313
+ email,
314
+ shippingAddress,
315
+ billingAddress,
316
+ sameAsShipping,
317
+ checkout,
318
+ client,
319
+ router,
320
+ ],
321
+ )
322
+
323
+ return (
324
+ <>
325
+ {processing && (
326
+ <div className={styles.processing}>
327
+ <div className="spinner spinner-lg" role="status" />
328
+ <p className={styles.processingText}>Processing your order...</p>
329
+ </div>
330
+ )}
331
+ {/* Hidden, not unmounted, while processing: Stripe confirms with the mounted card element. */}
332
+ <div
333
+ className={`container ${styles.page}`}
334
+ style={processing ? { display: 'none' } : undefined}
335
+ >
336
+ <h1 className={styles.pageTitle}>Checkout</h1>
337
+ <StepIndicator current={step} />
338
+
339
+ <div className={styles.layout}>
340
+ <div className={styles.formArea}>
341
+ {error && (
342
+ <div className={styles.paymentError} role="alert">
343
+ {error}
344
+ </div>
345
+ )}
346
+
347
+ {step === 0 && (
348
+ <form onSubmit={handleContact}>
349
+ <div className={styles.formSection}>
350
+ <h2 className={styles.formSectionTitle}>Contact Information</h2>
351
+ <div className="form-group">
352
+ <label htmlFor="checkout-email">Email Address</label>
353
+ <input
354
+ id="contact-email"
355
+ type="email"
356
+ value={email}
357
+ onChange={(e) => setEmail(e.target.value)}
358
+ required
359
+ data-testid="contact-email"
360
+ placeholder="you@example.com"
361
+ autoComplete="email"
362
+ />
363
+ </div>
364
+ </div>
365
+ <div className={styles.formActions}>
366
+ <span />
367
+ <button type="submit" className="btn btn-primary" data-testid="contact-continue">
368
+ Continue to Shipping
369
+ </button>
370
+ </div>
371
+ </form>
372
+ )}
373
+
374
+ {step === 1 && (
375
+ <form onSubmit={handleShipping}>
376
+ <div className={styles.formSection} data-testid="shipping-address">
377
+ <h2 className={styles.formSectionTitle}>Shipping Address</h2>
378
+ <AddressFormFields
379
+ prefix="shipping"
380
+ values={shippingAddress}
381
+ onChange={updateShipping}
382
+ />
383
+ </div>
384
+ <div className={styles.formActions}>
385
+ <button type="button" className="btn btn-secondary" onClick={() => setStep(0)}>
386
+ Back
387
+ </button>
388
+ <button type="submit" className="btn btn-primary" data-testid="shipping-continue">
389
+ Continue to Payment
390
+ </button>
391
+ </div>
392
+ </form>
393
+ )}
394
+
395
+ {step === 2 && (
396
+ <form onSubmit={handlePlaceOrder}>
397
+ <div className={styles.formSection}>
398
+ <h2 className={styles.formSectionTitle}>Billing Address</h2>
399
+ <label
400
+ style={{
401
+ display: 'flex',
402
+ alignItems: 'center',
403
+ gap: 'var(--nymbal-spacing-sm)',
404
+ cursor: 'pointer',
405
+ }}
406
+ >
407
+ <input
408
+ type="checkbox"
409
+ checked={sameAsShipping}
410
+ onChange={(e) => setSameAsShipping(e.target.checked)}
411
+ style={{ width: 'auto' }}
412
+ />
413
+ Same as shipping address
414
+ </label>
415
+ {!sameAsShipping && (
416
+ <div
417
+ data-testid="billing-address"
418
+ style={{ marginTop: 'var(--nymbal-spacing-md)' }}
419
+ >
420
+ <AddressFormFields
421
+ prefix="bill"
422
+ values={billingAddress}
423
+ onChange={updateBilling}
424
+ />
425
+ </div>
426
+ )}
427
+ </div>
428
+
429
+ <div
430
+ className={styles.formSection}
431
+ style={{ marginTop: 'var(--nymbal-spacing-lg)' }}
432
+ >
433
+ <h2 className={styles.formSectionTitle}>Payment</h2>
434
+ {isStub ? (
435
+ <div className={styles.paymentElement} data-testid="card-element">
436
+ <p
437
+ style={{
438
+ color: 'var(--nymbal-color-text-muted, #9ca3af)',
439
+ fontSize: '0.875rem',
440
+ margin: 0,
441
+ }}
442
+ >
443
+ Test mode — payment skipped
444
+ </p>
445
+ </div>
446
+ ) : (
447
+ <div className={styles.paymentElement} data-testid="card-element">
448
+ <CardElement
449
+ options={{
450
+ style: {
451
+ base: {
452
+ fontSize: '16px',
453
+ color: '#1a1a1a',
454
+ '::placeholder': { color: '#9ca3af' },
455
+ },
456
+ },
457
+ }}
458
+ />
459
+ </div>
460
+ )}
461
+ </div>
462
+
463
+ <div className={styles.formActions}>
464
+ <button type="button" className="btn btn-secondary" onClick={() => setStep(1)}>
465
+ Back
466
+ </button>
467
+ <button
468
+ type="submit"
469
+ className="btn btn-primary"
470
+ disabled={(!isStub && !stripe) || processing}
471
+ data-testid="place-order"
472
+ >
473
+ Place Order
474
+ </button>
475
+ </div>
476
+ </form>
477
+ )}
478
+ </div>
479
+
480
+ <aside className={styles.orderSummary}>
481
+ <h2 className={styles.orderSummaryTitle}>Order Summary</h2>
482
+ {cart.items?.map((item) => (
483
+ <div key={item.variantId} className={styles.orderItem}>
484
+ {item.imageUrl && (
485
+ <div className={styles.orderItemImage}>
486
+ <img src={item.imageUrl} alt={item.productName} />
487
+ </div>
488
+ )}
489
+ <div className={styles.orderItemInfo}>
490
+ <p className={styles.orderItemName}>{item.productName}</p>
491
+ <p className={styles.orderItemQty}>Qty: {item.qty}</p>
492
+ </div>
493
+ <span className={styles.orderItemPrice}>
494
+ {formatPrice(item.priceMinor * item.qty, cartCurrency)}
495
+ </span>
496
+ </div>
497
+ ))}
498
+ <hr className={styles.summaryDivider} />
499
+ <div className={styles.summaryRow}>
500
+ <span>Subtotal</span>
501
+ <span data-testid="cart-subtotal">
502
+ {formatPrice(cart.subtotalMinor ?? 0, cartCurrency)}
503
+ </span>
504
+ </div>
505
+ <hr className={styles.summaryDivider} />
506
+ <div className={`${styles.summaryRow} ${styles.summaryTotal}`}>
507
+ <span>Total</span>
508
+ <span>{formatPrice(cart.subtotalMinor ?? 0, cartCurrency)}</span>
509
+ </div>
510
+ </aside>
511
+ </div>
512
+ </div>
513
+ </>
514
+ )
515
+ }
516
+
517
+ export function CheckoutForm() {
518
+ return (
519
+ <Elements stripe={stripePromise}>
520
+ <CheckoutFormInner />
521
+ </Elements>
522
+ )
523
+ }
@@ -0,0 +1,68 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import Image from 'next/image'
5
+ import styles from '@/styles/pages/pdp.module.css'
6
+
7
+ interface GalleryImage {
8
+ url: string
9
+ alt: string
10
+ }
11
+
12
+ interface ImageGalleryProps {
13
+ images: GalleryImage[]
14
+ }
15
+
16
+ export function ImageGallery({ images }: ImageGalleryProps) {
17
+ const [selectedIndex, setSelectedIndex] = useState(0)
18
+
19
+ if (images.length === 0) {
20
+ return (
21
+ <div className={styles.gallery} data-testid="product-gallery">
22
+ <div className={styles.mainImage}>
23
+ <span className="visually-hidden">No images available</span>
24
+ </div>
25
+ </div>
26
+ )
27
+ }
28
+
29
+ const currentImage = images[selectedIndex]
30
+
31
+ return (
32
+ <div className={styles.gallery} data-testid="product-gallery">
33
+ <div className={styles.mainImage}>
34
+ <Image
35
+ src={currentImage.url}
36
+ alt={currentImage.alt}
37
+ width={600}
38
+ height={600}
39
+ sizes="(max-width: 1023px) 100vw, 50vw"
40
+ priority
41
+ />
42
+ </div>
43
+ {images.length > 1 && (
44
+ <div className={styles.thumbnails} role="list">
45
+ {images.map((image, index) => (
46
+ <button
47
+ key={index}
48
+ type="button"
49
+ className={`${styles.thumbnail} ${
50
+ index === selectedIndex ? styles.thumbnailActive : ''
51
+ }`}
52
+ onClick={() => setSelectedIndex(index)}
53
+ aria-label={`View image ${index + 1}`}
54
+ aria-current={index === selectedIndex ? 'true' : undefined}
55
+ >
56
+ <Image
57
+ src={image.url}
58
+ alt={image.alt}
59
+ width={80}
60
+ height={80}
61
+ />
62
+ </button>
63
+ ))}
64
+ </div>
65
+ )}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, type FormEvent } from 'react'
4
+ import { useRouter, useSearchParams } from 'next/navigation'
5
+ import Link from 'next/link'
6
+ import { useAuth } from '@nymbalhq/react'
7
+ import styles from '@/styles/pages/account.module.css'
8
+
9
+ export function LoginForm() {
10
+ const router = useRouter()
11
+ const searchParams = useSearchParams()
12
+ const { login } = useAuth()
13
+
14
+ const [email, setEmail] = useState('')
15
+ const [password, setPassword] = useState('')
16
+ const [error, setError] = useState<string | null>(null)
17
+ const [loading, setLoading] = useState(false)
18
+
19
+ const handleSubmit = useCallback(
20
+ async (e: FormEvent) => {
21
+ e.preventDefault()
22
+ setError(null)
23
+ setLoading(true)
24
+
25
+ try {
26
+ await login(email, password)
27
+ const redirect = searchParams.get('redirect') ?? '/account'
28
+ router.push(redirect)
29
+ } catch (err) {
30
+ setError(err instanceof Error ? err.message : 'Invalid email or password.')
31
+ setLoading(false)
32
+ }
33
+ },
34
+ [email, password, login, router, searchParams],
35
+ )
36
+
37
+ return (
38
+ <div className={styles.authPage}>
39
+ <div className={styles.authCard} data-testid="login-form">
40
+ <h1 className={styles.authTitle}>Sign In</h1>
41
+ <p className={styles.authSubtitle}>Welcome back. Enter your credentials to continue.</p>
42
+
43
+ {error && <div className={styles.alertError + ' ' + styles.alert}>{error}</div>}
44
+
45
+ <form onSubmit={handleSubmit} className={styles.authForm}>
46
+ <div className="form-group">
47
+ <label htmlFor="login-email">Email</label>
48
+ <input
49
+ id="login-email"
50
+ type="email"
51
+ value={email}
52
+ onChange={(e) => setEmail(e.target.value)}
53
+ required
54
+ data-testid="login-email"
55
+ placeholder="you@example.com"
56
+ autoComplete="email"
57
+ />
58
+ </div>
59
+ <div className="form-group">
60
+ <label htmlFor="login-password">Password</label>
61
+ <input
62
+ id="login-password"
63
+ type="password"
64
+ value={password}
65
+ onChange={(e) => setPassword(e.target.value)}
66
+ required
67
+ data-testid="login-password"
68
+ placeholder="Enter your password"
69
+ autoComplete="current-password"
70
+ />
71
+ </div>
72
+ <button
73
+ type="submit"
74
+ className={styles.authSubmit}
75
+ disabled={loading}
76
+ data-testid="login-submit"
77
+ >
78
+ {loading ? 'Signing in...' : 'Sign In'}
79
+ </button>
80
+ </form>
81
+
82
+ <p className={styles.authFooter}>
83
+ Don&apos;t have an account?{' '}
84
+ <Link href="/account/register">Create one</Link>
85
+ </p>
86
+ </div>
87
+ </div>
88
+ )
89
+ }
@@ -0,0 +1,12 @@
1
+ 'use client'
2
+
3
+ import { useCallback } from 'react'
4
+ import { MiniCart } from '@nymbalhq/react'
5
+
6
+ export function MiniCartWrapper() {
7
+ const handleClick = useCallback(() => {
8
+ window.dispatchEvent(new CustomEvent('nymbal:cart:open'))
9
+ }, [])
10
+
11
+ return <MiniCart onClick={handleClick} />
12
+ }