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
@@ -1,102 +1,38 @@
1
- import Link from 'next/link'
2
- import { SearchBarWrapper } from '@/components/client/SearchBarWrapper'
3
- import { MiniCartWrapper } from '@/components/client/MiniCartWrapper'
4
- 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'
5
7
 
6
- const NAV_LINKS = [
7
- { href: '/products', label: 'Shop' },
8
- { href: '/products?category=new', label: 'New Arrivals' },
9
- { href: '/products?category=sale', label: 'Sale' },
10
- ]
11
-
12
- export function Header() {
8
+ function HeaderElement({ header }: { header: ReturnType<typeof siteHeader> }) {
9
+ const serverHtml = renderNymbal('nymbal-site-header', header.attributes, header.children).__html
13
10
  return (
14
- <header className={styles.header} data-testid="site-header">
15
- <div className={`container ${styles.inner}`}>
16
- <Link href="/" className={styles.logo}>
17
- Nymbal
18
- </Link>
19
-
20
- <nav className={styles.nav} aria-label="Main navigation">
21
- <ul className={styles.navList}>
22
- {NAV_LINKS.map((link) => (
23
- <li key={link.href}>
24
- <Link href={link.href} className={styles.navLink}>
25
- {link.label}
26
- </Link>
27
- </li>
28
- ))}
29
- </ul>
30
- </nav>
31
-
32
- <div className={styles.actions}>
33
- <div className={styles.search}>
34
- <SearchBarWrapper />
35
- </div>
36
- <Link href="/account" className={styles.iconLink} aria-label="Account">
37
- <svg
38
- width="20"
39
- height="20"
40
- viewBox="0 0 24 24"
41
- fill="none"
42
- stroke="currentColor"
43
- strokeWidth="2"
44
- strokeLinecap="round"
45
- strokeLinejoin="round"
46
- aria-hidden="true"
47
- >
48
- <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
49
- <circle cx="12" cy="7" r="4" />
50
- </svg>
51
- </Link>
52
- <MiniCartWrapper />
53
- </div>
11
+ <SiteHeader
12
+ links={HEADER.links}
13
+ logo={HEADER.logo}
14
+ accountHref={HEADER.accountHref}
15
+ serverHtml={serverHtml}
16
+ />
17
+ )
18
+ }
54
19
 
55
- <input
56
- type="checkbox"
57
- id="mobile-menu-toggle"
58
- className={styles.menuCheckbox}
59
- aria-hidden="true"
60
- />
61
- <label
62
- htmlFor="mobile-menu-toggle"
63
- className={styles.menuButton}
64
- aria-label="Toggle menu"
65
- >
66
- <svg
67
- width="24"
68
- height="24"
69
- viewBox="0 0 24 24"
70
- fill="none"
71
- stroke="currentColor"
72
- strokeWidth="2"
73
- strokeLinecap="round"
74
- strokeLinejoin="round"
75
- aria-hidden="true"
76
- >
77
- <line x1="3" y1="6" x2="21" y2="6" />
78
- <line x1="3" y1="12" x2="21" y2="12" />
79
- <line x1="3" y1="18" x2="21" y2="18" />
80
- </svg>
81
- </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
+ }
82
30
 
83
- <nav className={styles.mobileNav} aria-label="Mobile navigation">
84
- <ul className={styles.mobileNavList}>
85
- {NAV_LINKS.map((link) => (
86
- <li key={link.href}>
87
- <Link href={link.href} className={styles.mobileNavLink}>
88
- {link.label}
89
- </Link>
90
- </li>
91
- ))}
92
- <li>
93
- <Link href="/account" className={styles.mobileNavLink}>
94
- Account
95
- </Link>
96
- </li>
97
- </ul>
98
- </nav>
99
- </div>
100
- </header>
101
- )
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)} />
102
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,9 +1,56 @@
1
- import type { DenormalisedProduct, ProductListResult, ReviewsListResult } from '@nymbalhq/sdk'
1
+ import { cache } from 'react'
2
+ import { unstable_rethrow } from 'next/navigation'
3
+ import type {
4
+ DenormalisedProduct,
5
+ ProductListResult,
6
+ ProductSearchResult,
7
+ ReviewsListResult,
8
+ } from '@nymbalhq/sdk'
2
9
  import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
