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,6 +1,11 @@
1
- import type { DenormalisedProduct, ProductListResult, ReviewsListResult } from '@nymbalhq/sdk'
1
+ import type {
2
+ DenormalisedProduct,
3
+ ProductListResult,
4
+ ProductSearchResult,
5
+ ReviewsListResult,
6
+ } from '@nymbalhq/sdk'
2
7
  import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
3
- import type { Category, Order, ProductSortKey } from '@nymbalhq/types'
8
+ import type { Cart, Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
4
9
 
5
10
  const API_URL = import.meta.env.NYMBAL_API_URL ?? 'http://localhost:3001'
6
11
 
@@ -27,40 +32,78 @@ export async function fetchProducts(params?: {
27
32
  }
28
33
  }
29
34
 
30
- /** The sort a listing URL asks for; a missing or unknown `?sort=` is the default. */
31
- export function sortFromUrl(url: URL): ProductSortKey {
35
+ /** The API failed: it did not answer, answered an error other than a 404, or answered with no data. */
36
+ export class ApiUnavailableError extends Error {
37
+ override name = 'ApiUnavailableError'
38
+ }
39
+
40
+ /** `GET <path>`'s data; null on a 404; rejects with ApiUnavailableError (or fetch's own error) on any other failure. */
41
+ async function apiData<T>(path: string): Promise<T | null> {
42
+ const res = await fetch(`${API_URL}${path}`)
43
+ if (res.status === 404) return null
44
+ if (!res.ok) throw new ApiUnavailableError(`GET ${path} answered ${res.status}`)
45
+ const envelope = (await res.json()) as { data?: T | null }
46
+ if (envelope.data === undefined || envelope.data === null) {
47
+ throw new ApiUnavailableError(`GET ${path} answered no data`)
48
+ }
49
+ return envelope.data
50
+ }
51
+
52
+ /** What the store can do, as the API sent it; rejects when the API fails. */
53
+ export async function loadCapabilities(): Promise<StoreCapabilities> {
54
+ const capabilities = await apiData<StoreCapabilities>('/api/capabilities')
55
+ if (capabilities === null) throw new ApiUnavailableError('GET /api/capabilities answered 404')
56
+ return capabilities
57
+ }
58
+
59
+ /** What the store can do, which decides what the page renders; null when the API did not answer. */
60
+ export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
61
+ try {
62
+ return await loadCapabilities()
63
+ } catch (err) {
64
+ console.error('[api] fetchCapabilities failed', err)
65
+ return null
66
+ }
67
+ }
68
+
69
+ /** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
70
+ export function sortFromUrl(url: URL, catalog?: StoreCapabilities['catalog']): ProductSortKey {
32
71
  const sort = url.searchParams.get('sort')
33
- return PRODUCT_SORT_KEYS.find((key) => key === sort) ?? DEFAULT_PRODUCT_SORT
72
+ const keys = catalog?.sortKeys ?? PRODUCT_SORT_KEYS
73
+ return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
74
+ }
75
+
76
+ /** A product as the API sent it; null when the API has none (a 404); rejects when the API fails. */
77
+ export async function loadProduct(slug: string): Promise<DenormalisedProduct | null> {
78
+ return apiData<DenormalisedProduct>(`/api/products/${encodeURIComponent(slug)}`)
34
79
  }
35
80
 
36
81
  export async function fetchProduct(slug: string): Promise<DenormalisedProduct | null> {
37
82
  try {
38
- const res = await fetch(`${API_URL}/api/products/${encodeURIComponent(slug)}`)
39
- if (!res.ok) return null
40
- const envelope = (await res.json()) as { data: DenormalisedProduct }
41
- return envelope.data ?? null
83
+ return await loadProduct(slug)
42
84
  } catch (err) {
43
85
  console.error('[api] fetchProduct failed', err)
44
86
  return null
45
87
  }
46
88
  }
47
89
 
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
- ]
90
+ /** The store's categories as the API sent them; rejects when the API fails. */
91
+ export async function loadCategories(): Promise<Category[]> {
92
+ const categories = await apiData<Category[]>('/api/categories')
93
+ if (categories === null) throw new ApiUnavailableError('GET /api/categories answered 404')
94
+ return categories
95
+ }
54
96
 
