create-nymbal-app 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/dist/index.d.ts +2 -1
  2. package/dist/index.js +7 -4
  3. package/dist/index.js.map +1 -1
  4. package/package.json +5 -4
  5. package/scaffold/project/astro/src/pages/index.astro +13 -15
  6. package/scaffold/templates/astro/_dot.node-version +1 -1
  7. package/scaffold/templates/astro/astro.config.mjs +23 -0
  8. package/scaffold/templates/astro/package.json +26 -26
  9. package/scaffold/templates/astro/src/components/SkipNav.astro +3 -1
  10. package/scaffold/templates/astro/src/elements/index.ts +11 -0
  11. package/scaffold/templates/astro/src/layouts/BaseLayout.astro +15 -4
  12. package/scaffold/templates/astro/src/lib/api.ts +113 -21
  13. package/scaffold/templates/astro/src/lib/chrome.ts +59 -0
  14. package/scaffold/templates/astro/src/lib/client.ts +4 -4
  15. package/scaffold/templates/astro/src/lib/fragments.ts +48 -0
  16. package/scaffold/templates/astro/src/lib/server-elements.ts +5 -0
  17. package/scaffold/templates/astro/src/middleware.ts +37 -0
  18. package/scaffold/templates/astro/src/pages/account/addresses.astro +5 -97
  19. package/scaffold/templates/astro/src/pages/account/login.astro +7 -34
  20. package/scaffold/templates/astro/src/pages/account/profile.astro +6 -56
  21. package/scaffold/templates/astro/src/pages/account/register.astro +7 -73
  22. package/scaffold/templates/astro/src/pages/cart.astro +14 -7
  23. package/scaffold/templates/astro/src/pages/category/[slug].astro +17 -21
  24. package/scaffold/templates/astro/src/pages/checkout.astro +30 -94
  25. package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -2
  26. package/scaffold/templates/astro/src/pages/fragments/header.astro +12 -2
  27. package/scaffold/templates/astro/src/pages/fragments/nav.astro +8 -5
  28. package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +9 -10
  29. package/scaffold/templates/astro/src/pages/index.astro +18 -15
  30. package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +22 -44
  31. package/scaffold/templates/astro/src/pages/product/[slug].astro +44 -30
  32. package/scaffold/templates/astro/src/pages/products/index.astro +16 -21
  33. package/scaffold/templates/astro/src/pages/search.astro +10 -11
  34. package/scaffold/templates/astro/src/scripts/init-client.ts +14 -0
  35. package/scaffold/templates/astro/src/scripts/pdp.ts +24 -21
  36. package/scaffold/templates/astro/src/scripts/plp.ts +15 -93
  37. package/scaffold/templates/astro/src/scripts/search.ts +7 -132
  38. package/scaffold/templates/astro/src/scripts/signed-in-redirect.ts +4 -0
  39. package/scaffold/templates/astro/src/styles/global.css +49 -45
  40. package/scaffold/templates/astro/src/styles/pages/account.css +0 -90
  41. package/scaffold/templates/astro/src/styles/pages/confirmation.css +6 -0
  42. package/scaffold/templates/astro/src/styles/pages/home.css +9 -100
  43. package/scaffold/templates/astro/src/styles/pages/pdp.css +5 -171
  44. package/scaffold/templates/astro/src/styles/pages/plp.css +3 -42
  45. package/scaffold/templates/astro/src/styles/pages/search.css +4 -110
  46. package/scaffold/templates/astro/src/styles/tokens.css +56 -0
  47. package/scaffold/templates/nextjs/_dot.node-version +1 -1
  48. package/scaffold/templates/nextjs/app/(catalog)/category/[slug]/page.tsx +74 -0
  49. package/scaffold/templates/nextjs/app/(catalog)/home/[key]/page.tsx +80 -0
  50. package/scaffold/templates/nextjs/app/(catalog)/layout.tsx +13 -0
  51. package/scaffold/templates/nextjs/app/(catalog)/not-found.tsx +7 -0
  52. package/scaffold/templates/nextjs/app/(catalog)/product/[slug]/page.tsx +182 -0
  53. package/scaffold/templates/nextjs/app/(catalog)/products/page.tsx +62 -0
  54. package/scaffold/templates/nextjs/app/(catalog)/search/SearchView.tsx +90 -0
  55. package/scaffold/templates/nextjs/app/(catalog)/search/page.tsx +46 -0
  56. package/scaffold/templates/nextjs/app/(shopper)/account/addresses/AddressesView.tsx +5 -0
  57. package/scaffold/templates/nextjs/app/(shopper)/account/addresses/page.tsx +17 -0
  58. package/scaffold/templates/nextjs/app/(shopper)/account/login/LoginView.tsx +28 -0
  59. package/scaffold/templates/nextjs/app/(shopper)/account/login/page.tsx +23 -0
  60. package/scaffold/templates/nextjs/app/(shopper)/account/profile/ProfileView.tsx +5 -0
  61. package/scaffold/templates/nextjs/app/(shopper)/account/profile/page.tsx +20 -0
  62. package/scaffold/templates/nextjs/app/(shopper)/account/register/RegisterView.tsx +26 -0
  63. package/scaffold/templates/nextjs/app/(shopper)/account/register/page.tsx +11 -0
  64. package/scaffold/templates/nextjs/app/(shopper)/cart/CartView.tsx +5 -0
  65. package/scaffold/templates/nextjs/app/(shopper)/cart/page.tsx +22 -0
  66. package/scaffold/templates/nextjs/app/(shopper)/checkout/CheckoutView.tsx +27 -0
  67. package/scaffold/templates/nextjs/app/(shopper)/checkout/page.tsx +39 -0
  68. package/scaffold/templates/nextjs/app/(shopper)/layout.tsx +12 -0
  69. package/scaffold/templates/nextjs/app/{order → (shopper)/order}/[orderNumber]/page.tsx +24 -5
  70. package/scaffold/templates/nextjs/app/error.tsx +3 -7
  71. package/scaffold/templates/nextjs/app/fragments/footer/route.ts +13 -0
  72. package/scaffold/templates/nextjs/app/fragments/header/[key]/route.ts +18 -0
  73. package/scaffold/templates/nextjs/app/fragments/nav/[key]/route.ts +23 -0
  74. package/scaffold/templates/nextjs/app/fragments/product/[slug]/route.ts +22 -0
  75. package/scaffold/templates/nextjs/app/globals.css +5 -23
  76. package/scaffold/templates/nextjs/app/layout.tsx +8 -5
  77. package/scaffold/templates/nextjs/app/not-found.tsx +9 -33
  78. package/scaffold/templates/nextjs/app/tokens.css +56 -0
  79. package/scaffold/templates/nextjs/components/client/ListingControls.tsx +87 -0
  80. package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +48 -6
  81. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +30 -15
  82. package/scaffold/templates/nextjs/components/client/elements.tsx +15 -0
  83. package/scaffold/templates/nextjs/components/server/Header.tsx +33 -102
  84. package/scaffold/templates/nextjs/components/server/NotFoundView.tsx +39 -0
  85. package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -3
  86. package/scaffold/templates/nextjs/elements/index.ts +12 -0
  87. package/scaffold/templates/nextjs/lib/api.ts +151 -55
  88. package/scaffold/templates/nextjs/lib/chrome.ts +40 -0
  89. package/scaffold/templates/nextjs/lib/fragments.ts +16 -0
  90. package/scaffold/templates/nextjs/lib/header.ts +39 -0
  91. package/scaffold/templates/nextjs/lib/render.ts +14 -0
  92. package/scaffold/templates/nextjs/next.config.mjs +32 -0
  93. package/scaffold/templates/nextjs/package.json +28 -27
  94. package/scaffold/templates/nextjs/styles/pages/account.module.css +0 -70
  95. package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +6 -85
  96. package/scaffold/templates/nextjs/styles/pages/home.module.css +2 -103
  97. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +3 -183
  98. package/scaffold/templates/nextjs/styles/pages/plp.module.css +9 -143
  99. package/scaffold/templates/nextjs/styles/pages/search.module.css +1 -145
  100. package/scaffold/templates/nextjs/tsconfig.dist.json +5 -0
  101. package/scaffold/templates/astro/src/components/AddressForm.astro +0 -107
  102. package/scaffold/templates/astro/src/components/Breadcrumb.astro +0 -20
  103. package/scaffold/templates/astro/src/components/CategoryNav.astro +0 -27
  104. package/scaffold/templates/astro/src/components/CheckoutSteps.astro +0 -29
  105. package/scaffold/templates/astro/src/components/Footer.astro +0 -108
  106. package/scaffold/templates/astro/src/components/Header.astro +0 -198
  107. package/scaffold/templates/astro/src/components/ImageGallery.astro +0 -42
  108. package/scaffold/templates/astro/src/components/OrderSummary.astro +0 -53
  109. package/scaffold/templates/astro/src/components/Pagination.astro +0 -13
  110. package/scaffold/templates/astro/src/components/PriceDisplay.astro +0 -25
  111. package/scaffold/templates/astro/src/components/ProductCard.astro +0 -44
  112. package/scaffold/templates/astro/src/components/RelatedProducts.astro +0 -28
  113. package/scaffold/templates/astro/src/components/ReviewForm.astro +0 -74
  114. package/scaffold/templates/astro/src/components/ReviewList.astro +0 -41
  115. package/scaffold/templates/astro/src/components/StockBadge.astro +0 -15
  116. package/scaffold/templates/astro/src/scripts/auth-forms.ts +0 -82
  117. package/scaffold/templates/astro/src/scripts/cart-page.ts +0 -118
  118. package/scaffold/templates/astro/src/scripts/checkout.ts +0 -271
  119. package/scaffold/templates/astro/src/scripts/image-gallery.ts +0 -22
  120. package/scaffold/templates/astro/src/scripts/profile-form.ts +0 -57
  121. package/scaffold/templates/astro/src/scripts/review-form.ts +0 -27
  122. package/scaffold/templates/astro/src/styles/pages/cart.css +0 -215
  123. package/scaffold/templates/astro/src/styles/pages/checkout.css +0 -270
  124. package/scaffold/templates/nextjs/app/account/addresses/page.tsx +0 -10
  125. package/scaffold/templates/nextjs/app/account/login/page.tsx +0 -23
  126. package/scaffold/templates/nextjs/app/account/profile/page.tsx +0 -10
  127. package/scaffold/templates/nextjs/app/account/register/page.tsx +0 -10
  128. package/scaffold/templates/nextjs/app/cart/page.tsx +0 -10
  129. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +0 -61
  130. package/scaffold/templates/nextjs/app/checkout/page.tsx +0 -17
  131. package/scaffold/templates/nextjs/app/page.tsx +0 -63
  132. package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +0 -111
  133. package/scaffold/templates/nextjs/app/products/page.tsx +0 -40
  134. package/scaffold/templates/nextjs/app/search/page.tsx +0 -24
  135. package/scaffold/templates/nextjs/components/client/AddressManager.tsx +0 -181
  136. package/scaffold/templates/nextjs/components/client/CartPage.tsx +0 -121
  137. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +0 -482
  138. package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +0 -68
  139. package/scaffold/templates/nextjs/components/client/LoginForm.tsx +0 -89
  140. package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +0 -12
  141. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +0 -137
  142. package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +0 -112
  143. package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +0 -131
  144. package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +0 -116
  145. package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +0 -27
  146. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +0 -181
  147. package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +0 -36
  148. package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +0 -29
  149. package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +0 -60
  150. package/scaffold/templates/nextjs/components/server/Footer.module.css +0 -64
  151. package/scaffold/templates/nextjs/components/server/Footer.tsx +0 -79
  152. package/scaffold/templates/nextjs/components/server/Header.module.css +0 -147
  153. package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +0 -79
  154. package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +0 -31
  155. package/scaffold/templates/nextjs/components/server/ProductCard.tsx +0 -59
  156. package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +0 -31
  157. package/scaffold/templates/nextjs/components/server/ReviewList.tsx +0 -62
  158. package/scaffold/templates/nextjs/styles/pages/cart.module.css +0 -240
  159. package/scaffold/templates/nextjs/styles/pages/checkout.module.css +0 -404
  160. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/layout.tsx +0 -0
  161. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/[orderNumber]/page.tsx +0 -0
  162. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/page.tsx +0 -0
  163. /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/page.tsx +0 -0
