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
@@ -2,24 +2,37 @@
2
2
 
3
3
  import { useRef, useState } from 'react'
4
4
  import type { DenormalisedProduct } from '@nymbalhq/sdk'
5
- import { STOCK_LABELS, stockLevel } from '@nymbalhq/types'
6
- import { useProduct, VariantSelector, AddToCart, QuantitySelector } from '@nymbalhq/react'
7
- import { formatPrice } from '@/lib/format'
5
+ import {
6
+ useProduct,
7
+ VariantSelector,
8
+ AddToCart,
9
+ ProductPrice,
10
+ QuantitySelector,
11
+ StockBadge,
12
+ } from '@nymbalhq/react'
8
13
  import styles from '@/styles/pages/pdp.module.css'
9
14
 
10
15
  interface ProductInteractiveProps {
11
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
+ }
12
25
  }
13
26
 
14
- export function ProductInteractive({ product }: ProductInteractiveProps) {
27
+ export function ProductInteractive({ product, serverHtml }: ProductInteractiveProps) {
15
28
  const { selectedVariant } = useProduct(product.slug)
16
29
 
17
30
  const variant = selectedVariant ?? product.variants?.[0]
18
- const price = variant?.priceMinor ?? product.priceMinor ?? 0
19
- const currency = product.currency ?? 'GBP'
20
31
  // A product with no variants has nothing to sell, so it reads as out of stock.
21
32
  const stock = variant?.stock ?? 0
22
- const level = stockLevel(stock)
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]
23
36
 
24
37
  // The quantity belongs to the variant it was chosen for, so a variant change starts again at 1.
25
38
  const [chosen, setChosen] = useState<{ variantId: string | undefined; qty: number }>({
@@ -40,18 +53,18 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
40
53
  {product.name}
41
54
  </h1>
42
55
 
43
- <div className={styles.price} data-testid="product-price">
44
- {formatPrice(price, currency)}
45
- </div>
56
+ <ProductPrice
57
+ productId={product.id}
58
+ priceMinor={first?.priceMinor ?? product.priceMinor ?? 0}
59
+ currency={product.currency ?? 'GBP'}
60
+ serverHtml={serverHtml?.price}
61
+ />
46
62
 
47
- <div className={styles.stockBadge} data-testid="stock-badge" data-level={level}>
48
- <span className={styles.stockDot} aria-hidden="true" />
49
- {STOCK_LABELS[level]}
50
- </div>
63
+ <StockBadge productId={product.id} stock={first?.stock ?? 0} serverHtml={serverHtml?.stock} />
51
64
 
52
65
  {hasVariantChoice && (
53
66
  <div className={styles.variantSection}>
54
- <VariantSelector />
67
+ <VariantSelector product={product} serverHtml={serverHtml?.variantSelector} />
55
68
  </div>
56
69
  )}
57
70
 
@@ -62,6 +75,7 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
62
75
  value={1}
63
76
  min={1}
64
77
  max={99}
78
+ serverHtml={serverHtml?.quantity}
65
79
  onQuantityChange={(e) => {
66
80
  // A typed quantity changes on blur, and the press on Add is what blurs it: the wrapper's effect lands
67
81
  // after a fast click has already read `qty`, so the element gets it here, in the same event.
@@ -75,6 +89,7 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
75
89
  variantId={variant?.id ?? product.id}
76
90
  qty={qty}
77
91
  disabled={stock <= 0}
92
+ serverHtml={serverHtml?.addToCart}
78
93
  />
79
94
  </div>
80
95
  </div>
@@ -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'
@@ -1,107 +1,38 @@
1
- import Link from 'next/link'
2
- import { SearchBarWrapper } from '@/components/client/SearchBarWrapper'
3
- import { MiniCartWrapper } from '@/components/client/MiniCartWrapper'
4
- import { fetchCapabilities } from '@/lib/api'
5
- import styles from './Header.module.css'
1
+ import { headers } from 'next/headers'
2
+ import { SiteHeader } from '@/components/client/elements'
3
+ import { fetchCart } from '@/lib/api'
4
+ import { HEADER } from '@/lib/chrome'
5
+ import { canSearch, siteHeader } from '@/lib/header'
6
+ import { renderNymbal } from '@/lib/render'
6
7
 
7
- const NAV_LINKS = [
8
- { href: '/products', label: 'Shop' },
9
- { href: '/products?category=new', label: 'New Arrivals' },
10
- { href: '/products?category=sale', label: 'Sale' },
11
- ]
12
-
13
- export async function Header() {
14
- const capabilities = await fetchCapabilities()
15
- const canSearch = capabilities?.search.capabilities.includes('search') ?? false
8
+ function HeaderElement({ header }: { header: ReturnType<typeof siteHeader> }) {
9
+ const serverHtml = renderNymbal('nymbal-site-header', header.attributes, header.children).__html
16
10
  return (
17
- <header className={styles.header} data-testid="site-header">
18
- <div className={`container ${styles.inner}`}>
19
- <Link href="/" className={styles.logo}>
20
- Nymbal
21
- </Link>
22
-
23
- <nav className={styles.nav} aria-label="Main navigation">
24
- <ul className={styles.navList}>
25
- {NAV_LINKS.map((link) => (
26
- <li key={link.href}>
27
- <Link href={link.href} className={styles.navLink}>
28
- {link.label}
29
- </Link>
30
- </li>
31
- ))}
32
- </ul>
33
- </nav>
34
-
35
- <div className={styles.actions}>
36
- {canSearch && (
37
- <div className={styles.search}>
38
- <SearchBarWrapper />
39
- </div>
40
- )}
41
- <Link href="/account" className={styles.iconLink} aria-label="Account">
42
- <svg
43
- width="20"
44
- height="20"
45
- viewBox="0 0 24 24"
46
- fill="none"
47
- stroke="currentColor"
48
- strokeWidth="2"
49
- strokeLinecap="round"
50
- strokeLinejoin="round"
51
- aria-hidden="true"
52
- >
53
- <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
54
- <circle cx="12" cy="7" r="4" />
55
- </svg>
56
- </Link>
57
- <MiniCartWrapper />
58
- </div>
11
+ <SiteHeader
12
+ links={HEADER.links}
13
+ logo={HEADER.logo}
14
+ accountHref={HEADER.accountHref}
15
+ serverHtml={serverHtml}
16
+ />
17
+ )
18
+ }
59
19
 
60
- <input
61
- type="checkbox"
62
- id="mobile-menu-toggle"
63
- className={styles.menuCheckbox}
64
- aria-hidden="true"
65
- />
66
- <label
67
- htmlFor="mobile-menu-toggle"
68
- className={styles.menuButton}
69
- aria-label="Toggle menu"
70
- >
71
- <svg
72
- width="24"
73
- height="24"
74
- viewBox="0 0 24 24"
75
- fill="none"
76
- stroke="currentColor"
77
- strokeWidth="2"
78
- strokeLinecap="round"
79
- strokeLinejoin="round"
80
- aria-hidden="true"
81
- >
82
- <line x1="3" y1="6" x2="21" y2="6" />
83
- <line x1="3" y1="12" x2="21" y2="12" />
84
- <line x1="3" y1="18" x2="21" y2="18" />
85
- </svg>
86
- </label>
20
+ /**
21
+ * The header of a page. `shopper` reads the request's cart cookie, so the count is in the HTML with JavaScript off and
22
+ * the page renders per request; without it the page may be cached for every shopper, and the mini-cart fills its count
23
+ * in the browser.
24
+ */
25
+ export async function Header({ shopper = false }: { shopper?: boolean }) {
26
+ const cookie = shopper ? (await headers()).get('cookie') : null
27
+ const [search, cart] = await Promise.all([canSearch(), shopper ? fetchCart(cookie) : null])
28
+ return <HeaderElement header={siteHeader(search, cart?.itemCount)} />
29
+ }
87
30
 
88
- <nav className={styles.mobileNav} aria-label="Mobile navigation">
89
- <ul className={styles.mobileNavList}>
90
- {NAV_LINKS.map((link) => (
91
- <li key={link.href}>
92
- <Link href={link.href} className={styles.mobileNavLink}>
93
- {link.label}
94
- </Link>
95
- </li>
96
- ))}
97
- <li>
98
- <Link href="/account" className={styles.mobileNavLink}>
99
- Account
100
- </Link>
101
- </li>
102
- </ul>
103
- </nav>
104
- </div>
105
- </header>
106
- )
31
+ /**
32
+ * The header with no search bar and no count, for the root 404: Next renders that 404 into every page under the root
33
+ * layout and at build, so it can neither read the request (a cached page would fail) nor call the API (a build with
34
+ * the API down would fail).
35
+ */
36
+ export function PlainHeader() {
37
+ return <HeaderElement header={siteHeader(false)} />
107
38
  }
@@ -0,0 +1,39 @@
1
+ import Link from 'next/link'
2
+ import styles from '@/styles/pages/error.module.css'
3
+
4
+ /** The 404's message and its ways back, under whichever header the 404 has. */
5
+ export function NotFoundView() {
6
+ return (
7
+ <div className={styles.page}>
8
+ <div className={styles.wrapper}>
9
+ <div className={`${styles.icon} ${styles.notFoundIcon}`}>
10
+ <svg
11
+ viewBox="0 0 24 24"
12
+ fill="none"
13
+ stroke="currentColor"
14
+ strokeWidth="2"
15
+ strokeLinecap="round"
16
+ strokeLinejoin="round"
17
+ aria-hidden="true"
18
+ >
19
+ <circle cx="11" cy="11" r="8" />
20
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
21
+ </svg>
22
+ </div>
23
+ <p className={styles.statusCode}>404</p>
24
+ <h1 className={styles.title}>Page Not Found</h1>
25
+ <p className={styles.message}>
26
+ The page you are looking for does not exist or may have been moved.
27
+ </p>
28
+ <div className={styles.actions}>
29
+ <Link href="/" className={styles.homeLink}>
30
+ Go Home
31
+ </Link>
32
+ <Link href="/products" className={styles.retryButton}>
33
+ Browse Products
34
+ </Link>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ )
39
+ }
@@ -1,7 +1,11 @@
1
+ // In a landmark of its own: the header's links are in its shadow root, where a checker cannot tell the skip link
2
+ // comes before them, so it would count it as content outside every landmark.
1
3
  export function SkipNav() {
2
4
  return (
3
- <a href="#main" className="skip-nav">
4
- Skip to content
5
- </a>
5
+ <nav aria-label="Skip link">
6
+ <a href="#main" className="skip-nav">
7
+ Skip to content
8
+ </a>
9
+ </nav>
6
10
  )
7
11
  }
@@ -0,0 +1,12 @@
1
+ // This store's own elements. Everything here runs before any Nymbal element is defined (components/client/
2
+ // NymbalSetup.tsx imports it first), and Nymbal leaves alone any tag that is already defined, so an element defined
3
+ // here under a Nymbal tag replaces Nymbal's. The server renders Nymbal's elements too, so a replacement is also named
4
+ // in `serverElements`, and the server renders it under the tag (lib/render.ts). The module is evaluated on the server
5
+ // as well, so define elements only in the browser, and import @nymbalhq/web-components/hydrate-support before lit,
6
+ // so the element takes over what the server rendered:
7
+ //
8
+ // import '@nymbalhq/web-components/hydrate-support'
9
+ // import { MyMiniCart } from './my-mini-cart'
10
+ // if (typeof window !== 'undefined') customElements.define('nymbal-mini-cart', MyMiniCart)
11
+ // export const serverElements = { 'nymbal-mini-cart': MyMiniCart }
12
+ export const serverElements = {}
@@ -1,10 +1,56 @@
1
+ import { cache } from 'react'
1
2
  import { unstable_rethrow } from 'next/navigation'
2
- import type { DenormalisedProduct, ProductListResult, ReviewsListResult } from '@nymbalhq/sdk'
3
+ import type {
4
+ DenormalisedProduct,
5
+ ProductListResult,
6
+ ProductSearchResult,
7
+ ReviewsListResult,
8
+ } from '@nymbalhq/sdk'
3
9
  import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
4
- import type { Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
10
+ import type { Cart, Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
5
11
 
6
12
  const API_URL = process.env.NYMBAL_API_URL ?? 'http://localhost:3001'
7
13
 
14
+ /**
15
+ * Seconds a catalog read stays in Next's data cache: each is at least the TTL of the longest route that reads it, since
16
+ * a route's TTL is the smallest of its segment's and its fetches'. NYMBAL_CATALOG_REVALIDATE_S replaces every one.
17
+ */
18
+ const CATALOG_REVALIDATE_S = {
19
+ capabilities: 3600,
20
+ categories: 21600,
21
+ product: 300,
22
+ products: 300,
23
+ reviews: 60,
24
+ } as const
25
+
26
+ function revalidateOf(read: keyof typeof CATALOG_REVALIDATE_S, seconds?: number): number {
27
+ const override = Number(process.env.NYMBAL_CATALOG_REVALIDATE_S)
28
+ if (Number.isInteger(override) && override > 0) return override
29
+ return seconds ?? CATALOG_REVALIDATE_S[read]
30
+ }
31
+
32
+ /**
33
+ * A catalog read through Next's data cache. It resolves a 200 or a 404 (an answer about the data, which the caller
34
+ * reads); a network error, a 5xx or any other refusal (a rate limit's 429) rejects, so a failed first render is an
35
+ * error Next does not cache and a failed regeneration keeps the last good page, where an empty value would be cached
36
+ * for every shopper. `next dev` caches no page but still keeps fetches, so there the read is fresh and a catalog edit
37
+ * shows at once.
38
+ */
39
+ async function catalogFetch(
40
+ url: string,
41
+ read: keyof typeof CATALOG_REVALIDATE_S,
42
+ seconds?: number,
43
+ ): Promise<Response> {
44
+ const res = await fetch(
45
+ url,
46
+ process.env.NODE_ENV === 'development'
47
+ ? { cache: 'no-store' }
48
+ : { next: { revalidate: revalidateOf(read, seconds) } },
49
+ )
50
+ if (!res.ok && res.status !== 404) throw new Error(`[api] GET ${url} answered ${res.status}`)
51
+ return res
52
+ }
53
+
8
54
  export async function fetchProducts(params?: {
9
55
  limit?: number
10
56
  cursor?: string
@@ -17,30 +63,18 @@ export async function fetchProducts(params?: {
17
63
  if (params?.category) url.searchParams.set('category', params.category)
18
64
  if (params?.sort) url.searchParams.set('sort', params.sort)
19
65
 
20
- try {
21
- const res = await fetch(url.toString(), { cache: 'no-store' })
22
- if (!res.ok) return { items: [], nextCursor: null }
23
- const envelope = (await res.json()) as { data: ProductListResult }
24
- return envelope.data ?? { items: [], nextCursor: null }
25
- } catch (err) {
26
- console.error('[api] fetchProducts failed', err)
27
- return { items: [], nextCursor: null }
28
- }
66
+ const res = await catalogFetch(url.toString(), 'products')
67
+ if (!res.ok) return { items: [], nextCursor: null }
68
+ const envelope = (await res.json()) as { data: ProductListResult }
69
+ return envelope.data ?? { items: [], nextCursor: null }
29
70
  }
30
71
 
31
- /** What the store can do, which decides what the page renders; null when the API did not answer. */
72
+ /** What the store can do, which decides what the page renders; null when the API has no capabilities to report. */
32
73
  export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
33
- try {
34
- const res = await fetch(`${API_URL}/api/capabilities`, { cache: 'no-store' })
35
- if (!res.ok) return null
36
- const envelope = (await res.json()) as { data: StoreCapabilities }
37
- return envelope.data ?? null
38
- } catch (err) {
39
- // Next signals a request-time render by throwing from fetch at build; that must reach Next, not become null.
40
- unstable_rethrow(err)
41
- console.error('[api] fetchCapabilities failed', err)
42
- return null
43
- }
74
+ const res = await catalogFetch(`${API_URL}/api/capabilities`, 'capabilities')
75
+ if (!res.ok) return null
76
+ const envelope = (await res.json()) as { data: StoreCapabilities }
77
+ return envelope.data ?? null
44
78
  }
45
79
 
46
80
  /** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
@@ -53,57 +87,119 @@ export function sortFromSearchParams(
53
87
  return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
54
88
  }
55
89
 
56
- export async function fetchProduct(slug: string): Promise<DenormalisedProduct | null> {
90
+ /**
91
+ * The product, or null when the API has none by that slug (a 404, the only answer that is null). `revalidateS` is the
92
+ * caller's route TTL when it is shorter than the default: callers share one data-cache entry, and Next judges its
93
+ * staleness by each caller's own seconds, so the PDP's 60 s regeneration reads stock no older than 60 s.
94
+ */
95
+ export async function fetchProduct(
96
+ slug: string,
97
+ revalidateS?: number,
98
+ ): Promise<DenormalisedProduct | null> {
99
+ const url = `${API_URL}/api/products/${encodeURIComponent(slug)}`
100
+ const res = await catalogFetch(url, 'product', revalidateS)
101
+ if (res.status === 404) return null
102
+ const envelope = (await res.json()) as { data: DenormalisedProduct }
103
+ return envelope.data ?? null
104
+ }
105
+
106
+ /** The store's categories as the API lists them; never a list of its own, which a cached page would then show. */
107
+ export async function fetchCategories(): Promise<Category[]> {
108
+ const url = `${API_URL}/api/categories`
109
+ const res = await catalogFetch(url, 'categories')
110
+ if (!res.ok) throw new Error(`[api] GET ${url} answered ${res.status}`)
111
+ const envelope = (await res.json()) as { data: Category[] }
112
+ return envelope.data
113
+ }
114
+
115
+ export async function fetchOrder(orderNumber: string): Promise<Order | null> {
57
116
  try {
58
- const res = await fetch(`${API_URL}/api/products/${encodeURIComponent(slug)}`, { cache: 'no-store' })
117
+ const res = await fetch(`${API_URL}/api/orders/${encodeURIComponent(orderNumber)}`, {
118
+ cache: 'no-store',
119
+ })
59
120
  if (!res.ok) return null
60
- const envelope = (await res.json()) as { data: DenormalisedProduct }
121
+ const envelope = (await res.json()) as { data: Order }
61
122
  return envelope.data ?? null
62
123
  } catch (err) {
63
- console.error('[api] fetchProduct failed', err)
124
+ unstable_rethrow(err)
125
+ console.error('[api] fetchOrder failed', err)
64
126
  return null
65
127
  }
66
128
  }
67
129
 
68
- const SEED_CATEGORIES: Category[] = [
69
- { id: 'cat_apparel', parentId: null, name: 'Apparel', slug: 'apparel', description: 'Clothing and accessories', position: 0, createdAt: '2024-01-01T00:00:00Z' },
70
- { id: 'cat_home', parentId: null, name: 'Home', slug: 'home', description: 'Home goods and decor', position: 1, createdAt: '2024-01-01T00:00:00Z' },
71
- { id: 'cat_electronics', parentId: null, name: 'Electronics', slug: 'electronics', description: 'Gadgets and devices', position: 2, createdAt: '2024-01-01T00:00:00Z' },
72
- { id: 'cat_books', parentId: null, name: 'Books', slug: 'books', description: 'Books and reading', position: 3, createdAt: '2024-01-01T00:00:00Z' },
73
- ]
130
+ export async function fetchReviews(productId: string): Promise<ReviewsListResult> {
131
+ const res = await catalogFetch(
132
+ `${API_URL}/api/products/${encodeURIComponent(productId)}/reviews`,
133
+ 'reviews',
134
+ )
135
+ if (!res.ok) return { items: [], nextCursor: null }
136
+ const envelope = (await res.json()) as { data: ReviewsListResult }
137
+ return envelope.data ?? { items: [], nextCursor: null }
138
+ }
74
139
 
75
- export async function fetchCategories(): Promise<Category[]> {
76
- try {
77
- const res = await fetch(`${API_URL}/api/categories`, { cache: 'no-store' })
78
- if (!res.ok) return SEED_CATEGORIES
79
- const envelope = (await res.json()) as { data: Category[] }
80
- return envelope.data ?? SEED_CATEGORIES
81
- } catch (err) {
82
- console.error('[api] fetchCategories failed', err)
83
- return SEED_CATEGORIES
140
+ /** The cart the cart elements take as their `cart` attribute: its lines and sums, never the cart token. */
141
+ export interface CartProjection {
142
+ items: Cart['items']
143
+ subtotalMinor: number
144
+ currency: string
145
+ itemCount: number
146
+ }
147
+
148
+ const CART_COOKIE = 'nymbal.cart'
149
+
150
+ /** The `nymbal.cart` value in a request's Cookie header, or null. */
151
+ function cartToken(cookie: string | null): string | null {
152
+ for (const pair of (cookie ?? '').split(';')) {
153
+ const [name, ...value] = pair.trim().split('=')
154
+ if (name === CART_COOKIE && value.length > 0) return value.join('=')
84
155
  }
156
+ return null
85
157
  }
86
158
 
87
- export async function fetchOrder(orderNumber: string): Promise<Order | null> {
159
+ /**
160
+ * The shopper's cart, read with the request's `nymbal.cart` cookie, as the projection the elements take; once per
161
+ * request, however many server components ask. Null without the cookie: `GET /api/cart` with none would make a cart
162
+ * the page could not keep, so the element renders its shell and the browser fills it. The cookie reaches this server
163
+ * when the store and the API share a host or a proxy.
164
+ */
165
+ export const fetchCart = cache(async (cookie: string | null): Promise<CartProjection | null> => {
166
+ const token = cartToken(cookie)
167
+ if (token === null) return null
88
168
  try {
89
- const res = await fetch(`${API_URL}/api/orders/${encodeURIComponent(orderNumber)}`, { cache: 'no-store' })
169
+ const res = await fetch(`${API_URL}/api/cart`, {
170
+ cache: 'no-store',
171
+ headers: { Cookie: `${CART_COOKIE}=${token}` },
172
+ })
90
173
  if (!res.ok) return null
91
- const envelope = (await res.json()) as { data: Order }
92
- return envelope.data ?? null
174
+ const { items, subtotalMinor, currency } = ((await res.json()) as { data: Cart }).data
175
+ return {
176
+ items,
177
+ subtotalMinor,
178
+ currency,
179
+ itemCount: items.reduce((n, item) => n + item.qty, 0),
180
+ }
93
181
  } catch (err) {
94
- console.error('[api] fetchOrder failed', err)
182
+ unstable_rethrow(err)
183
+ console.error('[api] fetchCart failed', err)
95
184
  return null
96
185
  }
97
- }
186
+ })
98
187
 
99
- export async function fetchReviews(productId: string): Promise<ReviewsListResult> {
188
+ /** The search the search page shows for `q` (and its category), as the search store would hold it. */
189
+ export async function fetchSearch(
190
+ q: string,
191
+ category: string | null,
192
+ ): Promise<ProductSearchResult | null> {
193
+ const url = new URL('/api/products/search', API_URL)
194
+ url.searchParams.set('q', q)
195
+ if (category) url.searchParams.set('category', category)
100
196
  try {
101
- const res = await fetch(`${API_URL}/api/products/${encodeURIComponent(productId)}/reviews`, { cache: 'no-store' })
102
- if (!res.ok) return { items: [], nextCursor: null }
103
- const envelope = (await res.json()) as { data: ReviewsListResult }
104
- return envelope.data ?? { items: [], nextCursor: null }
197
+ const res = await fetch(url.toString(), { cache: 'no-store' })
198
+ if (!res.ok) return null
199
+ return ((await res.json()) as { data: ProductSearchResult }).data ?? null
105
200
  } catch (err) {
106
- console.error('[api] fetchReviews failed', err)
107
- return { items: [], nextCursor: null }
201
+ unstable_rethrow(err)
202
+ console.error('[api] fetchSearch failed', err)
203
+ return null
108
204
  }
109
205
  }
@@ -0,0 +1,40 @@
1
+ // The store's header and footer, rendered by nymbal-site-header and nymbal-site-footer: edit the links here.
2
+
3
+ export const HEADER = {
4
+ logo: 'Nymbal',
5
+ accountHref: '/account',
6
+ links: [
7
+ { href: '/products', label: 'Shop' },
8
+ { href: '/products?category=new', label: 'New Arrivals' },
9
+ { href: '/products?category=sale', label: 'Sale' },
10
+ ],
11
+ }
12
+
13
+ export const FOOTER_COLUMNS = [
14
+ {
15
+ heading: 'Shop',
16
+ links: [
17
+ { href: '/products', label: 'All Products' },
18
+ { href: '/products?category=new', label: 'New Arrivals' },
19
+ { href: '/products?category=sale', label: 'Sale' },
20
+ ],
21
+ },
22
+ {
23
+ heading: 'Account',
24
+ links: [
25
+ { href: '/account', label: 'My Account' },
26
+ { href: '/account/orders', label: 'Order History' },
27
+ { href: '/account/login', label: 'Sign In' },
28
+ { href: '/account/register', label: 'Create Account' },
29
+ ],
30
+ },
31
+ {
32
+ heading: 'About',
33
+ links: [
34
+ { href: '/about', label: 'About Us' },
35
+ { href: '/contact', label: 'Contact' },
36
+ { href: '/shipping', label: 'Shipping & Returns' },
37
+ { href: '/privacy', label: 'Privacy Policy' },
38
+ ],
39
+ },
40
+ ]
@@ -0,0 +1,16 @@
1
+ // The fragment routes answer one element as the server renders it, the same as the pages carry, for a CDN to cache
2
+ // and an edge to assemble. Next sets each 200's Cache-Control from the route's TTL; a fragment reads no request, so it
3
+ // is the same for every shopper.
4
+
5
+ /** A fragment's HTML. It has no <meta charset>, so the charset is in the header. */
6
+ export function fragment(html: string): Response {
7
+ return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
8
+ }
9
+
10
+ /** No fragment by that name: Next may hold it for the route's TTL, and `no-store` keeps a CDN from storing it. */
11
+ export function noFragment(): Response {
12
+ return new Response('Not found', {
13
+ status: 404,
14
+ headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' },
15
+ })
16
+ }