97
+ /**
98
+ * The store's categories, or none when the API failed: a page renders per request and no shared cache keeps it, so
99
+ * the next request shows the store's own; a list the API did not send would link to categories the store lacks.
100
+ */
55
101
  export async function fetchCategories(): Promise<Category[]> {
56
102
  try {
57
- const res = await fetch(`${API_URL}/api/categories`)
58
- if (!res.ok) return SEED_CATEGORIES
59
- const envelope = (await res.json()) as { data: Category[] }
60
- return envelope.data ?? SEED_CATEGORIES
103
+ return await loadCategories()
61
104
  } catch (err) {
62
105
  console.error('[api] fetchCategories failed', err)
63
- return SEED_CATEGORIES
106
+ return []
64
107
  }
65
108
  }
66
109
 
@@ -87,3 +130,66 @@ export async function fetchReviews(productId: string): Promise<ReviewsListResult
87
130
  return { items: [], nextCursor: null }
88
131
  }
89
132
  }
133
+
134
+ /** The cart the cart elements take as their `cart` attribute: its lines and sums, never the cart token. */
135
+ export interface CartProjection {
136
+ items: Cart['items']
137
+ subtotalMinor: number
138
+ currency: string
139
+ itemCount: number
140
+ }
141
+
142
+ const CART_COOKIE = 'nymbal.cart'
143
+
144
+ /** The `nymbal.cart` value in a request's Cookie header, or null. */
145
+ function cartToken(cookie: string | null): string | null {
146
+ for (const pair of (cookie ?? '').split(';')) {
147
+ const [name, ...value] = pair.trim().split('=')
148
+ if (name === CART_COOKIE && value.length > 0) return value.join('=')
149
+ }
150
+ return null
151
+ }
152
+
153
+ /**
154
+ * The shopper's cart, read with the request's `nymbal.cart` cookie, as the projection the elements take. Null without
155
+ * the cookie: `GET /api/cart` with none would make a cart the page could not keep, so the element renders its shell
156
+ * and the browser fills it. The cookie reaches this server when the store and the API share a host or a proxy.
157
+ */
158
+ export async function fetchCart(cookie: string | null): Promise<CartProjection | null> {
159
+ const token = cartToken(cookie)
160
+ if (token === null) return null
161
+ try {
162
+ const res = await fetch(`${API_URL}/api/cart`, {
163
+ headers: { Cookie: `${CART_COOKIE}=${token}` },
164
+ })
165
+ if (!res.ok) return null
166
+ const { items, subtotalMinor, currency } = ((await res.json()) as { data: Cart }).data
167
+ return {
168
+ items,
169
+ subtotalMinor,
170
+ currency,
171
+ itemCount: items.reduce((n, item) => n + item.qty, 0),
172
+ }
173
+ } catch (err) {
174
+ console.error('[api] fetchCart failed', err)
175
+ return null
176
+ }
177
+ }
178
+
179
+ /** The search the search page shows for `q` (and its category), as the search store would hold it. */
180
+ export async function fetchSearch(
181
+ q: string,
182
+ category: string | null,
183
+ ): Promise<ProductSearchResult | null> {
184
+ const url = new URL('/api/products/search', API_URL)
185
+ url.searchParams.set('q', q)
186
+ if (category) url.searchParams.set('category', category)
187
+ try {
188
+ const res = await fetch(url.toString())
189
+ if (!res.ok) return null
190
+ return ((await res.json()) as { data: ProductSearchResult }).data ?? null
191
+ } catch (err) {
192
+ console.error('[api] fetchSearch failed', err)
193
+ return null
194
+ }
195
+ }
@@ -0,0 +1,59 @@
1
+ import { renderElement } from '@nymbalhq/web-components/ssr'
2
+ import './server-elements'
3
+
4
+ // The store's header and footer, rendered by nymbal-site-header and nymbal-site-footer: edit the links here.
5
+
6
+ export const HEADER = {
7
+ logo: 'Nymbal',
8
+ 'account-href': '/account',
9
+ links: JSON.stringify([
10
+ { label: 'Apparel', href: '/category/apparel' },
11
+ { label: 'Home', href: '/category/home' },
12
+ { label: 'Electronics', href: '/category/electronics' },
13
+ { label: 'Books', href: '/category/books' },
14
+ { label: 'All Products', href: '/products' },
15
+ ]),
16
+ }
17
+
18
+ export const FOOTER = {
19
+ columns: JSON.stringify([
20
+ {
21
+ heading: 'Shop',
22
+ links: [
23
+ { label: 'All Products', href: '/products' },
24
+ { label: 'Apparel', href: '/category/apparel' },
25
+ { label: 'Home', href: '/category/home' },
26
+ { label: 'Electronics', href: '/category/electronics' },
27
+ { label: 'Books', href: '/category/books' },
28
+ ],
29
+ },
30
+ {
31
+ heading: 'Account',
32
+ links: [
33
+ { label: 'Login', href: '/account/login' },
34
+ { label: 'Register', href: '/account/register' },
35
+ { label: 'Orders', href: '/account/orders' },
36
+ ],
37
+ },
38
+ {
39
+ heading: 'About',
40
+ text: 'Nymbal is a modern headless commerce platform built for developers. Fast, flexible, and fully customisable.',
41
+ },
42
+ ]),
43
+ }
44
+
45
+ /**
46
+ * The header's light-DOM children, each rendered by the server with the slot it fills: the search bar (when the store
47
+ * searches) and the mini-cart with the shopper's count (none in a shared, cached header). HTML, not markup: Astro
48
+ * would take `slot` on a component's child as a slot of NymbalElement's own.
49
+ */
50
+ export function headerSlots(canSearch: boolean, cartCount?: number): string {
51
+ const search = canSearch
52
+ ? renderElement('nymbal-search-bar', {
53
+ attributes: { slot: 'search', placeholder: 'Search products...', 'debounce-ms': '300' },
54
+ })
55
+ : ''
56
+ const attributes: Record<string, string> = { slot: 'cart' }
57
+ if (cartCount !== undefined) attributes['count'] = String(cartCount)
58
+ return search + renderElement('nymbal-mini-cart', { attributes })
59
+ }
@@ -1,5 +1,6 @@
1
+ import '../elements/index.js'
1
2
  import { createNymbalClient } from '@nymbalhq/sdk'
