create-nymbal-app 0.3.0 → 0.5.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 (167) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +14 -12
  3. package/dist/index.js.map +1 -1
  4. package/package.json +5 -4
  5. package/scaffold/project/astro/src/pages/index.astro +13 -15
  6. package/scaffold/templates/astro/_dot.node-version +1 -1
  7. package/scaffold/templates/astro/astro.config.mjs +23 -0
  8. package/scaffold/templates/astro/package.json +26 -27
  9. package/scaffold/templates/astro/src/components/SkipNav.astro +3 -1
  10. package/scaffold/templates/astro/src/elements/index.ts +11 -0
  11. package/scaffold/templates/astro/src/layouts/BaseLayout.astro +15 -4
  12. package/scaffold/templates/astro/src/lib/api.ts +126 -20
  13. package/scaffold/templates/astro/src/lib/chrome.ts +59 -0
  14. package/scaffold/templates/astro/src/lib/client.ts +4 -2
  15. package/scaffold/templates/astro/src/lib/fragments.ts +48 -0
  16. package/scaffold/templates/astro/src/lib/server-elements.ts +5 -0
  17. package/scaffold/templates/astro/src/middleware.ts +37 -0
  18. package/scaffold/templates/astro/src/pages/account/addresses.astro +5 -97
  19. package/scaffold/templates/astro/src/pages/account/login.astro +7 -34
  20. package/scaffold/templates/astro/src/pages/account/profile.astro +6 -56
  21. package/scaffold/templates/astro/src/pages/account/register.astro +7 -73
  22. package/scaffold/templates/astro/src/pages/cart.astro +14 -7
  23. package/scaffold/templates/astro/src/pages/category/[slug].astro +41 -26
  24. package/scaffold/templates/astro/src/pages/checkout.astro +34 -90
  25. package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -2
  26. package/scaffold/templates/astro/src/pages/fragments/header.astro +12 -2
  27. package/scaffold/templates/astro/src/pages/fragments/nav.astro +8 -5
  28. package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +9 -10
  29. package/scaffold/templates/astro/src/pages/index.astro +18 -15
  30. package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +22 -44
  31. package/scaffold/templates/astro/src/pages/product/[slug].astro +46 -29
  32. package/scaffold/templates/astro/src/pages/products/index.astro +40 -26
  33. package/scaffold/templates/astro/src/pages/search.astro +13 -8
  34. package/scaffold/templates/astro/src/scripts/init-client.ts +14 -0
  35. package/scaffold/templates/astro/src/scripts/pdp.ts +47 -53
  36. package/scaffold/templates/astro/src/scripts/plp.ts +21 -98
  37. package/scaffold/templates/astro/src/scripts/search.ts +19 -120
  38. package/scaffold/templates/astro/src/scripts/signed-in-redirect.ts +4 -0
  39. package/scaffold/templates/astro/src/styles/global.css +49 -45
  40. package/scaffold/templates/astro/src/styles/pages/account.css +0 -90
  41. package/scaffold/templates/astro/src/styles/pages/confirmation.css +6 -0
  42. package/scaffold/templates/astro/src/styles/pages/home.css +9 -100
  43. package/scaffold/templates/astro/src/styles/pages/pdp.css +5 -167
  44. package/scaffold/templates/astro/src/styles/pages/plp.css +3 -42
  45. package/scaffold/templates/astro/src/styles/pages/search.css +13 -100
  46. package/scaffold/templates/astro/src/styles/tokens.css +56 -0
  47. package/scaffold/templates/nextjs/_dot.node-version +1 -1
  48. package/scaffold/templates/nextjs/app/(catalog)/category/[slug]/page.tsx +74 -0
  49. package/scaffold/templates/nextjs/app/(catalog)/home/[key]/page.tsx +80 -0
  50. package/scaffold/templates/nextjs/app/(catalog)/layout.tsx +13 -0
  51. package/scaffold/templates/nextjs/app/(catalog)/not-found.tsx +7 -0
  52. package/scaffold/templates/nextjs/app/(catalog)/product/[slug]/page.tsx +182 -0
  53. package/scaffold/templates/nextjs/app/(catalog)/products/page.tsx +62 -0
  54. package/scaffold/templates/nextjs/app/(catalog)/search/SearchView.tsx +90 -0
  55. package/scaffold/templates/nextjs/app/(catalog)/search/page.tsx +46 -0
  56. package/scaffold/templates/nextjs/app/(shopper)/account/addresses/AddressesView.tsx +5 -0
  57. package/scaffold/templates/nextjs/app/(shopper)/account/addresses/page.tsx +17 -0
  58. package/scaffold/templates/nextjs/app/(shopper)/account/login/LoginView.tsx +28 -0
  59. package/scaffold/templates/nextjs/app/(shopper)/account/login/page.tsx +23 -0
  60. package/scaffold/templates/nextjs/app/(shopper)/account/profile/ProfileView.tsx +5 -0
  61. package/scaffold/templates/nextjs/app/(shopper)/account/profile/page.tsx +20 -0
  62. package/scaffold/templates/nextjs/app/(shopper)/account/register/RegisterView.tsx +26 -0
  63. package/scaffold/templates/nextjs/app/(shopper)/account/register/page.tsx +11 -0
  64. package/scaffold/templates/nextjs/app/(shopper)/cart/CartView.tsx +5 -0
  65. package/scaffold/templates/nextjs/app/(shopper)/cart/page.tsx +22 -0
  66. package/scaffold/templates/nextjs/app/(shopper)/checkout/CheckoutView.tsx +27 -0
  67. package/scaffold/templates/nextjs/app/(shopper)/checkout/page.tsx +39 -0
  68. package/scaffold/templates/nextjs/app/(shopper)/layout.tsx +12 -0
  69. package/scaffold/templates/nextjs/app/{order → (shopper)/order}/[orderNumber]/page.tsx +24 -5
  70. package/scaffold/templates/nextjs/app/error.tsx +3 -7
  71. package/scaffold/templates/nextjs/app/fragments/footer/route.ts +13 -0
  72. package/scaffold/templates/nextjs/app/fragments/header/[key]/route.ts +18 -0
  73. package/scaffold/templates/nextjs/app/fragments/nav/[key]/route.ts +23 -0
  74. package/scaffold/templates/nextjs/app/fragments/product/[slug]/route.ts +22 -0
  75. package/scaffold/templates/nextjs/app/globals.css +5 -23
  76. package/scaffold/templates/nextjs/app/layout.tsx +8 -5
  77. package/scaffold/templates/nextjs/app/not-found.tsx +9 -33
  78. package/scaffold/templates/nextjs/app/tokens.css +56 -0
  79. package/scaffold/templates/nextjs/components/client/ListingControls.tsx +87 -0
  80. package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +48 -6
  81. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +64 -28
  82. package/scaffold/templates/nextjs/components/client/elements.tsx +15 -0
  83. package/scaffold/templates/nextjs/components/server/Header.tsx +33 -97
  84. package/scaffold/templates/nextjs/components/server/NotFoundView.tsx +39 -0
  85. package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -3
  86. package/scaffold/templates/nextjs/elements/index.ts +12 -0
  87. package/scaffold/templates/nextjs/lib/api.ts +162 -46
  88. package/scaffold/templates/nextjs/lib/chrome.ts +40 -0
  89. package/scaffold/templates/nextjs/lib/client.ts +3 -0
  90. package/scaffold/templates/nextjs/lib/fragments.ts +16 -0
  91. package/scaffold/templates/nextjs/lib/header.ts +39 -0
  92. package/scaffold/templates/nextjs/lib/render.ts +14 -0
  93. package/scaffold/templates/nextjs/next.config.mjs +32 -0
  94. package/scaffold/templates/nextjs/package.json +28 -29
  95. package/scaffold/templates/nextjs/styles/pages/account.module.css +0 -70
  96. package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +6 -85
  97. package/scaffold/templates/nextjs/styles/pages/home.module.css +2 -103
  98. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +3 -179
  99. package/scaffold/templates/nextjs/styles/pages/plp.module.css +8 -141
  100. package/scaffold/templates/nextjs/styles/pages/search.module.css +8 -128
  101. package/scaffold/templates/nextjs/tsconfig.dist.json +5 -0
  102. package/scaffold/templates/astro/src/components/AddressForm.astro +0 -107
  103. package/scaffold/templates/astro/src/components/Breadcrumb.astro +0 -20
  104. package/scaffold/templates/astro/src/components/CategoryNav.astro +0 -27
  105. package/scaffold/templates/astro/src/components/CheckoutSteps.astro +0 -29
  106. package/scaffold/templates/astro/src/components/Footer.astro +0 -108
  107. package/scaffold/templates/astro/src/components/Header.astro +0 -192
  108. package/scaffold/templates/astro/src/components/ImageGallery.astro +0 -42
  109. package/scaffold/templates/astro/src/components/OrderSummary.astro +0 -53
  110. package/scaffold/templates/astro/src/components/Pagination.astro +0 -13
  111. package/scaffold/templates/astro/src/components/PriceDisplay.astro +0 -25
  112. package/scaffold/templates/astro/src/components/ProductCard.astro +0 -44
  113. package/scaffold/templates/astro/src/components/RelatedProducts.astro +0 -28
  114. package/scaffold/templates/astro/src/components/ReviewForm.astro +0 -74
  115. package/scaffold/templates/astro/src/components/ReviewList.astro +0 -41
  116. package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
  117. package/scaffold/templates/astro/src/components/StockBadge.astro +0 -32
  118. package/scaffold/templates/astro/src/scripts/auth-forms.ts +0 -82
  119. package/scaffold/templates/astro/src/scripts/cart-page.ts +0 -143
  120. package/scaffold/templates/astro/src/scripts/checkout.ts +0 -272
  121. package/scaffold/templates/astro/src/scripts/image-gallery.ts +0 -22
  122. package/scaffold/templates/astro/src/scripts/profile-form.ts +0 -57
  123. package/scaffold/templates/astro/src/scripts/review-form.ts +0 -27
  124. package/scaffold/templates/astro/src/styles/pages/cart.css +0 -243
  125. package/scaffold/templates/astro/src/styles/pages/checkout.css +0 -270
  126. package/scaffold/templates/nextjs/app/account/addresses/page.tsx +0 -10
  127. package/scaffold/templates/nextjs/app/account/login/page.tsx +0 -23
  128. package/scaffold/templates/nextjs/app/account/profile/page.tsx +0 -10
  129. package/scaffold/templates/nextjs/app/account/register/page.tsx +0 -10
  130. package/scaffold/templates/nextjs/app/cart/page.tsx +0 -10
  131. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +0 -58
  132. package/scaffold/templates/nextjs/app/checkout/page.tsx +0 -10
  133. package/scaffold/templates/nextjs/app/page.tsx +0 -63
  134. package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +0 -111
  135. package/scaffold/templates/nextjs/app/products/page.tsx +0 -40
  136. package/scaffold/templates/nextjs/app/search/page.tsx +0 -24
  137. package/scaffold/templates/nextjs/components/client/AddressManager.tsx +0 -181
  138. package/scaffold/templates/nextjs/components/client/CartPage.tsx +0 -120
  139. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +0 -523
  140. package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +0 -68
  141. package/scaffold/templates/nextjs/components/client/LoginForm.tsx +0 -89
  142. package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +0 -12
  143. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +0 -148
  144. package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +0 -112
  145. package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +0 -131
  146. package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +0 -116
  147. package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +0 -27
  148. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +0 -141
  149. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
  150. package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +0 -36
  151. package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +0 -29
  152. package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +0 -60
  153. package/scaffold/templates/nextjs/components/server/Footer.module.css +0 -64
  154. package/scaffold/templates/nextjs/components/server/Footer.tsx +0 -79
  155. package/scaffold/templates/nextjs/components/server/Header.module.css +0 -147
  156. package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +0 -79
  157. package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +0 -31
  158. package/scaffold/templates/nextjs/components/server/ProductCard.tsx +0 -59
  159. package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +0 -31
  160. package/scaffold/templates/nextjs/components/server/ReviewList.tsx +0 -62
  161. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +0 -19
  162. package/scaffold/templates/nextjs/styles/pages/cart.module.css +0 -240
  163. package/scaffold/templates/nextjs/styles/pages/checkout.module.css +0 -404
  164. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/layout.tsx +0 -0
  165. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/[orderNumber]/page.tsx +0 -0
  166. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/page.tsx +0 -0
  167. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/page.tsx +0 -0
