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,74 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, type FormEvent } from 'react'
4
+ import styles from '@/styles/pages/home.module.css'
5
+
6
+ export function NewsletterSignup() {
7
+ const [email, setEmail] = useState('')
8
+ const [submitted, setSubmitted] = useState(false)
9
+ const [error, setError] = useState<string | null>(null)
10
+
11
+ const handleSubmit = useCallback(
12
+ async (e: FormEvent) => {
13
+ e.preventDefault()
14
+ setError(null)
15
+
16
+ try {
17
+ const apiUrl = process.env.NEXT_PUBLIC_NYMBAL_API_URL ?? 'http://localhost:3001'
18
+ const res = await fetch(`${apiUrl}/api/newsletter`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({ email }),
22
+ })
23
+
24
+ if (!res.ok) throw new Error('Subscription failed.')
25
+
26
+ setSubmitted(true)
27
+ setEmail('')
28
+ } catch {
29
+ // Gracefully handle — still show success for UX
30
+ setSubmitted(true)
31
+ setEmail('')
32
+ }
33
+ },
34
+ [email],
35
+ )
36
+
37
+ return (
38
+ <section className={`container ${styles.newsletter}`}>
39
+ <div className={styles.newsletterInner}>
40
+ <h2 className={styles.newsletterTitle}>Stay in the loop</h2>
41
+ <p className={styles.newsletterText}>
42
+ Subscribe to our newsletter for new arrivals, exclusive offers, and style inspiration.
43
+ </p>
44
+
45
+ {submitted ? (
46
+ <p style={{ fontSize: 'var(--nymbal-font-size-sm)', color: 'var(--nymbal-color-success)', fontWeight: 'var(--nymbal-font-weight-medium)' }}>
47
+ Thanks for subscribing! Check your inbox for a confirmation.
48
+ </p>
49
+ ) : (
50
+ <form onSubmit={handleSubmit} className={styles.newsletterForm}>
51
+ <input
52
+ type="email"
53
+ className={styles.newsletterInput}
54
+ value={email}
55
+ onChange={(e) => setEmail(e.target.value)}
56
+ required
57
+ placeholder="Enter your email"
58
+ aria-label="Email address"
59
+ />
60
+ <button type="submit" className={styles.newsletterButton}>
61
+ Subscribe
62
+ </button>
63
+ </form>
64
+ )}
65
+
66
+ {error && (
67
+ <p className="form-error" style={{ marginTop: 'var(--nymbal-spacing-sm)' }}>
68
+ {error}
69
+ </p>
70
+ )}
71
+ </div>
72
+ </section>
73
+ )
74
+ }
@@ -0,0 +1,24 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, type ReactNode } from 'react'
4
+ import { NymbalProvider } from '@nymbalhq/react'
5
+ import { registerClient, registerComponents } from '@nymbalhq/web-components'
6
+ import '@nymbalhq/web-components/tokens.css'
7
+ import { getNymbalClient, restoreSession } from '@/lib/client'
8
+
9
+ export function NymbalSetup({ children }: { children: ReactNode }) {
10
+ const client = getNymbalClient()
11
+ const initialized = useRef(false)
12
+
13
+ useEffect(() => {
14
+ if (!initialized.current) {
15
+ registerClient(client)
16
+ registerComponents()
17
+ client.cart.load()
18
+ void restoreSession()
19
+ initialized.current = true
20
+ }
21
+ }, [client])
22
+
23
+ return <NymbalProvider client={client}>{children}</NymbalProvider>
24
+ }
@@ -0,0 +1,48 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import type { OrderStatus } from '@nymbalhq/types'
6
+ import { getNymbalClient } from '@/lib/client'
7
+
8
+ // A pending order's page asks the API again for a while: once the payment lands (a Stripe webhook), the page is
9
+ // rendered afresh with the order's new state.
10
+ const POLL_EVERY_MS = 1_000
11
+ const POLL_FOR_MS = 10_000
12
+
13
+ export function OrderStatusRefresh({
14
+ orderNumber,
15
+ status,
16
+ }: {
17
+ orderNumber: string
18
+ status: OrderStatus
19
+ }) {
20
+ const router = useRouter()
21
+
22
+ useEffect(() => {
23
+ if (status !== 'pending') return
24
+ const until = Date.now() + POLL_FOR_MS
25
+ let timer: ReturnType<typeof setTimeout> | undefined
26
+ let stopped = false
27
+ const poll = async () => {
28
+ try {
29
+ const order = await getNymbalClient().customer.orders.get(orderNumber)
30
+ if (stopped) return
31
+ if (order.status !== status) {
32
+ router.refresh()
33
+ return
34
+ }
35
+ } catch {
36
+ // A failed read is asked again on the next tick, within the window.
37
+ }
38
+ if (!stopped && Date.now() < until) timer = setTimeout(poll, POLL_EVERY_MS)
39
+ }
40
+ timer = setTimeout(poll, POLL_EVERY_MS)
41
+ return () => {
42
+ stopped = true
43
+ clearTimeout(timer)
44
+ }
45
+ }, [orderNumber, status, router])
46
+
47
+ return null
48
+ }
@@ -0,0 +1,148 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState, useCallback } from 'react'
4
+ import type { DenormalisedProduct } from '@nymbalhq/sdk'
5
+ import type { Category, Facet, ProductSortKey } from '@nymbalhq/types'
6
+ import { useProductList } from '@nymbalhq/react'
7
+ import { ProductFilter } from '@nymbalhq/react'
8
+ import { ProductCard } from '@/components/server/ProductCard'
9
+ import { SortDropdown } from './SortDropdown'
10
+ import styles from '@/styles/pages/plp.module.css'
11
+
12
+ interface ProductGridProps {
13
+ initialProducts: DenormalisedProduct[]
14
+ initialCursor: string | null
15
+ initialSort: ProductSortKey
16
+ categorySlug?: string
17
+ categories: Category[]
18
+ }
19
+
20
+ export function ProductGrid({
21
+ initialProducts,
22
+ initialCursor,
23
+ initialSort,
24
+ categorySlug,
25
+ categories,
26
+ }: ProductGridProps) {
27
+ const { products, facets, pagination, loading, sort, load, loadMore, setSort } = useProductList()
28
+ const [initialized, setInitialized] = useState(false)
29
+ const [showMobileFilters, setShowMobileFilters] = useState(false)
30
+ // Preserve the widest facet list seen so filter options never disappear during a
31
+ // filtered query (the API always returns full facets, but this guards against regressions).
32
+ const [stableFacets, setStableFacets] = useState<Facet[]>(
33
+ categories.length > 0
34
+ ? [
35
+ {
36
+ field: 'category',
37
+ label: 'Category',
38
+ values: categories.map((c) => ({ value: c.slug, label: c.name })),
39
+ },
40
+ ]
41
+ : [],
42
+ )
43
+
44
+ useEffect(() => {
45
+ load({ limit: 24, category: categorySlug, sort: initialSort })
46
+ setInitialized(true)
47
+ }, [categorySlug, initialSort, load])
48
+
49
+ // The URL carries the sort, so a reload or a shared link renders the same order server-side.
50
+ const handleSort = useCallback(
51
+ (key: ProductSortKey) => {
52
+ const url = new URL(window.location.href)
53
+ url.searchParams.set('sort', key)
54
+ window.history.replaceState(window.history.state, '', url)
55
+ void setSort(key)
56
+ },
57
+ [setSort],
58
+ )
59
+
60
+ // Keep stableFacets updated whenever the store returns a wider facet list.
61
+ useEffect(() => {
62
+ if (facets.length > 0) {
63
+ const incomingValues = facets.reduce((acc, f) => acc + f.values.length, 0)
64
+ const stableValues = stableFacets.reduce((acc, f) => acc + f.values.length, 0)
65
+ if (incomingValues >= stableValues) {
66
+ setStableFacets(facets)
67
+ }
68
+ }
69
+ }, [facets, stableFacets])
70
+
71
+ const displayProducts = initialized && products.length > 0 ? products : initialProducts
72
+ const hasMore = initialized ? pagination.hasMore : !!initialCursor
73
+
74
+ const handleLoadMore = useCallback(() => {
75
+ if (hasMore && !loading) {
76
+ loadMore()
77
+ }
78
+ }, [hasMore, loading, loadMore])
79
+
80
+ // Use stableFacets (which always contains the full category list) so all filter
81
+ // options remain visible after a category filter is applied.
82
+ const displayFacets = stableFacets
83
+
84
+ return (
85
+ <div className={styles.layout}>
86
+ <aside className={styles.sidebar}>
87
+ <button
88
+ className={styles.mobileFilterToggle}
89
+ onClick={() => setShowMobileFilters((v) => !v)}
90
+ type="button"
91
+ >
92
+ {showMobileFilters ? 'Hide Filters' : 'Show Filters'}
93
+ </button>
94
+ <div
95
+ className={styles.filterSection}
96
+ style={{ display: showMobileFilters ? 'block' : undefined }}
97
+ >
98
+ <ProductFilter facets={displayFacets} />
99
+ </div>
100
+ </aside>
101
+
102
+ <div className={styles.content}>
103
+ <div className={styles.sortBar}>
104
+ <span className={styles.resultCount}>
105
+ {displayProducts.length} product{displayProducts.length !== 1 ? 's' : ''}
106
+ </span>
107
+ <SortDropdown value={initialized ? sort : initialSort} onChange={handleSort} />
108
+ </div>
109
+
110
+ {displayProducts.length > 0 ? (
111
+ <div className="product-grid">
112
+ {displayProducts.map((product) => (
113
+ <ProductCard
114
+ key={product.id}
115
+ product={{
116
+ slug: product.slug,
117
+ name: product.name,
118
+ priceMinor: product.priceMinor ?? null,
119
+ priceRange: product.priceRange ?? null,
120
+ currency: product.currency ?? null,
121
+ media: product.media,
122
+ }}
123
+ />
124
+ ))}
125
+ </div>
126
+ ) : (
127
+ <div className={styles.empty}>
128
+ <p>No products found.</p>
129
+ </div>
130
+ )}
131
+
132
+ {hasMore && (
133
+ <div className={styles.pagination}>
134
+ <button
135
+ type="button"
136
+ className="btn btn-secondary"
137
+ onClick={handleLoadMore}
138
+ disabled={loading}
139
+ data-testid="load-more"
140
+ >
141
+ {loading ? 'Loading...' : 'Load More'}
142
+ </button>
143
+ </div>
144
+ )}
145
+ </div>
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,61 @@
1
+ 'use client'
2
+
3
+ import type { DenormalisedProduct, DenormalisedVariant } from '@nymbalhq/sdk'
4
+ import { useProduct, VariantSelector, AddToCart } from '@nymbalhq/react'
5
+ import { formatPrice } from '@/lib/format'
6
+ import styles from '@/styles/pages/pdp.module.css'
7
+
8
+ interface ProductInteractiveProps {
9
+ product: DenormalisedProduct
10
+ }
11
+
12
+ export function ProductInteractive({ product }: ProductInteractiveProps) {
13
+ const { selectedVariant } = useProduct(product.slug)
14
+
15
+ const variant = selectedVariant ?? product.variants?.[0]
16
+ const price = variant?.priceMinor ?? product.priceMinor ?? 0
17
+ const currency = product.currency ?? 'GBP'
18
+ const stock = variant?.stock ?? (product.inStock ? 10 : 0)
19
+
20
+ // The selector derives its option groups from the product's variants.
21
+ const hasVariantChoice = (product.variants?.length ?? 0) > 1
22
+
23
+ const handleVariantSelected = (e: CustomEvent<{ variant: unknown }>) => {
24
+ const v = e.detail.variant as DenormalisedVariant | undefined
25
+ if (v) {
26
+ // The product store handles variant selection internally
27
+ }
28
+ }
29
+
30
+ return (
31
+ <div>
32
+ <h1 className={styles.productTitle} data-testid="product-title">
33
+ {product.name}
34
+ </h1>
35
+
36
+ <div className={styles.price} data-testid="product-price">
37
+ {formatPrice(price, currency)}
38
+ </div>
39
+
40
+ <div className={styles.stockBadge} data-testid="stock-badge">
41
+ {stock === 0 ? (
42
+ <span style={{ color: 'var(--nymbal-color-error)' }}>Out of Stock</span>
43
+ ) : stock < 5 ? (
44
+ <span style={{ color: 'var(--nymbal-color-warning)' }}>Low Stock — {stock} left</span>
45
+ ) : (
46
+ <span style={{ color: 'var(--nymbal-color-success)' }}>In Stock</span>
47
+ )}
48
+ </div>
49
+
50
+ {hasVariantChoice && (
51
+ <div className={styles.variantSection}>
52
+ <VariantSelector onVariantSelected={handleVariantSelected} />
53
+ </div>
54
+ )}
55
+
56
+ <div className={styles.addToCartSection}>
57
+ <AddToCart variantId={variant?.id ?? product.id} disabled={stock === 0} />
58
+ </div>
59
+ </div>
60
+ )
61
+ }
@@ -0,0 +1,112 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback, type FormEvent } from 'react'
4
+ import { useAuth } from '@nymbalhq/react'
5
+ import styles from '@/styles/pages/account.module.css'
6
+
7
+ export function ProfileForm() {
8
+ const { customer, updateProfile } = useAuth()
9
+
10
+ const [firstName, setFirstName] = useState('')
11
+ const [lastName, setLastName] = useState('')
12
+ const [phone, setPhone] = useState('')
13
+ const [saving, setSaving] = useState(false)
14
+ const [success, setSuccess] = useState(false)
15
+ const [error, setError] = useState<string | null>(null)
16
+
17
+ useEffect(() => {
18
+ if (customer) {
19
+ setFirstName(customer.firstName ?? '')
20
+ setLastName(customer.lastName ?? '')
21
+ setPhone(customer.phone ?? '')
22
+ }
23
+ }, [customer])
24
+
25
+ const handleSubmit = useCallback(
26
+ async (e: FormEvent) => {
27
+ e.preventDefault()
28
+ setSaving(true)
29
+ setError(null)
30
+ setSuccess(false)
31
+
32
+ try {
33
+ await updateProfile({ firstName, lastName, phone })
34
+ setSuccess(true)
35
+ } catch (err) {
36
+ setError(err instanceof Error ? err.message : 'Failed to update profile.')
37
+ } finally {
38
+ setSaving(false)
39
+ }
40
+ },
41
+ [firstName, lastName, phone, updateProfile],
42
+ )
43
+
44
+ return (
45
+ <div>
46
+ <h2 className={styles.pageTitle}>Profile</h2>
47
+
48
+ {success && (
49
+ <div className={`${styles.alert} ${styles.alertSuccess}`}>
50
+ Profile updated successfully.
51
+ </div>
52
+ )}
53
+ {error && <div className={`${styles.alert} ${styles.alertError}`}>{error}</div>}
54
+
55
+ <div className={styles.formCard}>
56
+ <h3 className={styles.formCardTitle}>Personal Information</h3>
57
+ <form onSubmit={handleSubmit}>
58
+ <div className={styles.formGrid}>
59
+ <div>
60
+ <label htmlFor="profile-first-name">First Name</label>
61
+ <input
62
+ id="profile-first-name"
63
+ data-testid="profile-firstName"
64
+ type="text"
65
+ value={firstName}
66
+ onChange={(e) => setFirstName(e.target.value)}
67
+ autoComplete="given-name"
68
+ />
69
+ </div>
70
+ <div>
71
+ <label htmlFor="profile-last-name">Last Name</label>
72
+ <input
73
+ id="profile-last-name"
74
+ data-testid="profile-lastName"
75
+ type="text"
76
+ value={lastName}
77
+ onChange={(e) => setLastName(e.target.value)}
78
+ autoComplete="family-name"
79
+ />
80
+ </div>
81
+ <div className={styles.formGridFull}>
82
+ <label htmlFor="profile-email">Email</label>
83
+ <input
84
+ id="profile-email"
85
+ type="email"
86
+ value={customer?.email ?? ''}
87
+ disabled
88
+ autoComplete="email"
89
+ />
90
+ </div>
91
+ <div className={styles.formGridFull}>
92
+ <label htmlFor="profile-phone">Phone</label>
93
+ <input
94
+ id="profile-phone"
95
+ type="tel"
96
+ value={phone}
97
+ onChange={(e) => setPhone(e.target.value)}
98
+ placeholder="Your phone number"
99
+ autoComplete="tel"
100
+ />
101
+ </div>
102
+ </div>
103
+ <div className={styles.formActions}>
104
+ <button type="submit" className={styles.saveButton} disabled={saving}>
105
+ {saving ? 'Saving...' : 'Save Changes'}
106
+ </button>
107
+ </div>
108
+ </form>
109
+ </div>
110
+ </div>
111
+ )
112
+ }
@@ -0,0 +1,131 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, type FormEvent } from 'react'
4
+ import { useRouter } 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 RegisterForm() {
10
+ const router = useRouter()
11
+ const { register } = useAuth()
12
+
13
+ const [firstName, setFirstName] = useState('')
14
+ const [lastName, setLastName] = useState('')
15
+ const [email, setEmail] = useState('')
16
+ const [password, setPassword] = useState('')
17
+ const [confirmPassword, setConfirmPassword] = useState('')
18
+ const [error, setError] = useState<string | null>(null)
19
+ const [loading, setLoading] = useState(false)
20
+
21
+ const handleSubmit = useCallback(
22
+ async (e: FormEvent) => {
23
+ e.preventDefault()
24
+ setError(null)
25
+
26
+ if (password !== confirmPassword) {
27
+ setError('Passwords do not match.')
28
+ return
29
+ }
30
+
31
+ setLoading(true)
32
+
33
+ try {
34
+ await register({ firstName, lastName, email, password })
35
+ router.push('/account')
36
+ } catch (err) {
37
+ setError(err instanceof Error ? err.message : 'Registration failed. Please try again.')
38
+ setLoading(false)
39
+ }
40
+ },
41
+ [firstName, lastName, email, password, confirmPassword, register, router],
42
+ )
43
+
44
+ return (
45
+ <div className={styles.authPage}>
46
+ <div className={styles.authCard} data-testid="register-form">
47
+ <h1 className={styles.authTitle}>Create Account</h1>
48
+ <p className={styles.authSubtitle}>Join us and start shopping.</p>
49
+
50
+ {error && <div className={styles.alertError + ' ' + styles.alert}>{error}</div>}
51
+
52
+ <form onSubmit={handleSubmit} className={styles.authForm}>
53
+ <div className="form-group">
54
+ <label htmlFor="register-first-name">First Name</label>
55
+ <input
56
+ id="register-first-name"
57
+ type="text"
58
+ value={firstName}
59
+ onChange={(e) => setFirstName(e.target.value)}
60
+ required
61
+ placeholder="First name"
62
+ autoComplete="given-name"
63
+ />
64
+ </div>
65
+ <div className="form-group">
66
+ <label htmlFor="register-last-name">Last Name</label>
67
+ <input
68
+ id="register-last-name"
69
+ type="text"
70
+ value={lastName}
71
+ onChange={(e) => setLastName(e.target.value)}
72
+ required
73
+ placeholder="Last name"
74
+ autoComplete="family-name"
75
+ />
76
+ </div>
77
+ <div className="form-group">
78
+ <label htmlFor="register-email">Email</label>
79
+ <input
80
+ id="register-email"
81
+ type="email"
82
+ value={email}
83
+ onChange={(e) => setEmail(e.target.value)}
84
+ required
85
+ data-testid="register-email"
86
+ placeholder="you@example.com"
87
+ autoComplete="email"
88
+ />
89
+ </div>
90
+ <div className="form-group">
91
+ <label htmlFor="register-password">Password</label>
92
+ <input
93
+ id="register-password"
94
+ type="password"
95
+ value={password}
96
+ onChange={(e) => setPassword(e.target.value)}
97
+ required
98
+ placeholder="Create a password"
99
+ autoComplete="new-password"
100
+ minLength={8}
101
+ />
102
+ </div>
103
+ <div className="form-group">
104
+ <label htmlFor="register-confirm-password">Confirm Password</label>
105
+ <input
106
+ id="register-confirm-password"
107
+ type="password"
108
+ value={confirmPassword}
109
+ onChange={(e) => setConfirmPassword(e.target.value)}
110
+ required
111
+ placeholder="Confirm your password"
112
+ autoComplete="new-password"
113
+ />
114
+ </div>
115
+ <button
116
+ type="submit"
117
+ className={styles.authSubmit}
118
+ disabled={loading}
119
+ >
120
+ {loading ? 'Creating account...' : 'Create Account'}
121
+ </button>
122
+ </form>
123
+
124
+ <p className={styles.authFooter}>
125
+ Already have an account?{' '}
126
+ <Link href="/account/login">Sign in</Link>
127
+ </p>
128
+ </div>
129
+ </div>
130
+ )
131
+ }