2
- import { registerClient, registerComponents } from '@nymbalhq/web-components'
3
+ import { registerClient } from '@nymbalhq/web-components'
3
4
 
4
5
  const client = createNymbalClient({
5
6
  baseUrl: import.meta.env.PUBLIC_NYMBAL_API_URL ?? 'http://localhost:3001',
@@ -7,7 +8,8 @@ const client = createNymbalClient({
7
8
  })
8
9
 
9
10
  registerClient(client)
10
- registerComponents()
11
+ // A payment driver of the project's own goes here, before any element is defined: registerPaymentDriver(name, driver)
12
+ // from @nymbalhq/sdk, named by the driver of the payments adapter's client half.
11
13
  client.cart.load()
12
14
 
13
15
  export { client }
@@ -0,0 +1,48 @@
1
+ import type { DenormalisedProduct } from '@nymbalhq/sdk'
2
+ import type { Category } from '@nymbalhq/types'
3
+ import { loadCapabilities, loadCategories, loadProduct } from './api'
4
+
5
+ // A fragment is cached by a CDN for hours, so it carries only what the API sent: when the API fails, the fragment
6
+ // answers an error no cache keeps, never a fallback (no categories, no search bar) a CDN would hold for its TTL.
7
+
8
+ const NOT_STORED = { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }
9
+
10
+ function apiFailed(fragment: string, err: unknown): Response {
11
+ console.error(`[fragments] ${fragment}: the API failed`, err)
12
+ return new Response('The store API is unavailable', { status: 502, headers: NOT_STORED })
13
+ }
14
+
15
+ /** The header fragment's data: whether the store searches; a 502 `no-store` response when the API failed. */
16
+ export async function headerFragment(): Promise<{ canSearch: boolean } | Response> {
17
+ try {
18
+ const capabilities = await loadCapabilities()
19
+ return { canSearch: capabilities.search.capabilities.includes('search') }
20
+ } catch (err) {
21
+ return apiFailed('header', err)
22
+ }
23
+ }
24
+
25
+ /** The nav fragment's data: the API's categories; a 502 `no-store` response when the API failed. */
26
+ export async function navFragment(): Promise<{ categories: Category[] } | Response> {
27
+ try {
28
+ return { categories: await loadCategories() }
29
+ } catch (err) {
30
+ return apiFailed('nav', err)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * The product fragment's data: the API's product; a 404 `no-store` response for a product the API does not have (a CDN
36
+ * would apply its default 404 TTL to a 404 with no Cache-Control), a 502 `no-store` one when the API failed.
37
+ */
38
+ export async function productFragment(
39
+ slug: string,
40
+ ): Promise<{ product: DenormalisedProduct } | Response> {
41
+ try {
42
+ const product = await loadProduct(slug)
43
+ if (product === null) return new Response('Not found', { status: 404, headers: NOT_STORED })
44
+ return { product }
45
+ } catch (err) {
46
+ return apiFailed('product', err)
47
+ }
48
+ }
@@ -0,0 +1,5 @@
1
+ import { registerServerElements } from '@nymbalhq/web-components/ssr'
2
+ import { serverElements } from '../elements/index.js'
3
+
4
+ // The store's own elements render on the server under their tags, as the browser defines them there.
5
+ registerServerElements(serverElements)
@@ -0,0 +1,37 @@
1
+ import { defineMiddleware } from 'astro:middleware'
2
+
3
+ /** Whether an `Accept-Encoding` value accepts gzip: a `gzip` entry (or, without one, `*`) with a q-value above 0. */
4
+ function acceptsGzip(header: string): boolean {
5
+ let star = false
6
+ for (const entry of header.split(',')) {
7
+ const [coding = '', ...params] = entry.split(';').map((part) => part.trim().toLowerCase())
8
+ const q = params.find((param) => param.startsWith('q='))
9
+ const accepted = q === undefined || Number(q.slice(2)) > 0
10
+ if (coding === 'gzip' || coding === 'x-gzip') return accepted
11
+ if (coding === '*') star = accepted
12
+ }
13
+ return star
14
+ }
15
+
16
+ // The standalone Node adapter compresses nothing, so a page's HTML (its styles inlined) went over the wire whole.
17
+ // An HTML response is gzipped as a stream when the client accepts it; everything else passes through unchanged, and a
18
+ // proxy that compresses sees the encoding and passes it on. Every HTML response varies on Accept-Encoding, the
19
+ // uncompressed one included, so a cache keeps both forms apart.
20
+ export const onRequest = defineMiddleware(async (context, next) => {
21
+ const response = await next()
22
+ const type = response.headers.get('content-type') ?? ''
23
+ if (!type.startsWith('text/html') || response.headers.has('content-encoding')) return response
24
+ const headers = new Headers(response.headers)
25
+ headers.append('vary', 'Accept-Encoding')
26
+ let body = response.body
27
+ if (body !== null && acceptsGzip(context.request.headers.get('accept-encoding') ?? '')) {
28
+ headers.set('content-encoding', 'gzip')
29
+ headers.delete('content-length')
30
+ body = body.pipeThrough(new CompressionStream('gzip'))
31
+ }
32
+ return new Response(body, {
33
+ status: response.status,
34
+ statusText: response.statusText,
35
+ headers,
36
+ })
37
+ })
@@ -1,110 +1,18 @@
1
1
  ---
2
2
  import '../../styles/pages/account.css'
3
- import '../../styles/pages/checkout.css'
3
+ import NymbalElement from '@nymbalhq/web-components/astro'
4
4
  import AccountLayout from '../../layouts/AccountLayout.astro'
5
- import AddressForm from '../../components/AddressForm.astro'
6
5
  ---
7
6
 
8
7
  <AccountLayout title="Addresses">
9
8
  <h1 class="account-page-title">Addresses</h1>
10
9
 
11
- <div id="addresses-container" data-testid="addresses-list">
12
- <p style="color: var(--nymbal-color-text-muted, #71717a); font-size: var(--nymbal-font-size-sm, 0.875rem);">
13
- Loading addresses...
14
- </p>
15
- </div>
16
-
17
- <div style="margin-top: var(--nymbal-spacing-xl, 2rem);">
18
- <h2 class="section-title">Add New Address</h2>
19
- <form id="add-address-form" class="account-form" data-testid="add-address-form">
20
- <AddressForm id="new-address" />
21
- <div class="account-form-actions">
22
- <button type="submit" class="btn btn-primary" data-testid="save-address">Save Address</button>
23
- </div>
24
- </form>
25
- <p class="form-message" id="address-form-message" style="display:none; margin-top: var(--nymbal-spacing-md, 1rem); font-size: var(--nymbal-font-size-sm, 0.875rem);"></p>
26
- </div>
10
+ <NymbalElement tag="nymbal-address-book" />
27
11
  </AccountLayout>
28
12
 
29
13
  <script>
30
- import type { Address, CustomerAddress } from '@nymbalhq/types'
31
- import { client } from '../../lib/client.js'
32
- import { escapeHtml } from '../../lib/format.js'
33
- import { submitOnce } from '../../lib/submit-once.js'
34
-
35
- const container = document.getElementById('addresses-container')
36
- const form = document.getElementById('add-address-form') as HTMLFormElement | null
37
- const messageEl = document.getElementById('address-form-message')
38
-
39
- function renderAddress(addr: CustomerAddress): string {
40
- const isDefault = addr.isDefaultShipping || addr.isDefaultBilling
41
- return `
42
- <div class="address-card ${isDefault ? 'default' : ''}" data-testid="address-${escapeHtml(addr.id)}">
43
- ${isDefault ? '<span class="address-card-badge">Default</span>' : ''}
44
- <div class="address-card-line">${escapeHtml(`${addr.firstName} ${addr.lastName}`)}</div>
45
- <div class="address-card-line">${escapeHtml(addr.addressLine1)}</div>
46
- ${addr.addressLine2 ? `<div class="address-card-line">${escapeHtml(addr.addressLine2)}</div>` : ''}
47
- <div class="address-card-line">${escapeHtml(`${addr.city}, ${addr.region} ${addr.postalCode}`)}</div>
48
- <div class="address-card-line">${escapeHtml(addr.country)}</div>
49
- </div>
50
- `
51
- }
52
-
53
- async function loadAddresses() {
54
- if (!container) return
55
-
56
- try {
57
- const addresses = await client.customer.addresses.list()
58
-
59
- if (addresses.length === 0) {
60
- container.innerHTML = `
61
- <div class="account-empty">
62
- <h2 class="account-empty-title">No saved addresses</h2>
63
- <p class="account-empty-text">Add an address below to get started.</p>
64
- </div>
65
- `
66
- return
67
- }
68
-
69
- container.innerHTML = `<div class="address-list">${addresses.map(renderAddress).join('')}</div>`
70
- } catch {
71
- container.innerHTML = `
72
- <div class="account-empty">
73
- <p class="account-empty-text">Unable to load addresses.</p>
74
- </div>
75
- `
76
- }
77
- }
78
-
79
- submitOnce(form, async () => {
80
- if (!form || !messageEl) return
81
-
82
- const formData = new FormData(form)
83
- const field = (name: string) => String(formData.get(name) ?? '').trim()
84
- const address: Address = {
85
- firstName: field('firstName'),
86
- lastName: field('lastName'),
87
- addressLine1: field('addressLine1'),
88
- city: field('city'),
89
- region: field('region'),
90
- postalCode: field('postalCode'),
91
- country: field('country'),
92
- ...(field('addressLine2') && { addressLine2: field('addressLine2') }),
93
- }
94
-
95
- try {
96
- await client.customer.addresses.create(address)
97
- messageEl.textContent = 'Address saved successfully.'
98
- messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
99
- messageEl.style.display = 'block'
100
- form.reset()
101
- await loadAddresses()
102
- } catch {
103
- messageEl.textContent = 'Failed to save address. Please try again.'
104
- messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
105
- messageEl.style.display = 'block'
106
- }
107
- })
14
+ import '../../lib/client.js'
15
+ import { NymbalAddressBook } from '@nymbalhq/web-components/elements/nymbal-address-book'
108
16
 
109
- loadAddresses()
17
+ NymbalAddressBook.define()
110
18
  </script>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import '../../styles/pages/account.css'
3
+ import NymbalElement from '@nymbalhq/web-components/astro'
3
4
  import BaseLayout from '../../layouts/BaseLayout.astro'
4
5
  ---
5
6
 
@@ -9,39 +10,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
9
10
  <h1 class="auth-card-title">Sign In</h1>
10
11
  <p class="auth-card-subtitle">Welcome back. Enter your credentials to continue.</p>
11
12
 
12
- <div class="checkout-error" id="login-error" style="display:none; margin-bottom: var(--nymbal-spacing-md, 1rem);" role="alert"></div>
13
-
14
- <form id="login-form" data-testid="login-form">
15
- <div class="form-group">
16
- <label for="login-email">Email</label>
17
- <input
18
- type="email"
19
- id="login-email"
20
- name="email"
21
- required
22
- autocomplete="email"
23
- placeholder="you@example.com"
24
- data-testid="login-email"
25
- />
26
- </div>
27
-
28
- <div class="form-group">
29
- <label for="login-password">Password</label>
30
- <input
31
- type="password"
32
- id="login-password"
33
- name="password"
34
- required
35
- autocomplete="current-password"
36
- placeholder="Your password"
37
- data-testid="login-password"
38
- />
39
- </div>
40
-
41
- <button type="submit" class="btn btn-primary" data-testid="login-submit">
42
- Sign In
43
- </button>
44
- </form>
13
+ <NymbalElement tag="nymbal-login-form" />
45
14
 
46
15
  <p class="auth-footer">
47
16
  Don't have an account? <a href="/account/register">Create one</a>
@@ -51,5 +20,9 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
51
20
  </BaseLayout>
52
21
 
53
22
  <script>
54
- import '../../scripts/auth-forms.js'
23
+ import '../../lib/client.js'
24
+ import { NymbalLoginForm } from '@nymbalhq/web-components/elements/nymbal-login-form'
25
+ import '../../scripts/signed-in-redirect.js'
26
+
27
+ NymbalLoginForm.define()
55
28
  </script>
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  import '../../styles/pages/account.css'
3
+ import NymbalElement from '@nymbalhq/web-components/astro'
3
4
  import AccountLayout from '../../layouts/AccountLayout.astro'
4
5
  ---
5
6
 
@@ -7,63 +8,12 @@ import AccountLayout from '../../layouts/AccountLayout.astro'
7
8
  <h1 class="account-page-title">Profile</h1>
8
9
  <p class="account-page-subtitle">Update your personal information.</p>
9
10
 
10
- <form id="profile-form" class="account-form" data-testid="profile-form">
11
- <div class="account-form-row">
12
- <div class="form-group">
13
- <label for="profile-firstName">First Name</label>
14
- <input
15
- type="text"
16
- id="profile-firstName"
17
- name="firstName"
18
- autocomplete="given-name"
19
- data-testid="profile-firstName"
20
- />
21
- </div>
22
-
23
- <div class="form-group">
24
- <label for="profile-lastName">Last Name</label>
25
- <input
26
- type="text"
27
- id="profile-lastName"
28
- name="lastName"
29
- autocomplete="family-name"
30
- data-testid="profile-lastName"
31
- />
32
- </div>
33
- </div>
34
-
35
- <div class="form-group">
36
- <label for="profile-email">Email</label>
37
- <input
38
- type="email"
39
- id="profile-email"
40
- name="email"
41
- disabled
42
- autocomplete="email"
43
- data-testid="profile-email"
44
- style="opacity: 0.6; cursor: not-allowed;"
45
- />
46
- </div>
47
-
48
- <div class="form-group">
49
- <label for="profile-phone">Phone</label>
50
- <input
51
- type="tel"
52
- id="profile-phone"
53
- name="phone"
54
- autocomplete="tel"
55
- data-testid="profile-phone"
56
- />
57
- </div>
58
-
59
- <div class="account-form-actions">
60
- <button type="submit" class="btn btn-primary" data-testid="profile-save">Save Changes</button>
61
- </div>
62
-
63
- <p class="form-message" id="profile-message" style="display:none; margin-top: var(--nymbal-spacing-md, 1rem); font-size: var(--nymbal-font-size-sm, 0.875rem);"></p>
64
- </form>
11
+ <NymbalElement tag="nymbal-profile-form" attributes={{ class: 'account-form' }} />
65
12
  </AccountLayout>
66
13
 
67
14
  <script>
68
- import '../../scripts/profile-form.js'
15
+ import '../../lib/client.js'
16
+ import { NymbalProfileForm } from '@nymbalhq/web-components/elements/nymbal-profile-form'
17
+
18
+ NymbalProfileForm.define()
69
19
  </script>