@@ -0,0 +1,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,9 +8,8 @@ const client = createNymbalClient({
7
8
  })
8
9
 
9
10
  registerClient(client)
10
- // A payment driver of the project's own goes here, before the components: registerPaymentDriver(name, driver) from
11
- // @nymbalhq/sdk, named by the driver of the payments adapter's client half.
12
- 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.
13
13
  client.cart.load()
14
14
 
15
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>
@@ -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,78 +10,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
9
10
  <h1 class="auth-card-title">Create Account</h1>
10
11
  <p class="auth-card-subtitle">Join us and start shopping.</p>
11
12
 
12
- <div class="checkout-error" id="register-error" style="display:none; margin-bottom: var(--nymbal-spacing-md, 1rem);" role="alert"></div>
13
-
14
- <form id="register-form" data-testid="register-form">
15
- <div class="account-form-row">
16
- <div class="form-group">
17
- <label for="register-firstName">First Name</label>
18
- <input
19
- type="text"
20
- id="register-firstName"
21
- name="firstName"
22
- autocomplete="given-name"
23
- placeholder="First name"
24
- data-testid="register-firstName"
25
- />
26
- </div>
27
-
28
- <div class="form-group">
29
- <label for="register-lastName">Last Name</label>
30
- <input
31
- type="text"
32
- id="register-lastName"
33
- name="lastName"
34
- autocomplete="family-name"
35
- placeholder="Last name"
36
- data-testid="register-lastName"
37
- />
38
- </div>
39
- </div>
40
-
41
- <div class="form-group">
42
- <label for="register-email">Email</label>
43
- <input
44
- type="email"
45
- id="register-email"
46
- name="email"
47
- required
48
- autocomplete="email"
49
- placeholder="you@example.com"
50
- data-testid="register-email"
51
- />
52
- </div>
53
-
54
- <div class="form-group">
55
- <label for="register-password">Password</label>
56
- <input
57
- type="password"
58
- id="register-password"
59
- name="password"
60
- required
61
- autocomplete="new-password"
62
- placeholder="Choose a password"
63
- minlength="8"
64
- data-testid="register-password"
65
- />
66
- </div>
67
-
68
- <div class="form-group">
69
- <label for="register-phone">Phone (optional)</label>
70
- <input
71
- type="tel"
72
- id="register-phone"
73
- name="phone"
74
- autocomplete="tel"
75
- placeholder="+44 7700 900000"
76
- data-testid="register-phone"
77
- />
78
- </div>
79
-
80
- <button type="submit" class="btn btn-primary" data-testid="register-submit">
81
- Create Account
82
- </button>
83
- </form>
13
+ <NymbalElement tag="nymbal-register-form" />
84
14
 
