create-nymbal-app 0.4.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 (163) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +7 -4
  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 -26
  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 +113 -21
  13. package/scaffold/templates/astro/src/lib/chrome.ts +59 -0
  14. package/scaffold/templates/astro/src/lib/client.ts +4 -4
  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 +17 -21
  24. package/scaffold/templates/astro/src/pages/checkout.astro +30 -94
  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 +44 -30
  32. package/scaffold/templates/astro/src/pages/products/index.astro +16 -21
  33. package/scaffold/templates/astro/src/pages/search.astro +10 -11
  34. package/scaffold/templates/astro/src/scripts/init-client.ts +14 -0
  35. package/scaffold/templates/astro/src/scripts/pdp.ts +24 -21
  36. package/scaffold/templates/astro/src/scripts/plp.ts +15 -93
  37. package/scaffold/templates/astro/src/scripts/search.ts +7 -132
  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 -171
  44. package/scaffold/templates/astro/src/styles/pages/plp.css +3 -42
  45. package/scaffold/templates/astro/src/styles/pages/search.css +4 -110
  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 +30 -15
  82. package/scaffold/templates/nextjs/components/client/elements.tsx +15 -0
  83. package/scaffold/templates/nextjs/components/server/Header.tsx +33 -102
  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 +151 -55
  88. package/scaffold/templates/nextjs/lib/chrome.ts +40 -0
  89. package/scaffold/templates/nextjs/lib/fragments.ts +16 -0
  90. package/scaffold/templates/nextjs/lib/header.ts +39 -0
  91. package/scaffold/templates/nextjs/lib/render.ts +14 -0
  92. package/scaffold/templates/nextjs/next.config.mjs +32 -0
  93. package/scaffold/templates/nextjs/package.json +28 -27
  94. package/scaffold/templates/nextjs/styles/pages/account.module.css +0 -70
  95. package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +6 -85
  96. package/scaffold/templates/nextjs/styles/pages/home.module.css +2 -103
  97. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +3 -183
  98. package/scaffold/templates/nextjs/styles/pages/plp.module.css +9 -143
  99. package/scaffold/templates/nextjs/styles/pages/search.module.css +1 -145
  100. package/scaffold/templates/nextjs/tsconfig.dist.json +5 -0
  101. package/scaffold/templates/astro/src/components/AddressForm.astro +0 -107
  102. package/scaffold/templates/astro/src/components/Breadcrumb.astro +0 -20
  103. package/scaffold/templates/astro/src/components/CategoryNav.astro +0 -27
  104. package/scaffold/templates/astro/src/components/CheckoutSteps.astro +0 -29
  105. package/scaffold/templates/astro/src/components/Footer.astro +0 -108
  106. package/scaffold/templates/astro/src/components/Header.astro +0 -198
  107. package/scaffold/templates/astro/src/components/ImageGallery.astro +0 -42
  108. package/scaffold/templates/astro/src/components/OrderSummary.astro +0 -53
  109. package/scaffold/templates/astro/src/components/Pagination.astro +0 -13
  110. package/scaffold/templates/astro/src/components/PriceDisplay.astro +0 -25
  111. package/scaffold/templates/astro/src/components/ProductCard.astro +0 -44
  112. package/scaffold/templates/astro/src/components/RelatedProducts.astro +0 -28
  113. package/scaffold/templates/astro/src/components/ReviewForm.astro +0 -74
  114. package/scaffold/templates/astro/src/components/ReviewList.astro +0 -41
  115. package/scaffold/templates/astro/src/components/StockBadge.astro +0 -15
  116. package/scaffold/templates/astro/src/scripts/auth-forms.ts +0 -82
  117. package/scaffold/templates/astro/src/scripts/cart-page.ts +0 -118
  118. package/scaffold/templates/astro/src/scripts/checkout.ts +0 -271
  119. package/scaffold/templates/astro/src/scripts/image-gallery.ts +0 -22
  120. package/scaffold/templates/astro/src/scripts/profile-form.ts +0 -57
  121. package/scaffold/templates/astro/src/scripts/review-form.ts +0 -27
  122. package/scaffold/templates/astro/src/styles/pages/cart.css +0 -215
  123. package/scaffold/templates/astro/src/styles/pages/checkout.css +0 -270
  124. package/scaffold/templates/nextjs/app/account/addresses/page.tsx +0 -10
  125. package/scaffold/templates/nextjs/app/account/login/page.tsx +0 -23
  126. package/scaffold/templates/nextjs/app/account/profile/page.tsx +0 -10
  127. package/scaffold/templates/nextjs/app/account/register/page.tsx +0 -10
  128. package/scaffold/templates/nextjs/app/cart/page.tsx +0 -10
  129. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +0 -61
  130. package/scaffold/templates/nextjs/app/checkout/page.tsx +0 -17
  131. package/scaffold/templates/nextjs/app/page.tsx +0 -63
  132. package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +0 -111
  133. package/scaffold/templates/nextjs/app/products/page.tsx +0 -40
  134. package/scaffold/templates/nextjs/app/search/page.tsx +0 -24
  135. package/scaffold/templates/nextjs/components/client/AddressManager.tsx +0 -181
  136. package/scaffold/templates/nextjs/components/client/CartPage.tsx +0 -121
  137. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +0 -482
  138. package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +0 -68
  139. package/scaffold/templates/nextjs/components/client/LoginForm.tsx +0 -89
  140. package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +0 -12
  141. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +0 -137
  142. package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +0 -112
  143. package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +0 -131
  144. package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +0 -116
  145. package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +0 -27
  146. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +0 -181
  147. package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +0 -36
  148. package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +0 -29
  149. package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +0 -60
  150. package/scaffold/templates/nextjs/components/server/Footer.module.css +0 -64
  151. package/scaffold/templates/nextjs/components/server/Footer.tsx +0 -79
  152. package/scaffold/templates/nextjs/components/server/Header.module.css +0 -147
  153. package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +0 -79
  154. package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +0 -31
  155. package/scaffold/templates/nextjs/components/server/ProductCard.tsx +0 -59
  156. package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +0 -31
  157. package/scaffold/templates/nextjs/components/server/ReviewList.tsx +0 -62
  158. package/scaffold/templates/nextjs/styles/pages/cart.module.css +0 -240
  159. package/scaffold/templates/nextjs/styles/pages/checkout.module.css +0 -404
  160. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/layout.tsx +0 -0
  161. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/[orderNumber]/page.tsx +0 -0
  162. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/page.tsx +0 -0
  163. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/page.tsx +0 -0