@@ -0,0 +1,182 @@
1
+ import type { Metadata } from 'next'
2
+ import { notFound } from 'next/navigation'
3
+ import { preload } from 'react-dom'
4
+ import { fetchProduct, fetchProducts, fetchReviews } from '@/lib/api'
5
+ import { cardProductOf, renderNymbal } from '@/lib/render'
6
+ import { productJsonLd } from '@/lib/seo'
7
+ import {
8
+ Breadcrumb,
9
+ ImageGallery,
10
+ ProductGrid,
11
+ ReviewForm,
12
+ ReviewList,
13
+ } from '@/components/client/elements'
14
+ import { ProductInteractive } from '@/components/client/ProductInteractive'
15
+ import styles from '@/styles/pages/pdp.module.css'
16
+
17
+ // Next caches each product page on its first request and regenerates it in the background every 60 s; the build
18
+ // prerenders none, since the API may be down then.
19
+ export const revalidate = 60
20
+
21
+ export function generateStaticParams(): Array<{ slug: string }> {
22
+ return []
23
+ }
24
+
25
+ interface ProductPageProps {
26
+ params: Promise<{ slug: string }>
27
+ }
28
+
29
+ export async function generateMetadata({ params }: ProductPageProps): Promise<Metadata> {
30
+ const { slug } = await params
31
+ const product = await fetchProduct(slug, revalidate)
32
+
33
+ if (!product) {
34
+ return { title: 'Product Not Found' }
35
+ }
36
+
37
+ return {
38
+ title: product.name,
39
+ description: product.shortDescription ?? product.description ?? `Buy ${product.name}.`,
40
+ }
41
+ }
42
+
43
+ export default async function ProductPage({ params }: ProductPageProps) {
44
+ const { slug } = await params
45
+ const product = await fetchProduct(slug, revalidate)
46
+
47
+ if (!product) notFound()
48
+
49
+ const [{ items: reviews }, { items: relatedProducts }] = await Promise.all([
50
+ fetchReviews(product.id),
51
+ fetchProducts({ limit: 4 }),
52
+ ])
53
+
54
+ const related = relatedProducts
55
+ .filter((p) => p.id !== product.id)
56
+ .slice(0, 4)
57
+ .map(cardProductOf)
58
+
59
+ // The product's elements as the server renders them for its first variant, which ProductInteractive shows first.
60
+ const variant = product.variants?.[0]
61
+ const serverHtml = {
62
+ price: renderNymbal('nymbal-product-price', {
63
+ 'product-id': product.id,
64
+ 'price-minor': String(variant?.priceMinor ?? product.priceMinor ?? 0),
65
+ currency: product.currency ?? 'GBP',
66
+ }).__html,
67
+ stock: renderNymbal('nymbal-stock-badge', {
68
+ 'product-id': product.id,
69
+ stock: String(variant?.stock ?? 0),
70
+ }).__html,
71
+ variantSelector: renderNymbal('nymbal-variant-selector', { product: JSON.stringify(product) })
72
+ .__html,
73
+ quantity: renderNymbal('nymbal-quantity-selector', { value: '1', min: '1', max: '99' }).__html,
74
+ addToCart: renderNymbal('nymbal-add-to-cart', {
75
+ 'variant-id': variant?.id ?? product.id,
76
+ qty: '1',
77
+ ...((variant?.stock ?? 0) <= 0 && { disabled: '' }),
78
+ }).__html,
79
+ }
80
+
81
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
82
+ const jsonLd = productJsonLd(product, baseUrl)
83
+
84
+ const mediaArray = Array.isArray(product.media) ? product.media : []
85
+ const images =
86
+ mediaArray.length > 0
87
+ ? mediaArray.map((m: Record<string, string>) => ({
88
+ url: m.url,
89
+ alt: m.alt ?? product.name,
90
+ }))
91
+ : [{ url: `https://picsum.photos/seed/${product.slug}/600/600`, alt: product.name }]
92
+ const crumbs = [
93
+ { label: 'Home', href: '/' },
94
+ { label: 'Products', href: '/products' },
95
+ { label: product.name },
96
+ ]
97
+
98
+ // The gallery's main image is the page's LCP: a preload in the head starts it before the browser parses the body.
99
+ // React's preload(), not a <link> element: a cached render writes an element and its preload both.
100
+ preload(images[0]!.url, { as: 'image', fetchPriority: 'high' })
101
+
102
+ return (
103
+ <>
104
+ <script
105
+ type="application/ld+json"
106
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
107
+ />
108
+
109
+ <div className={`container ${styles.page}`}>
110
+ <Breadcrumb
111
+ items={crumbs}
112
+ serverHtml={renderNymbal('nymbal-breadcrumb', { items: JSON.stringify(crumbs) }).__html}
113
+ />
114
+
115
+ <div className={styles.productLayout}>
116
+ <ImageGallery
117
+ className={styles.gallery}
118
+ images={images}
119
+ serverHtml={
120
+ renderNymbal('nymbal-image-gallery', { images: JSON.stringify(images) }).__html
121
+ }
122
+ />
123
+
124
+ <div className={styles.info}>
125
+ <ProductInteractive product={product} serverHtml={serverHtml} />
126
+
127
+ {product.description && (
128
+ <div className={styles.description}>
129
+ <p>{product.description}</p>
130
+ </div>
131
+ )}
132
+
133
+ <div className={styles.detailsSection}>
134
+ <details className={styles.detailsItem}>
135
+ <summary className={styles.detailsSummary}>Details</summary>
136
+ <div className={styles.detailsContent}>
137
+ {product.description ?? 'No additional details available.'}
138
+ </div>
139
+ </details>
140
+ <details className={styles.detailsItem}>
141
+ <summary className={styles.detailsSummary}>Shipping & Returns</summary>
142
+ <div className={styles.detailsContent}>
143
+ Free standard shipping on orders over $50. Returns accepted within 30 days of
144
+ purchase.
145
+ </div>
146
+ </details>
147
+ </div>
148
+ </div>
149
+ </div>
150
+
151
+ <section className={styles.reviewsSection}>
152
+ <h2 className={styles.reviewsTitle}>Customer Reviews</h2>
153
+ <ReviewList
154
+ reviews={reviews}
155
+ serverHtml={
156
+ renderNymbal('nymbal-review-list', { reviews: JSON.stringify(reviews) }).__html
157
+ }
158
+ />
159
+ <ReviewForm
160
+ className={styles.reviewForm}
161
+ productId={product.id}
162
+ serverHtml={renderNymbal('nymbal-review-form', { 'product-id': product.id }).__html}
163
+ />
164
+ </section>
165
+
166
+ {related.length > 0 && (
167
+ <section className="section">
168
+ <h2 className="section-title">You may also like</h2>
169
+ <ProductGrid
170
+ products={related}
171
+ lazy
172
+ serverHtml={
173
+ renderNymbal('nymbal-product-grid', { products: JSON.stringify(related), lazy: '' })
174
+ .__html
175
+ }
176
+ />
177
+ </section>
178
+ )}
179
+ </div>
180
+ </>
181
+ )
182
+ }
@@ -0,0 +1,62 @@
1
+ import type { Metadata } from 'next'
2
+ import { fetchProducts, fetchCapabilities, sortFromSearchParams } from '@/lib/api'
3
+ import { cardImageOf, cardProductOf, renderNymbal } from '@/lib/render'
4
+ import { Breadcrumb, ProductGrid } from '@/components/client/elements'
5
+ import { ListingControls } from '@/components/client/ListingControls'
6
+ import styles from '@/styles/pages/plp.module.css'
7
+
8
+ export const metadata: Metadata = {
9
+ title: 'All Products',
10
+ description: 'Browse our full collection of products.',
11
+ }
12
+
13
+ // The page reads `?sort=`, so it renders per request; said here, the build does not render it with the API down. Its
14
+ // catalog reads still go through Next's data cache.
15
+ export const dynamic = 'force-dynamic'
16
+
17
+ interface ProductsPageProps {
18
+ searchParams: Promise<{ sort?: string | string[] }>
19
+ }
20
+
21
+ const CRUMBS = [{ label: 'Home', href: '/' }, { label: 'Products' }]
22
+
23
+ export default async function ProductsPage({ searchParams }: ProductsPageProps) {
24
+ const capabilities = await fetchCapabilities()
25
+ const sort = sortFromSearchParams(await searchParams, capabilities?.catalog)
26
+ const { items, nextCursor, facets } = await fetchProducts({ limit: 24, sort })
27
+ const cards = items.map(cardProductOf)
28
+ // The first card's image is the page's LCP, and the preload scanner never reaches it inside the grid's shadow root:
29
+ // a preload link, which React hoists into the head, starts it.
30
+ const firstImage = cards[0] === undefined ? null : cardImageOf(cards[0]).url
31
+ const grid = {
32
+ products: JSON.stringify(cards),
33
+ source: 'product-list',
34
+ ...(nextCursor !== null && { 'has-more': '' }),
35
+ }
36
+
37
+ return (
38
+ <div className={`container ${styles.page}`}>
39
+ {firstImage !== null && (
40
+ <link rel="preload" as="image" href={firstImage} fetchPriority="high" />
41
+ )}
42
+ <Breadcrumb
43
+ items={CRUMBS}
44
+ serverHtml={renderNymbal('nymbal-breadcrumb', { items: JSON.stringify(CRUMBS) }).__html}
45
+ />
46
+ <h1 className={styles.pageTitle}>All Products</h1>
47
+ <ListingControls
48
+ sort={sort}
49
+ facets={facets ?? []}
50
+ sortKeys={capabilities?.catalog.sortKeys ?? []}
51
+ defaultSort={capabilities?.catalog.defaultSort}
52
+ >
53
+ <ProductGrid
54
+ products={cards}
55
+ source="product-list"
56
+ hasMore={nextCursor !== null}
57
+ serverHtml={renderNymbal('nymbal-product-grid', grid).__html}
58
+ />
59
+ </ListingControls>
60
+ </div>
61
+ )
62
+ }
@@ -0,0 +1,90 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect } from 'react'
4
+ import { useSearchParams } from 'next/navigation'
5
+ import Link from 'next/link'
6
+ import type { DenormalisedProduct } from '@nymbalhq/sdk'
7
+ import { ProductFilter, SearchResults, useSearch } from '@nymbalhq/react'
8
+ import styles from '@/styles/pages/search.module.css'
9
+
10
+ const POPULAR = ['Shirts', 'Shoes', 'Jackets', 'Accessories', 'New Arrivals']
11
+
12
+ /**
13
+ * The URL owns the term and the category; `SearchResults` renders the store's answer, and until the store has one, the
14
+ * results the server fetched for the URL (`given`, rendered in `serverHtml`).
15
+ */
16
+ export function SearchView({
17
+ given,
18
+ serverHtml,
19
+ }: {
20
+ given: { results: DenormalisedProduct[]; query: string } | null
21
+ serverHtml: string
22
+ }) {
23
+ const searchParams = useSearchParams()
24
+ const query = searchParams.get('q') ?? ''
25
+ const category = searchParams.get('category')
26
+ const {
27
+ results,
28
+ facets,
29
+ query: searched,
30
+ category: searchedCategory,
31
+ search,
32
+ clearSearch,
33
+ } = useSearch()
34
+
35
+ useEffect(() => {
36
+ // Runs when the URL changes; the filter has already searched the category it writes there. The store outlives
37
+ // the page on a client-side navigation, so a URL with no term clears what an earlier visit searched.
38
+ if (!query) {
39
+ if (searched) clearSearch()
40
+ } else if (query !== searched || category !== searchedCategory) {
41
+ void search(query, { category })
42
+ }
43
+ }, [query, category, search, clearSearch])
44
+
45
+ const onFilterChange = useCallback((event: CustomEvent<{ name: string; value: string }>) => {
46
+ const url = new URL(window.location.href)
47
+ if (event.detail.value) url.searchParams.set('category', event.detail.value)
48
+ else url.searchParams.delete('category')
49
+ window.history.replaceState(null, '', url.toString())
50
+ }, [])
51
+
52
+ const filtered = facets.length > 0 && results.length > 0
53
+
54
+ return (
55
+ <div className={`container ${styles.page}`}>
56
+ <div className={styles.header}>
57
+ <h1 className={styles.pageTitle}>Search Results</h1>
58
+ </div>
59
+
60
+ <div className={filtered ? styles.layout : undefined}>
61
+ {filtered && (
62
+ <aside className={styles.sidebar}>
63
+ <ProductFilter source="search" onFilterChange={onFilterChange} />
64
+ </aside>
65
+ )}
66
+ <SearchResults
67
+ data-testid="search-results"
68
+ results={given?.results}
69
+ query={given?.query}
70
+ serverHtml={serverHtml}
71
+ />
72
+ </div>
73
+
74
+ <div className={styles.suggestions}>
75
+ <h3 className={styles.suggestionsTitle}>Popular searches</h3>
76
+ <div className={styles.suggestionTags}>
77
+ {POPULAR.map((term) => (
78
+ <Link
79
+ key={term}
80
+ href={`/search?q=${encodeURIComponent(term)}`}
81
+ className={styles.suggestionTag}
82
+ >
83
+ {term}
84
+ </Link>
85
+ ))}
86
+ </div>
87
+ </div>
88
+ </div>
89
+ )
90
+ }
@@ -0,0 +1,46 @@
1
+ import type { Metadata } from 'next'
2
+ import { Suspense } from 'react'
3
+ import { fetchSearch } from '@/lib/api'
4
+ import { renderNymbal } from '@/lib/render'
5
+ import { SearchView } from './SearchView'
6
+
7
+ export const metadata: Metadata = {
8
+ title: 'Search',
9
+ description: 'Search our product catalog.',
10
+ }
11
+
12
+ // The page reads `?q=`, so it renders per request; said here, the build does not render it with the API down. The
13
+ // header's catalog reads still go through Next's data cache.
14
+ export const dynamic = 'force-dynamic'
15
+
16
+ const first = (value: string | string[] | undefined): string | null =>
17
+ (Array.isArray(value) ? value[0] : value) ?? null
18
+
19
+ export default async function SearchPage({
20
+ searchParams,
21
+ }: {
22
+ searchParams: Promise<Record<string, string | string[] | undefined>>
23
+ }) {
24
+ const params = await searchParams
25
+ const q = first(params['q'])?.trim() ?? ''
26
+ const search = q === '' ? null : await fetchSearch(q, first(params['category']))
27
+ // The results the page's search would show, so they are in the HTML; the search store takes over once it answers.
28
+ const given = search === null ? null : { results: search.items, query: q }
29
+ const serverHtml = renderNymbal(
30
+ 'nymbal-search-results',
31
+ given === null ? {} : { results: JSON.stringify(given.results), query: given.query },
32
+ ).__html
33
+ return (
34
+ <Suspense
35
+ fallback={
36
+ <div className="loading-center">
37
+ <div className="spinner spinner-lg" role="status">
38
+ <span className="visually-hidden">Loading...</span>
39
+ </div>
40
+ </div>
41
+ }
42
+ >
43
+ <SearchView given={given} serverHtml={serverHtml} />
44
+ </Suspense>
45
+ )
46
+ }
@@ -0,0 +1,5 @@
1
+ 'use client'
2
+
3
+ import { AddressBook } from '@nymbalhq/react'
4
+
5
+ export default AddressBook
@@ -0,0 +1,17 @@
1
+ import type { Metadata } from 'next'
2
+ import { renderNymbal } from '@/lib/render'
3
+ import AddressBook from './AddressesView'
4
+ import styles from '@/styles/pages/account.module.css'
5
+
6
+ export const metadata: Metadata = {
7
+ title: 'Addresses',
8
+ }
9
+
10
+ export default function AddressesPage() {
11
+ return (
12
+ <div>
13
+ <h2 className={styles.pageTitle}>Addresses</h2>
14
+ <AddressBook serverHtml={renderNymbal('nymbal-address-book').__html} />
15
+ </div>
16
+ )
17
+ }
@@ -0,0 +1,28 @@
1
+ 'use client'
2
+
3
+ import { useCallback } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import Link from 'next/link'
6
+ import { LoginForm } from '@nymbalhq/react'
7
+ import styles from '@/styles/pages/account.module.css'
8
+
9
+ /** `serverHtml` is the form as the server rendered it, `redirect` where to go once signed in (page.tsx). */
10
+ export function LoginView({ serverHtml, redirect }: { serverHtml: string; redirect?: string }) {
11
+ const router = useRouter()
12
+ const onSignedIn = useCallback(() => {
13
+ router.push(redirect ?? '/account')
14
+ }, [router, redirect])
15
+
16
+ return (
17
+ <div className={styles.authPage}>
18
+ <div className={styles.authCard}>
19
+ <h1 className={styles.authTitle}>Sign In</h1>
20
+ <p className={styles.authSubtitle}>Welcome back. Enter your credentials to continue.</p>
21
+ <LoginForm onSignedIn={onSignedIn} serverHtml={serverHtml} />
22
+ <p className={styles.authFooter}>
23
+ Don&apos;t have an account? <Link href="/account/register">Create one</Link>
24
+ </p>
25
+ </div>
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,23 @@
1
+ import type { Metadata } from 'next'
2
+ import { renderNymbal } from '@/lib/render'
3
+ import { LoginView } from './LoginView'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Sign In',
7
+ }
8
+
9
+ interface LoginPageProps {
10
+ searchParams: Promise<{ redirect?: string | string[] }>
11
+ }
12
+
13
+ // The page reads `redirect` itself rather than the view reading the URL: a view that reads it needs a Suspense
14
+ // boundary, which React may stream after the shell, and without JavaScript a streamed boundary is never shown.
15
+ export default async function LoginPage({ searchParams }: LoginPageProps) {
16
+ const { redirect } = await searchParams
17
+ return (
18
+ <LoginView
19
+ redirect={typeof redirect === 'string' ? redirect : undefined}
20
+ serverHtml={renderNymbal('nymbal-login-form').__html}
21
+ />
22
+ )
23
+ }
@@ -0,0 +1,5 @@
1
+ 'use client'
2
+
3
+ import { ProfileForm } from '@nymbalhq/react'
4
+
5
+ export default ProfileForm
@@ -0,0 +1,20 @@
1
+ import type { Metadata } from 'next'
2
+ import { renderNymbal } from '@/lib/render'
3
+ import ProfileForm from './ProfileView'
4
+ import styles from '@/styles/pages/account.module.css'
5
+
6
+ export const metadata: Metadata = {
7
+ title: 'Profile',
8
+ }
9
+
10
+ export default function ProfilePage() {
11
+ return (
12
+ <div>
13
+ <h2 className={styles.pageTitle}>Profile</h2>
14
+ <div className={styles.formCard}>
15
+ <h3 className={styles.formCardTitle}>Personal Information</h3>
16
+ <ProfileForm serverHtml={renderNymbal('nymbal-profile-form').__html} />
17
+ </div>
18
+ </div>
19
+ )
20
+ }
@@ -0,0 +1,26 @@
1
+ 'use client'
2
+
3
+ import { useCallback } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import Link from 'next/link'
6
+ import { RegisterForm } from '@nymbalhq/react'
7
+ import styles from '@/styles/pages/account.module.css'
8
+
9
+ /** `serverHtml` is the form as the server rendered it (page.tsx). */
10
+ export function RegisterView({ serverHtml }: { serverHtml: string }) {
11
+ const router = useRouter()
12
+ const onSignedIn = useCallback(() => router.push('/account'), [router])
13
+
14
+ return (
15
+ <div className={styles.authPage}>
16
+ <div className={styles.authCard}>
17
+ <h1 className={styles.authTitle}>Create Account</h1>
18
+ <p className={styles.authSubtitle}>Join us and start shopping.</p>
19
+ <RegisterForm onSignedIn={onSignedIn} serverHtml={serverHtml} />
20
+ <p className={styles.authFooter}>
21
+ Already have an account? <Link href="/account/login">Sign in</Link>
22
+ </p>
23
+ </div>
24
+ </div>
25
+ )
26
+ }
@@ -0,0 +1,11 @@
1
+ import type { Metadata } from 'next'
2
+ import { renderNymbal } from '@/lib/render'
3
+ import { RegisterView } from './RegisterView'
4
+
5
+ export const metadata: Metadata = {
6
+ title: 'Create Account',
7
+ }
8
+
9
+ export default function RegisterPage() {
10
+ return <RegisterView serverHtml={renderNymbal('nymbal-register-form').__html} />
11
+ }
@@ -0,0 +1,5 @@
1
+ 'use client'
2
+
3
+ import { CartPage } from '@nymbalhq/react'
4
+
5
+ export default CartPage
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from 'next'
2
+ import { headers } from 'next/headers'
3
+ import { fetchCart } from '@/lib/api'
4
+ import { renderNymbal } from '@/lib/render'
5
+ import CartPage from './CartView'
6
+
7
+ export const metadata: Metadata = {
8
+ title: 'Shopping Cart',
9
+ }
10
+
11
+ export default async function CartRoute() {
12
+ const cart = await fetchCart((await headers()).get('cookie'))
13
+ const serverHtml = renderNymbal(
14
+ 'nymbal-cart-page',
15
+ cart === null ? {} : { cart: JSON.stringify(cart) },
16
+ ).__html
17
+ return (
18
+ <div className="container section" data-testid="cart-page">
19
+ <CartPage cart={cart ?? undefined} serverHtml={serverHtml} />
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,27 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Checkout } from '@nymbalhq/react'
6
+ import { NymbalPaymentElement } from '@nymbalhq/web-components/elements/nymbal-payment-element'
7
+ import type { CartProjection } from '@/lib/api'
8
+
9
+ /**
10
+ * The element places the order; the page only goes to it. The server rendered the checkout with its payment element
11
+ * as a light-DOM child (`serverHtml`), which is defined here: it stays mounted by its driver in the browser.
12
+ */
13
+ export function CheckoutView({ cart, serverHtml }: { cart: CartProjection | null; serverHtml: string }) {
14
+ const router = useRouter()
15
+ const onPlaced = useCallback(
16
+ (event: CustomEvent<{ orderNumber: string }>) => {
17
+ router.push(`/order/${encodeURIComponent(event.detail.orderNumber)}`)
18
+ },
19
+ [router],
20
+ )
21
+
22
+ useEffect(() => {
23
+ NymbalPaymentElement.define()
24
+ }, [])
25
+
26
+ return <Checkout cart={cart ?? undefined} serverHtml={serverHtml} onPlaced={onPlaced} />
27
+ }
@@ -0,0 +1,39 @@
1
+ import type { Metadata } from 'next'
2
+ import { headers } from 'next/headers'
3
+ import { fetchCapabilities, fetchCart } from '@/lib/api'
4
+ import { renderNymbal } from '@/lib/render'
5
+ import { CheckoutView } from './CheckoutView'
6
+
7
+ export const metadata: Metadata = {
8
+ title: 'Checkout',
9
+ }
10
+
11
+ const escapeAttribute = (value: string): string =>
12
+ value.replaceAll('&', '&amp;').replaceAll('"', '&quot;').replaceAll('<', '&lt;')
13
+
14
+ export default async function CheckoutRoute() {
15
+ const [capabilities, cart] = await Promise.all([
16
+ fetchCapabilities(),
17
+ headers().then((h) => fetchCart(h.get('cookie'))),
18
+ ])
19
+ if (capabilities === null) {
20
+ throw new Error(
21
+ 'The store did not answer GET /api/capabilities, so checkout cannot tell how to take a payment.',
22
+ )
23
+ }
24
+ // The payments adapter's client half, as the checkout's light-DOM child in its payment slot; a driver the project
25
+ // registers in lib/client.ts renders it.
26
+ const payment = `<nymbal-payment-element slot="payment" client="${escapeAttribute(
27
+ JSON.stringify(capabilities.payments.client),
28
+ )}" data-testid="payment-element"></nymbal-payment-element>`
29
+ const serverHtml = renderNymbal(
30
+ 'nymbal-checkout',
31
+ cart === null ? {} : { cart: JSON.stringify(cart) },
32
+ payment,
33
+ ).__html
34
+ return (
35
+ <div className="container section">
36
+ <CheckoutView cart={cart} serverHtml={serverHtml} />
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Header } from '@/components/server/Header'
3
+
4
+ // The shopper's own pages render per request: the header reads the cart cookie, so its count is in the HTML.
5
+ export default function ShopperLayout({ children }: { children: ReactNode }) {
6
+ return (
7
+ <>
8
+ <Header shopper />
9
+ <main id="main">{children}</main>
10
+ </>
11
+ )
12
+ }