85
15
  <p class="auth-footer">
86
16
  Already have an account? <a href="/account/login">Sign in</a>
@@ -90,5 +20,9 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
90
20
  </BaseLayout>
91
21
 
92
22
  <script>
93
- import '../../scripts/auth-forms.js'
23
+ import '../../lib/client.js'
24
+ import { NymbalRegisterForm } from '@nymbalhq/web-components/elements/nymbal-register-form'
25
+ import '../../scripts/signed-in-redirect.js'
26
+
27
+ NymbalRegisterForm.define()
94
28
  </script>
@@ -1,16 +1,23 @@
1
1
  ---
2
- import '../styles/pages/cart.css'
2
+ import NymbalElement from '@nymbalhq/web-components/astro'
3
3
  import BaseLayout from '../layouts/BaseLayout.astro'
4
+ import { fetchCart } from '../lib/api'
5
+
6
+ const cart = await fetchCart(Astro.request.headers.get('cookie'))
4
7
  ---
5
8
 
6
- <BaseLayout title="Your Cart | Nymbal" description="Review the items in your cart.">
7
- <div class="container section" id="cart-content" data-testid="cart-page">
8
- <div class="cart-empty">
9
- <h1 class="cart-empty-title">Loading your cart...</h1>
10
- </div>
9
+ <BaseLayout title="Your Cart | Nymbal" description="Review the items in your cart." cart={cart}>
10
+ <div class="container section" data-testid="cart-page">
11
+ <NymbalElement
12
+ tag="nymbal-cart-page"
13
+ attributes={cart === null ? {} : { cart: JSON.stringify(cart) }}
14
+ />
11
15
  </div>