@@ -0,0 +1,80 @@
1
+ import Link from 'next/link'
2
+ import { notFound } from 'next/navigation'
3
+ import { preload } from 'react-dom'
4
+ import { fetchProducts, fetchCategories } from '@/lib/api'
5
+ import { cardImageOf, cardProductOf, renderNymbal } from '@/lib/render'
6
+ import { organizationJsonLd } from '@/lib/seo'
7
+ import { HeroSection } from '@/components/server/HeroSection'
8
+ import { CategoryNav, ProductGrid } from '@/components/client/elements'
9
+ import { NewsletterSignup } from '@/components/client/NewsletterSignup'
10
+ import styles from '@/styles/pages/home.module.css'
11
+
12
+ // `/` is rewritten here (next.config.mjs): a dynamic segment with no params prerenders nothing at build, when the API
13
+ // may be down, and Next caches its first render and regenerates it in the background every 300 s.
14
+ export const revalidate = 300
15
+
16
+ export function generateStaticParams(): Array<{ key: string }> {
17
+ return []
18
+ }
19
+
20
+ interface HomePageProps {
21
+ params: Promise<{ key: string }>
22
+ }
23
+
24
+ export default async function HomePage({ params }: HomePageProps) {
25
+ if ((await params).key !== 'index') notFound()
26
+ const [{ items }, categories] = await Promise.all([
27
+ fetchProducts({ limit: 8 }),
28
+ fetchCategories(),
29
+ ])
30
+ const products = items.map(cardProductOf)
31
+ // The first card's image is the page's likely LCP, and the preload scanner never reaches it inside the grid's shadow
32
+ // root: a preload in the head starts it. React's preload(), not a <link> element: a cached render writes an element
33
+ // and its preload both.
34
+ if (products[0] !== undefined) {
35
+ preload(cardImageOf(products[0]).url, { as: 'image', fetchPriority: 'high' })
36
+ }
37
+
38
+ const jsonLd = organizationJsonLd(
39
+ 'Nymbal Store',
40
+ process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000',
41
+ )
42
+
43
+ return (
44
+ <>
45
+ <script
46
+ type="application/ld+json"
47
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
48
+ />
49
+
50
+ <HeroSection />
51
+
52
+ <section className={`container ${styles.categoriesSection}`}>
53
+ <h2 className="section-title">Shop by Category</h2>
54
+ <CategoryNav
55
+ categories={categories}
56
+ serverHtml={
57
+ renderNymbal('nymbal-category-nav', { categories: JSON.stringify(categories) }).__html
58
+ }
59
+ />
60
+ </section>
61
+
62
+ <section className={`container ${styles.featuredSection}`}>
63
+ <div className={styles.featuredHeader}>
64
+ <h2 className={styles.featuredTitle}>Featured Products</h2>
65
+ <Link href="/products" className={styles.featuredViewAll}>
66
+ View All
67
+ </Link>
68
+ </div>
69
+ <ProductGrid
70
+ products={products}
71
+ serverHtml={
72
+ renderNymbal('nymbal-product-grid', { products: JSON.stringify(products) }).__html
73
+ }
74
+ />
75
+ </section>
76
+
77
+ <NewsletterSignup />
78
+ </>
79
+ )
80
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from 'react'
2
+ import { Header } from '@/components/server/Header'
3
+
4
+ // The catalog's pages are the same for every shopper, so Next may cache them: the header reads no request, and the
5
+ // mini-cart fills its count in the browser.
6
+ export default function CatalogLayout({ children }: { children: ReactNode }) {
7
+ return (
8
+ <>
9
+ <Header />
10
+ <main id="main">{children}</main>
11
+ </>
12
+ )
13
+ }
@@ -0,0 +1,7 @@
1
+ import { NotFoundView } from '@/components/server/NotFoundView'
2
+
3
+ // A catalog page with nothing to show (a product no longer sold), under the catalog's header. Next caches it with the
4
+ // page, so it reads no request.
5
+ export default function CatalogNotFound() {
6
+ return <NotFoundView />
7
+ }
@@ -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
+ }