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
@@ -2,7 +2,8 @@ import type { Metadata } from 'next'
2
2
  import { notFound } from 'next/navigation'
3
3
  import Link from 'next/link'
4
4
  import { fetchOrder } from '@/lib/api'
5
- import { OrderSummary } from '@/components/server/OrderSummary'
5
+ import { OrderSummary } from '@/components/client/elements'
6
+ import { renderNymbal } from '@/lib/render'
6
7
  import { OrderStatusRefresh } from '@/components/client/OrderStatusRefresh'
7
8
  import { isPaid, orderHeading } from '@/lib/order-status'
8
9
  import styles from '@/styles/pages/confirmation.module.css'
@@ -59,6 +60,21 @@ export default async function OrderPage({ params }: OrderPageProps) {
59
60
 
60
61
  if (!order) notFound()
61
62
  const paid = isPaid(order.status)
63
+ const items = order.lineItems.map((item) => ({
64
+ name: `${item.productName}${item.variantName ? ` - ${item.variantName}` : ''}`,
65
+ qty: item.qty,
66
+ unitPriceMinor: item.unitPriceMinor,
67
+ lineTotalMinor: item.lineTotalMinor,
68
+ }))
69
+ const summary = {
70
+ items: JSON.stringify(items),
71
+ 'subtotal-minor': String(order.subtotalMinor),
72
+ 'discount-minor': String(order.discountTotalMinor ?? 0),
73
+ 'shipping-minor': String(order.shippingTotalMinor ?? 0),
74
+ 'tax-minor': String(order.taxTotalMinor ?? 0),
75
+ 'total-minor': String(order.totalMinor),
76
+ currency: order.currency,
77
+ }
62
78
 
63
79
  return (
64
80
  <div className={`container ${styles.page}`}>
@@ -97,12 +113,15 @@ export default async function OrderPage({ params }: OrderPageProps) {
97
113
  </div>
98
114
 
99
115
  <OrderSummary
100
- items={order.lineItems}
116
+ className={styles.summary}
117
+ items={items}
101
118
  subtotalMinor={order.subtotalMinor}
102
- currency={order.currency}
103
- shippingTotalMinor={order.shippingTotalMinor}
104
- taxTotalMinor={order.taxTotalMinor}
119
+ discountMinor={order.discountTotalMinor ?? 0}
120
+ shippingMinor={order.shippingTotalMinor ?? 0}
121
+ taxMinor={order.taxTotalMinor ?? 0}
105
122
  totalMinor={order.totalMinor}
123
+ currency={order.currency}
124
+ serverHtml={renderNymbal('nymbal-order-summary', summary).__html}
106
125
  />
107
126
 
108
127
  <div className={styles.orderCard}>
@@ -10,7 +10,7 @@ interface ErrorProps {
10
10
 
11
11
  export default function ErrorPage({ error: _error, reset }: ErrorProps) {
12
12
  return (
13
- <div className={styles.page}>
13
+ <main id="main" className={styles.page}>
14
14
  <div className={styles.wrapper}>
15
15
  <div className={styles.icon}>
16
16
  <svg
@@ -35,15 +35,11 @@ export default function ErrorPage({ error: _error, reset }: ErrorProps) {
35
35
  <Link href="/" className={styles.homeLink}>
36
36
  Go Home
37
37
  </Link>
38
- <button
39
- type="button"
40
- className={styles.retryButton}
41
- onClick={reset}
42
- >
38
+ <button type="button" className={styles.retryButton} onClick={reset}>
43
39
  Try Again
44
40
  </button>
45
41
  </div>
46
42
  </div>
47
- </div>
43
+ </main>
48
44
  )
49
45
  }
@@ -0,0 +1,13 @@
1
+ import { FOOTER_COLUMNS } from '@/lib/chrome'
2
+ import { fragment } from '@/lib/fragments'
3
+ import { renderElement } from '@/lib/render'
4
+
5
+ export const revalidate = 86400
6
+
7
+ export function GET(): Response {
8
+ return fragment(
9
+ renderElement('nymbal-site-footer', {
10
+ attributes: { columns: JSON.stringify(FOOTER_COLUMNS) },
11
+ }),
12
+ )
13
+ }
@@ -0,0 +1,18 @@
1
+ import { fragment, noFragment } from '@/lib/fragments'
2
+ import { canSearch, siteHeader } from '@/lib/header'
3
+ import { renderElement } from '@/lib/render'
4
+
5
+ export const revalidate = 3600
6
+ // A dynamic segment, so the build prerenders nothing when the API may be down; next.config.mjs rewrites
7
+ // `/fragments/<name>` to `/fragments/<name>/index`.
8
+ export function generateStaticParams(): Array<{ key: string }> {
9
+ return []
10
+ }
11
+
12
+ export async function GET(
13
+ _request: Request,
14
+ { params }: { params: Promise<{ key: string }> },
15
+ ): Promise<Response> {
16
+ if ((await params).key !== 'index') return noFragment()
17
+ return fragment(renderElement('nymbal-site-header', siteHeader(await canSearch())))
18
+ }
@@ -0,0 +1,23 @@
1
+ import { fetchCategories } from '@/lib/api'
2
+ import { fragment, noFragment } from '@/lib/fragments'
3
+ import { renderElement } from '@/lib/render'
4
+
5
+ export const revalidate = 21600
6
+ // A dynamic segment, so the build prerenders nothing when the API may be down; next.config.mjs rewrites
7
+ // `/fragments/<name>` to `/fragments/<name>/index`.
8
+ export function generateStaticParams(): Array<{ key: string }> {
9
+ return []
10
+ }
11
+
12
+ export async function GET(
13
+ _request: Request,
14
+ { params }: { params: Promise<{ key: string }> },
15
+ ): Promise<Response> {
16
+ if ((await params).key !== 'index') return noFragment()
17
+ const categories = await fetchCategories()
18
+ return fragment(
19
+ renderElement('nymbal-category-nav', {
20
+ attributes: { categories: JSON.stringify(categories) },
21
+ }),
22
+ )
23
+ }
@@ -0,0 +1,22 @@
1
+ import { fetchProduct } from '@/lib/api'
2
+ import { fragment, noFragment } from '@/lib/fragments'
3
+ import { cardProductOf, renderElement } from '@/lib/render'
4
+
5
+ export const revalidate = 300
6
+ // The build prerenders no product, since the API may be down then.
7
+ export function generateStaticParams(): Array<{ slug: string }> {
8
+ return []
9
+ }
10
+
11
+ export async function GET(
12
+ _request: Request,
13
+ { params }: { params: Promise<{ slug: string }> },
14
+ ): Promise<Response> {
15
+ const product = await fetchProduct((await params).slug)
16
+ if (product === null) return noFragment()
17
+ return fragment(
18
+ renderElement('nymbal-product-card', {
19
+ attributes: { product: JSON.stringify(cardProductOf(product)) },
20
+ }),
21
+ )
22
+ }
@@ -1,4 +1,4 @@
1
- @import '@nymbalhq/web-components/tokens.css';
1
+ @import './tokens.css';
2
2
 
3
3
  /* ── CSS Reset ─────────────────────────────────────────────────────── */
4
4
 
@@ -169,7 +169,8 @@ a:focus-visible {
169
169
  border: 1px solid transparent;
170
170
  border-radius: var(--nymbal-radius-md);
171
171
  cursor: pointer;
172
- transition: background-color var(--nymbal-transition-fast),
172
+ transition:
173
+ background-color var(--nymbal-transition-fast),
173
174
  color var(--nymbal-transition-fast),
174
175
  border-color var(--nymbal-transition-fast),
175
176
  box-shadow var(--nymbal-transition-fast);
@@ -243,7 +244,8 @@ select {
243
244
  background-color: var(--nymbal-color-surface);
244
245
  border: 1px solid var(--nymbal-color-border);
245
246
  border-radius: var(--nymbal-radius-md);
246
- transition: border-color var(--nymbal-transition-fast),
247
+ transition:
248
+ border-color var(--nymbal-transition-fast),
247
249
  box-shadow var(--nymbal-transition-fast);
248
250
  appearance: none;
249
251
  }
@@ -281,26 +283,6 @@ label {
281
283
  margin-top: var(--nymbal-spacing-xs);
282
284
  }
283
285
 
284
- /* ── Product Grid ──────────────────────────────────────────────────── */
285
-
286
- .product-grid {
287
- display: grid;
288
- grid-template-columns: 1fr;
289
- gap: var(--nymbal-spacing-lg);
290
- }
291
-
292
- @media (min-width: 640px) {
293
- .product-grid {
294
- grid-template-columns: repeat(2, 1fr);
295
- }
296
- }
297
-
298
- @media (min-width: 1024px) {
299
- .product-grid {
300
- grid-template-columns: repeat(4, 1fr);
301
- }
302
- }
303
-
304
286
  /* ── Link Styles ───────────────────────────────────────────────────── */
305
287
 
306
288
  .link-muted {
@@ -2,25 +2,28 @@ import type { ReactNode } from 'react'
2
2
  import type { Metadata } from 'next'
3
3
  import './globals.css'
4
4
  import { NymbalSetup } from '@/components/client/NymbalSetup'
5
- import { Header } from '@/components/server/Header'
6
- import { Footer } from '@/components/server/Footer'
5
+ import { SiteFooter } from '@/components/client/elements'
7
6
  import { SkipNav } from '@/components/server/SkipNav'
8
7
  import { CartDrawerToggle } from '@/components/client/CartDrawerToggle'
8
+ import { FOOTER_COLUMNS } from '@/lib/chrome'
9
+ import { renderNymbal } from '@/lib/render'
9
10
 
10
11
  export const metadata: Metadata = {
11
12
  title: { default: 'Nymbal Store', template: '%s | Nymbal' },
12
13
  description: 'Shop the Nymbal store',
13
14
  }
14
15
 
16
+ // The root layout makes no API call: Next renders the 404 under it at build, when the API may be down. Each route
17
+ // group's layout brings the header the group's pages share, (catalog)'s cached for every shopper, (shopper)'s per request.
15
18
  export default function RootLayout({ children }: { children: ReactNode }) {
19
+ const footer = renderNymbal('nymbal-site-footer', { columns: JSON.stringify(FOOTER_COLUMNS) })
16
20
  return (
17
21
  <html lang="en">
18
22
  <body>
19
23
  <NymbalSetup>
20
24
  <SkipNav />
21
- <Header />
22
- <main id="main">{children}</main>
23
- <Footer />
25
+ {children}
26
+ <SiteFooter columns={FOOTER_COLUMNS} serverHtml={footer.__html} />
24
27
  <CartDrawerToggle />
25
28
  </NymbalSetup>
26
29
  </body>
@@ -1,38 +1,14 @@
1
- import Link from 'next/link'
2
- import styles from '@/styles/pages/error.module.css'
1
+ import { PlainHeader } from '@/components/server/Header'
2
+ import { NotFoundView } from '@/components/server/NotFoundView'
3
3
 
4
+ // A URL no route matches, and a shopper page with nothing to show.
4
5
  export default function NotFound() {
5
6
  return (
6
- <div className={styles.page}>
7
- <div className={styles.wrapper}>
8
- <div className={`${styles.icon} ${styles.notFoundIcon}`}>
9
- <svg
10
- viewBox="0 0 24 24"
11
- fill="none"
12
- stroke="currentColor"
13
- strokeWidth="2"
14
- strokeLinecap="round"
15
- strokeLinejoin="round"
16
- aria-hidden="true"
17
- >
18
- <circle cx="11" cy="11" r="8" />
19
- <line x1="21" y1="21" x2="16.65" y2="16.65" />
20
- </svg>
21
- </div>
22
- <p className={styles.statusCode}>404</p>
23
- <h1 className={styles.title}>Page Not Found</h1>
24
- <p className={styles.message}>
25
- The page you are looking for does not exist or may have been moved.
26
- </p>
27
- <div className={styles.actions}>
28
- <Link href="/" className={styles.homeLink}>
29
- Go Home
30
- </Link>
31
- <Link href="/products" className={styles.retryButton}>
32
- Browse Products
33
- </Link>
34
- </div>
35
- </div>
36
- </div>
7
+ <>
8
+ <PlainHeader />
9
+ <main id="main">
10
+ <NotFoundView />
11
+ </main>
12
+ </>
37
13
  )
38
14
  }
@@ -0,0 +1,56 @@
1
+ /* ----------------------------------------------------------------
2
+ Design tokens: this store's file, edit it to restyle.
3
+ Every Nymbal element reads its look from these custom properties,
4
+ which cross into its shadow root. An element's `part`s can be
5
+ styled further with ::part(), e.g. nymbal-add-to-cart::part(button).
6
+ Defaults: @nymbalhq/web-components/tokens.css.
7
+ ---------------------------------------------------------------- */
8
+
9
+ :root {
10
+ --nymbal-color-primary: #18181b;
11
+ --nymbal-color-primary-hover: #27272a;
12
+ --nymbal-color-accent: #2563eb;
13
+ --nymbal-color-accent-hover: #1d4ed8;
14
+ --nymbal-color-success: #16a34a;
15
+ --nymbal-color-error: #dc2626;
16
+ --nymbal-color-warning: #d97706;
17
+ --nymbal-color-text: #18181b;
18
+ --nymbal-color-text-muted: #71717a;
19
+ --nymbal-color-text-inverse: #ffffff;
20
+ --nymbal-color-border: #e4e4e7;
21
+ --nymbal-color-surface: #ffffff;
22
+ --nymbal-color-overlay: rgba(0, 0, 0, 0.4);
23
+
24
+ --nymbal-font-family: system-ui, -apple-system, sans-serif;
25
+ --nymbal-font-size-xs: 0.75rem;
26
+ --nymbal-font-size-sm: 0.875rem;
27
+ --nymbal-font-size-base: 1rem;
28
+ --nymbal-font-size-lg: 1.125rem;
29
+ --nymbal-font-size-xl: 1.25rem;
30
+ --nymbal-font-weight-normal: 400;
31
+ --nymbal-font-weight-medium: 500;
32
+ --nymbal-font-weight-bold: 700;
33
+
34
+ --nymbal-spacing-xs: 0.25rem;
35
+ --nymbal-spacing-sm: 0.5rem;
36
+ --nymbal-spacing-md: 1rem;
37
+ --nymbal-spacing-lg: 1.5rem;
38
+ --nymbal-spacing-xl: 2rem;
39
+
40
+ --nymbal-radius-sm: 0.25rem;
41
+ --nymbal-radius-md: 0.375rem;
42
+ --nymbal-radius-lg: 0.5rem;
43
+ --nymbal-radius-full: 9999px;
44
+
45
+ --nymbal-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
46
+ --nymbal-shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
47
+ --nymbal-shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
48
+
49
+ --nymbal-transition-fast: 150ms ease;
50
+ --nymbal-transition-normal: 200ms ease;
51
+ --nymbal-transition-slow: 300ms ease;
52
+
53
+ --nymbal-z-dropdown: 100;
54
+ --nymbal-z-drawer: 200;
55
+ --nymbal-z-toast: 300;
56
+ }
@@ -0,0 +1,87 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState, type ReactNode } from 'react'
4
+ import type { Facet, ProductSortKey } from '@nymbalhq/types'
5
+ import { ProductFilter, SortDropdown, useNymbalClient } from '@nymbalhq/react'
6
+ import styles from '@/styles/pages/plp.module.css'
7
+
8
+ interface ListingControlsProps {
9
+ /** The server-rendered listing's sort and category: the product-list store loads the same page on mount. */
10
+ sort: ProductSortKey
11
+ category?: string
12
+ /** The listing's facets: none when the store's search cannot facet, and then no filter. */
13
+ facets: Facet[]
14
+ /** The sort keys the store reports, in order, and the one it lists by when none is asked. */
15
+ sortKeys: ProductSortKey[]
16
+ defaultSort?: ProductSortKey | undefined
17
+ /** The listing's `nymbal-product-grid`, which follows the store once this load settles. */
18
+ children: ReactNode
19
+ }
20
+
21
+ /** A listing's filter and sort around its grid: they drive the product-list store, which the grid follows. */
22
+ export function ListingControls({
23
+ sort,
24
+ category,
25
+ facets,
26
+ sortKeys,
27
+ defaultSort,
28
+ children,
29
+ }: ListingControlsProps) {
30
+ const client = useNymbalClient()
31
+ const [showMobileFilters, setShowMobileFilters] = useState(false)
32
+
33
+ useEffect(() => {
34
+ void client.productList.load({ limit: 24, category, sort })
35
+ }, [client, category, sort])
36
+
37
+ // The URL carries the sort, so a reload or a shared link renders the same order server-side.
38
+ const handleSort = useCallback(
39
+ (event: CustomEvent<{ sort: string }>) => {
40
+ const key = event.detail.sort as ProductSortKey
41
+ const url = new URL(window.location.href)
42
+ url.searchParams.set('sort', key)
43
+ window.history.replaceState(window.history.state, '', url)
44
+ void client.productList.setSort(key)
45
+ },
46
+ [client],
47
+ )
48
+
49
+ return (
50
+ <div className={styles.layout}>
51
+ <aside className={styles.sidebar}>
52
+ {facets.length > 0 && (
53
+ <>
54
+ <button
55
+ className={styles.mobileFilterToggle}
56
+ onClick={() => setShowMobileFilters((v) => !v)}
57
+ type="button"
58
+ >
59
+ {showMobileFilters ? 'Hide Filters' : 'Show Filters'}
60
+ </button>
61
+ <div
62
+ className={styles.filterSection}
63
+ style={{ display: showMobileFilters ? 'block' : undefined }}
64
+ >
65
+ <ProductFilter facets={facets} />
66
+ </div>
67
+ </>
68
+ )}
69
+ </aside>
70
+
71
+ <div className={styles.content}>
72
+ {sortKeys.length > 0 && (
73
+ <div className={styles.sortBar}>
74
+ <SortDropdown
75
+ className={styles.sortSelect}
76
+ sortKeys={sortKeys}
77
+ value={sort}
78
+ defaultSort={defaultSort}
79
+ onSortChange={handleSort}
80
+ />
81
+ </div>
82
+ )}
83
+ {children}
84
+ </div>
85
+ </div>
86
+ )
87
+ }
@@ -1,24 +1,66 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useRef, type ReactNode } from 'react'
3
+ import { useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'
4
+ import { useRouter } from 'next/navigation'
4
5
  import { NymbalProvider } from '@nymbalhq/react'
5
- import { registerClient, registerComponents } from '@nymbalhq/web-components'
6
- import '@nymbalhq/web-components/tokens.css'
6
+ import '@/elements'
7
+ import { registerClient } from '@nymbalhq/web-components'
8
+ import { NymbalCartDrawer } from '@nymbalhq/web-components/elements/nymbal-cart-drawer'
9
+ import { NymbalMiniCart } from '@nymbalhq/web-components/elements/nymbal-mini-cart'
10
+ import { NymbalSearchBar } from '@nymbalhq/web-components/elements/nymbal-search-bar'
11
+ import { NymbalSiteFooter } from '@nymbalhq/web-components/elements/nymbal-site-footer'
12
+ import { NymbalSiteHeader } from '@nymbalhq/web-components/elements/nymbal-site-header'
7
13
  import { getNymbalClient, restoreSession } from '@/lib/client'
8
14
 
9
15
  export function NymbalSetup({ children }: { children: ReactNode }) {
10
16
  const client = getNymbalClient()
17
+ const router = useRouter()
11
18
  const initialized = useRef(false)
12
19
 
13
- useEffect(() => {
20
+ // A layout effect runs before every passive effect of the commit, so the client is registered before any wrapper
21
+ // defines its element (each wrapper defines its own, so a route loads only the elements it renders). The cart starts
22
+ // loading first, so an element the server rendered from the cart keeps showing it until the load settles.
23
+ useLayoutEffect(() => {
14
24
  if (!initialized.current) {
15
25
  registerClient(client)
16
- registerComponents()
17
- client.cart.load()
26
+ void client.cart.load()
27
+ NymbalSiteHeader.define()
28
+ NymbalSiteFooter.define()
29
+ NymbalSearchBar.define()
30
+ NymbalMiniCart.define()
31
+ NymbalCartDrawer.define()
18
32
  void restoreSession()
19
33
  initialized.current = true
20
34
  }
21
35
  }, [client])
22
36
 
37
+ // The elements' links are plain anchors in shadow roots, which the router does not see: each asks with
38
+ // `nymbal:navigate`, and the router takes it. The header's search bar and mini-cart are in its server HTML, not
39
+ // React children, so their events are taken here too.
40
+ useEffect(() => {
41
+ const navigate = (event: Event): void => {
42
+ const href = (event as CustomEvent<{ href?: string }>).detail?.href
43
+ if (!href || new URL(href, window.location.href).origin !== window.location.origin) return
44
+ event.preventDefault()
45
+ router.push(href)
46
+ }
47
+ const searchSelected = (event: Event): void => {
48
+ const slug = (event as CustomEvent<{ product?: { slug?: string } }>).detail?.product?.slug
49
+ if (slug) router.push(`/product/${encodeURIComponent(slug)}`)
50
+ }
51
+ // Before the drawer's own listener toggles it, as the wrapper's listener on the mini-cart ran.
52
+ const miniCartClicked = (): void => {
53
+ window.dispatchEvent(new CustomEvent('nymbal:cart:open'))
54
+ }
55
+ document.addEventListener('nymbal:navigate', navigate)
56
+ document.addEventListener('nymbal:search:selected', searchSelected)
57
+ document.addEventListener('nymbal:mini-cart:clicked', miniCartClicked, true)
58
+ return () => {
59
+ document.removeEventListener('nymbal:navigate', navigate)
60
+ document.removeEventListener('nymbal:search:selected', searchSelected)
61
+ document.removeEventListener('nymbal:mini-cart:clicked', miniCartClicked, true)
62
+ }
63
+ }, [router])
64
+
23
65
  return <NymbalProvider client={client}>{children}</NymbalProvider>
24
66
  }
@@ -1,60 +1,96 @@
1
1
  'use client'
2
2
 
3
- import type { DenormalisedProduct, DenormalisedVariant } from '@nymbalhq/sdk'
4
- import { useProduct, VariantSelector, AddToCart } from '@nymbalhq/react'
5
- import { formatPrice } from '@/lib/format'
3
+ import { useRef, useState } from 'react'
4
+ import type { DenormalisedProduct } from '@nymbalhq/sdk'
5
+ import {
6
+ useProduct,
7
+ VariantSelector,
8
+ AddToCart,
9
+ ProductPrice,
10
+ QuantitySelector,
11
+ StockBadge,
12
+ } from '@nymbalhq/react'
6
13
  import styles from '@/styles/pages/pdp.module.css'
7
14
 
8
15
  interface ProductInteractiveProps {
9
16
  product: DenormalisedProduct
17
+ /** The server's render of each element, for the product's first variant (app/product/[slug]/page.tsx). */
18
+ serverHtml?: {
19
+ price: string
20
+ stock: string
21
+ variantSelector: string
22
+ quantity: string
23
+ addToCart: string
24
+ }
10
25
  }
11
26
 
12
- export function ProductInteractive({ product }: ProductInteractiveProps) {
27
+ export function ProductInteractive({ product, serverHtml }: ProductInteractiveProps) {
13
28
  const { selectedVariant } = useProduct(product.slug)
14
29
 
15
30
  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)
31
+ // A product with no variants has nothing to sell, so it reads as out of stock.
32
+ const stock = variant?.stock ?? 0
33
+ // The price and the badge are the server's first variant until the store settles on this product; they follow it
34
+ // themselves, so a soft navigation from another product never shows that product's price here.
35
+ const first = product.variants?.[0]
36
+
37
+ // The quantity belongs to the variant it was chosen for, so a variant change starts again at 1.
38
+ const [chosen, setChosen] = useState<{ variantId: string | undefined; qty: number }>({
39
+ variantId: variant?.id,
40
+ qty: 1,
41
+ })
42
+ // Reset on the change itself, so a quantity left on a variant is not there again when the shopper comes back to it.
43
+ if (chosen.variantId !== variant?.id) setChosen({ variantId: variant?.id, qty: 1 })
44
+ const qty = chosen.variantId === variant?.id ? chosen.qty : 1
45
+ const addToCartRef = useRef<HTMLElement>(null)
19
46
 
20
47
  // The selector derives its option groups from the product's variants.
21
48
  const hasVariantChoice = (product.variants?.length ?? 0) > 1
22
49
 
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
50
  return (
31
51
  <div>
32
52
  <h1 className={styles.productTitle} data-testid="product-title">
33
53
  {product.name}
34
54
  </h1>
35
55
 
36
- <div className={styles.price} data-testid="product-price">
37
- {formatPrice(price, currency)}
38
- </div>
56
+ <ProductPrice
57
+ productId={product.id}
58
+ priceMinor={first?.priceMinor ?? product.priceMinor ?? 0}
59
+ currency={product.currency ?? 'GBP'}
60
+ serverHtml={serverHtml?.price}
61
+ />
39
62
 
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>
63
+ <StockBadge productId={product.id} stock={first?.stock ?? 0} serverHtml={serverHtml?.stock} />
49
64
 
50
65
  {hasVariantChoice && (
51
66
  <div className={styles.variantSection}>
52
- <VariantSelector onVariantSelected={handleVariantSelected} />
67
+ <VariantSelector product={product} serverHtml={serverHtml?.variantSelector} />
53
68
  </div>
54
69
  )}
55
70
 
56
71
  <div className={styles.addToCartSection}>
57
- <AddToCart variantId={variant?.id ?? product.id} disabled={stock === 0} />
72
+ <div className={styles.quantityWrapper}>
73
+ <QuantitySelector
74
+ key={variant?.id}
75
+ value={1}
76
+ min={1}
77
+ max={99}
78
+ serverHtml={serverHtml?.quantity}
79
+ onQuantityChange={(e) => {
80
+ // A typed quantity changes on blur, and the press on Add is what blurs it: the wrapper's effect lands
81
+ // after a fast click has already read `qty`, so the element gets it here, in the same event.
82
+ addToCartRef.current?.setAttribute('qty', String(e.detail.value))
83
+ setChosen({ variantId: variant?.id, qty: e.detail.value })
84
+ }}
85
+ />
86
+ </div>
87
+ <AddToCart
88
+ ref={addToCartRef}
89
+ variantId={variant?.id ?? product.id}
90
+ qty={qty}
91
+ disabled={stock <= 0}
92
+ serverHtml={serverHtml?.addToCart}
93
+ />
58
94
  </div>
59
95
  </div>
60
96
  )
@@ -0,0 +1,15 @@
1
+ 'use client'
2
+
3
+ // The generated wrappers the server components render with their server HTML (`renderNymbal`): each defines its
4
+ // element and keeps its attributes in the browser, so it is a client module.
5
+ export {
6
+ Breadcrumb,
7
+ CategoryNav,
8
+ ImageGallery,
9
+ OrderSummary,
10
+ ProductGrid,
11
+ ReviewForm,
12
+ ReviewList,
13
+ SiteFooter,
14
+ SiteHeader,
15
+ } from '@nymbalhq/react'