12
16
  </BaseLayout>
13
17
 
14
18
  <script>
15
- import '../scripts/cart-page.js'
19
+ import '../lib/client.js'
20
+ import { NymbalCartPage } from '@nymbalhq/web-components/elements/nymbal-cart-page'
21
+
22
+ NymbalCartPage.define()
16
23
  </script>
@@ -1,10 +1,8 @@
1
1
  ---
2
2
  import '../../styles/pages/plp.css'
3
- import '../../styles/pages/home.css'
4
3
  import BaseLayout from '../../layouts/BaseLayout.astro'
5
- import Breadcrumb from '../../components/Breadcrumb.astro'
6
- import ProductCard from '../../components/ProductCard.astro'
7
- import Pagination from '../../components/Pagination.astro'
4
+ import NymbalElement from '@nymbalhq/web-components/astro'
5
+ import { cardImageOf, cardProductOf } from '@nymbalhq/web-components/ssr'
8
6
  import { PRODUCT_SORT_LABELS } from '@nymbalhq/types'
9
7
  import { fetchProducts, fetchCategories, fetchCapabilities, sortFromUrl } from '../../lib/api'
10
8
 
@@ -18,7 +16,10 @@ const [productResult, categories] = await Promise.all([
18
16
  fetchCategories(),
19
17
  ])