3
- import type { Category, Order, ProductSortKey } from '@nymbalhq/types'
10
+ import type { Cart, Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
4
11
 
5
12
  const API_URL = process.env.NYMBAL_API_URL ?? 'http://localhost:3001'
6
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
+
7
54
  export async function fetchProducts(params?: {
8
55
  limit?: number
9
56
  cursor?: string
@@ -16,74 +63,143 @@ export async function fetchProducts(params?: {
16
63
  if (params?.category) url.searchParams.set('category', params.category)
17
64
  if (params?.sort) url.searchParams.set('sort', params.sort)
18
65
 
19
- try {
20
- const res = await fetch(url.toString(), { cache: 'no-store' })
21
- if (!res.ok) return { items: [], nextCursor: null }
22
- const envelope = (await res.json()) as { data: ProductListResult }
23
- return envelope.data ?? { items: [], nextCursor: null }
24
- } catch (err) {
25
- console.error('[api] fetchProducts failed', err)
26
- return { items: [], nextCursor: null }
27
- }
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 }
28
70
  }
29
71
 
30
- /** The sort a listing URL asks for; a missing or unknown `?sort=` is the default. */
31
- export function sortFromSearchParams(params: { sort?: string | string[] }): ProductSortKey {
72
+ /** What the store can do, which decides what the page renders; null when the API has no capabilities to report. */
73
+ export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
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
78
+ }
79
+
80
+ /** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
81
+ export function sortFromSearchParams(
82
+ params: { sort?: string | string[] },
83
+ catalog?: StoreCapabilities['catalog'],
84
+ ): ProductSortKey {
32
85
  const sort = Array.isArray(params.sort) ? params.sort[0] : params.sort
33
- return PRODUCT_SORT_KEYS.find((key) => key === sort) ?? DEFAULT_PRODUCT_SORT
86
+ const keys = catalog?.sortKeys ?? PRODUCT_SORT_KEYS
87
+ return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
34
88
  }
35
89
 
36
- 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> {
37
116
  try {
38
- 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
+ })
39
120
  if (!res.ok) return null
40
- const envelope = (await res.json()) as { data: DenormalisedProduct }
121
+ const envelope = (await res.json()) as { data: Order }
41
122
  return envelope.data ?? null
42
123
  } catch (err) {
43
- console.error('[api] fetchProduct failed', err)
124
+ unstable_rethrow(err)
125
+ console.error('[api] fetchOrder failed', err)
44
126
  return null
45
127
  }
46
128
  }
47
129
 
48
- const SEED_CATEGORIES: Category[] = [
49
- { id: 'cat_apparel', parentId: null, name: 'Apparel', slug: 'apparel', description: 'Clothing and accessories', position: 0, createdAt: '2024-01-01T00:00:00Z' },
50
- { id: 'cat_home', parentId: null, name: 'Home', slug: 'home', description: 'Home goods and decor', position: 1, createdAt: '2024-01-01T00:00:00Z' },
51
- { id: 'cat_electronics', parentId: null, name: 'Electronics', slug: 'electronics', description: 'Gadgets and devices', position: 2, createdAt: '2024-01-01T00:00:00Z' },
52
- { id: 'cat_books', parentId: null, name: 'Books', slug: 'books', description: 'Books and reading', position: 3, createdAt: '2024-01-01T00:00:00Z' },
53
- ]
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
+ }
54
139
 
55
- export async function fetchCategories(): Promise<Category[]> {
56
- try {
57
- const res = await fetch(`${API_URL}/api/categories`, { cache: 'no-store' })
58
- if (!res.ok) return SEED_CATEGORIES
59
- const envelope = (await res.json()) as { data: Category[] }
60
- return envelope.data ?? SEED_CATEGORIES
61
- } catch (err) {
62
- console.error('[api] fetchCategories failed', err)
63
- 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('=')
64
155
  }
156
+ return null
65
157
  }
66
158
 
67
- 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
68
168
  try {
69
- 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
+ })
70
173
  if (!res.ok) return null
71
- const envelope = (await res.json()) as { data: Order }
72
- 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
+ }
73
181
  } catch (err) {
74
- console.error('[api] fetchOrder failed', err)
182
+ unstable_rethrow(err)
183
+ console.error('[api] fetchCart failed', err)
75
184
  return null
76
185
  }
77
- }
186
+ })
78
187
 
79
- 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)
80
196
  try {
81
- const res = await fetch(`${API_URL}/api/products/${encodeURIComponent(productId)}/reviews`, { cache: 'no-store' })
82
- if (!res.ok) return { items: [], nextCursor: null }
83
- const envelope = (await res.json()) as { data: ReviewsListResult }
84
- 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
85
200
  } catch (err) {
86
- console.error('[api] fetchReviews failed', err)
87
- return { items: [], nextCursor: null }
201
+ unstable_rethrow(err)
202
+ console.error('[api] fetchSearch failed', err)
203
+ return null
88
204
  }
89
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
+ ]
@@ -1,5 +1,8 @@
1
1
  import { createNymbalClient, type NymbalClient } from '@nymbalhq/sdk'
2
2
 
3
+ // A payment driver of the project's own is registered here, at module load, before any checkout renders:
4
+ // registerPaymentDriver(name, driver) from @nymbalhq/sdk, named by the driver of the payments adapter's client half.
5
+
3
6
  let instance: NymbalClient | null = null
4
7
  let restoring: Promise<void> | null = null
5
8
 
@@ -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
+ }
@@ -0,0 +1,39 @@
1
+ import { fetchCapabilities } from '@/lib/api'
2
+ import { HEADER } from '@/lib/chrome'
3
+ import { renderElement } from '@/lib/render'
4
+
5
+ /**
6
+ * The store's nymbal-site-header as the server renders it: its attributes, and the search bar (when the store searches)
7
+ * and the mini-cart as its light-DOM children, each with the slot it fills. React cannot give children beside the
8
+ * header's server HTML, so they go in that HTML. The mini-cart has a count only on a shopper's own page; a shared,
9
+ * cached header has none.
10
+ */
11
+ export function siteHeader(
12
+ search: boolean,
13
+ cartCount?: number,
14
+ ): { attributes: Record<string, string>; children: string } {
15
+ const miniCart: Record<string, string> = { slot: 'cart' }
16
+ if (cartCount !== undefined) miniCart['count'] = String(cartCount)
17
+ const children = [
18
+ search
19
+ ? renderElement('nymbal-search-bar', {
20
+ attributes: { slot: 'search', placeholder: 'Search products...', 'debounce-ms': '300' },
21
+ })
22
+ : '',
23
+ renderElement('nymbal-mini-cart', { attributes: miniCart }),
24
+ ].join('')
25
+ return {
26
+ attributes: {
27
+ links: JSON.stringify(HEADER.links),
28
+ logo: HEADER.logo,
29
+ 'account-href': HEADER.accountHref,
30
+ },
31
+ children,
32
+ }
33
+ }
34
+
35
+ /** Whether the store searches, which puts the search bar in the header. */
36
+ export async function canSearch(): Promise<boolean> {
37
+ const capabilities = await fetchCapabilities()
38
+ return capabilities?.search.capabilities.includes('search') ?? false
39
+ }
@@ -0,0 +1,14 @@
1
+ import { renderNymbal } from '@nymbalhq/react/server'
2
+ import {
3
+ cardImageOf,
4
+ cardProductOf,
5
+ registerServerElements,
6
+ renderElement,
7
+ } from '@nymbalhq/web-components/ssr'
8
+ import { serverElements } from '@/elements'
9
+
10
+ // Server components only: this loads Lit SSR. The store's own elements render on the server under their tags, as the
11
+ // browser defines them there.
12
+ registerServerElements(serverElements)
13
+
14
+ export { cardImageOf, cardProductOf, renderElement, renderNymbal }
@@ -1,9 +1,41 @@
1
+ import { relative } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ // NYMBAL_ELEMENTS_ENTRY (an absolute path) is the store's elements entry in place of elements/index.ts, on the server
5
+ // and in the browser (the E2E override fixture).
6
+ const elementsEntry = process.env.NYMBAL_ELEMENTS_ENTRY
7
+ const elementsAlias =
8
+ elementsEntry === undefined
9
+ ? {}
10
+ : { '@/elements': `./${relative(fileURLToPath(new URL('.', import.meta.url)), elementsEntry)}` }
11
+
12
+ // NYMBAL_NEXT_DIST_DIR builds beside the usual `.next`, so a second build cannot overwrite one being served.
13
+ const distDir = process.env.NYMBAL_NEXT_DIST_DIR || '.next'
14
+
1
15
  /** @type {import('next').NextConfig} */
2
16
  const nextConfig = {
3
17
  reactStrictMode: true,
18
+ distDir,
19
+ ...(distDir !== '.next' && { typescript: { tsconfigPath: 'tsconfig.dist.json' } }),
20
+ // Cached pages live in memory, bounded by cacheMaxMemorySize: on disk, every distinct product URL a crawler asks for
21
+ // would keep an entry.
22
+ experimental: { isrFlushToDisk: false },
23
+ // `/` and the header and nav fragments are dynamic segments with no params, so the build prerenders none of them
24
+ // (the API may be down then) and Next caches each on its first request.
25
+ async rewrites() {
26
+ return {
27
+ beforeFiles: [
28
+ { source: '/', destination: '/home/index' },
29
+ { source: '/fragments/header', destination: '/fragments/header/index' },
30
+ { source: '/fragments/nav', destination: '/fragments/nav/index' },
31
+ ],
32
+ }
33
+ },
4
34
  transpilePackages: ['@nymbalhq/sdk', '@nymbalhq/react', '@nymbalhq/web-components'],
5
35
  images: {
6
36
  remotePatterns: [{ protocol: 'https', hostname: '**' }],
7
37
  },
38
+ turbopack: { resolveAlias: elementsAlias },
8
39
  }
40
+
9
41
  export default nextConfig