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,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
+ }
@@ -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
  }