20
18
 
21
- const products = productResult.items
19
+ const products = productResult.items.map(cardProductOf)
20
+ // The first card's image is the page's LCP, and the preload scanner never reaches it inside the grid's shadow root:
21
+ // the head starts it.
22
+ const firstImage = products[0] === undefined ? null : cardImageOf(products[0]).url
22
23
  const category = categories.find((c) => c.slug === slug)
23
24
  const categoryName = category?.name ?? slug ?? 'Category'
24
25
  const facets = productResult.facets ?? []
@@ -28,8 +29,9 @@ const facets = productResult.facets ?? []
28
29
  title={`${categoryName} | Nymbal`}
29
30
  description={category?.description ?? `Browse ${categoryName} products.`}
30
31
  >
32
+ {firstImage && <link slot="head" rel="preload" as="image" href={firstImage} fetchpriority="high" />}
31
33
  <div class="container section">
32
- <Breadcrumb items={[{ label: 'Home', href: '/' }, { label: categoryName }]} />
34
+ <NymbalElement tag="nymbal-breadcrumb" attributes={{ items: JSON.stringify([{ label: 'Home', href: '/' }, { label: categoryName }]) }} />
33
35
 
34
36
  <div class="plp-header">
35
37
  <h1 class="plp-title">{categoryName}</h1>
@@ -38,7 +40,6 @@ const facets = productResult.facets ?? []
38
40
  {category.description}
39
41
  </p>
40
42
  )}
41
- <p class="plp-count" id="product-count">{products.length} products</p>
42
43
  </div>
43
44
 
44
45
  <div class="plp-layout">
@@ -68,20 +69,15 @@ const facets = productResult.facets ?? []
68
69
  </div>
69
70
  )}
70
71
 
71
- <div class="product-grid" id="product-grid" data-category={slug}>
72
- {products.map((product) => (
73
- <ProductCard product={product} />
74
- ))}
75
- </div>
76
-
77
- {products.length === 0 && (
78
- <div class="plp-empty">
79
- <h2 class="plp-empty-title">No products found</h2>
80
- <p class="plp-empty-text">Check back soon for new arrivals.</p>
81
- </div>
82
- )}
83
-
84
- <Pagination />
72
+ <NymbalElement
73
+ tag="nymbal-product-grid"
74
+ attributes={{
75
+ products: JSON.stringify(products),
76
+ source: 'product-list',
77
+ ...(productResult.nextCursor !== null && { 'has-more': '' }),
78
+ 'data-category': slug!,
79
+ }}
80
+ />
85
81
  </div>
86
82
  </div>
87
83
  </div>