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
@@ -1,6 +1,26 @@
1
- import { STOCK_LABELS, stockLevel } from '@nymbalhq/types'
1
+ import { NymbalAddToCart } from '@nymbalhq/web-components/elements/nymbal-add-to-cart'
2
+ import { NymbalBreadcrumb } from '@nymbalhq/web-components/elements/nymbal-breadcrumb'
3
+ import { NymbalImageGallery } from '@nymbalhq/web-components/elements/nymbal-image-gallery'
4
+ import { NymbalProductGrid } from '@nymbalhq/web-components/elements/nymbal-product-grid'
5
+ import { NymbalProductPrice } from '@nymbalhq/web-components/elements/nymbal-product-price'
6
+ import { NymbalQuantitySelector } from '@nymbalhq/web-components/elements/nymbal-quantity-selector'
7
+ import { NymbalReviewForm } from '@nymbalhq/web-components/elements/nymbal-review-form'
8
+ import { NymbalReviewList } from '@nymbalhq/web-components/elements/nymbal-review-list'
9
+ import { NymbalStockBadge } from '@nymbalhq/web-components/elements/nymbal-stock-badge'
10
+ import { NymbalVariantSelector } from '@nymbalhq/web-components/elements/nymbal-variant-selector'
2
11
  import { client } from '../lib/client.js'
3
- import { formatPrice } from '../lib/format.js'
12
+
13
+ NymbalBreadcrumb.define()
14
+ NymbalImageGallery.define()
15
+ NymbalProductPrice.define()
16
+ NymbalStockBadge.define()
17
+ NymbalVariantSelector.define()
18
+ NymbalQuantitySelector.define()
19
+ NymbalAddToCart.define()
20
+ NymbalReviewList.define()
21
+ NymbalReviewForm.define()
22
+ // The related grid is there only when the product has relations.
23
+ if (document.querySelector('nymbal-product-grid') !== null) NymbalProductGrid.define()
4
24
 
5
25
  const slug = document.querySelector<HTMLElement>('[data-product-slug]')?.dataset.productSlug
6
26
  const addRow = document.querySelector<HTMLElement>('.pdp-add-row')
@@ -20,32 +40,15 @@ if (slug) {
20
40
  addToCart?.setAttribute('qty', String(e.detail.value))
21
41
  }) as EventListener)
22
42
 
43
+ // The price and the stock badge follow the store themselves; the add row is the page's.
23
44
  client.product.subscribe(() => {
24
45
  const { product, selectedVariant } = client.product.getState()
25
46
  if (!product) return
26
47
  const variant = selectedVariant ?? product.variants?.[0]
27
48
 
28
- const priceEl = document.querySelector('[data-testid="product-price"]')
29
- if (priceEl) {
30
- const price = formatPrice(
31
- variant?.priceMinor ?? product.priceMinor ?? 0,
32
- product.currency ?? 'GBP',
33
- )
34
- priceEl.innerHTML = `<span>${price}</span>`
35
- }
36
-
37
- const stock = variant?.stock ?? 0
38
- const level = stockLevel(stock)
39
- const badge = document.querySelector<HTMLElement>('[data-testid="stock-badge"]')
40
- if (badge) {
41
- badge.dataset.level = level
42
- const label = badge.querySelector('[data-stock-label]')
43
- if (label) label.textContent = STOCK_LABELS[level]
44
- }
45
-
46
49
  if (addToCart && variant) {
47
50
  addToCart.setAttribute('variant-id', variant.id)
48
- addToCart.toggleAttribute('disabled', stock <= 0)
51
+ addToCart.toggleAttribute('disabled', (variant.stock ?? 0) <= 0)
49
52
  }
50
53
 
51
54
  if (variant && variant.id !== shownVariantId) {
@@ -1,98 +1,25 @@
1
1
  import type { ProductSortKey } from '@nymbalhq/types'
2
+ import { NymbalBreadcrumb } from '@nymbalhq/web-components/elements/nymbal-breadcrumb'
3
+ import { NymbalProductFilter } from '@nymbalhq/web-components/elements/nymbal-product-filter'
4
+ import { NymbalProductGrid } from '@nymbalhq/web-components/elements/nymbal-product-grid'
5
+ import { NymbalSortDropdown } from '@nymbalhq/web-components/elements/nymbal-sort-dropdown'
2
6
  import { client } from '../lib/client.js'
3
- import { escapeHtml, formatPrice } from '../lib/format.js'
4
7
 
5
- const gridContainer = document.getElementById('product-grid')
8
+ NymbalBreadcrumb.define()
9
+ NymbalProductFilter.define()
10
+ NymbalSortDropdown.define()
11
+ NymbalProductGrid.define()
12
+
13
+ const grid = document.querySelector<HTMLElement>('nymbal-product-grid')
6
14
  const sortDropdown = document.querySelector('nymbal-sort-dropdown')
7
- const loadMoreBtn = document.getElementById('load-more-btn')
8
- const countEl = document.getElementById('product-count')
9
15
 
10
16
  // A category page carries its category in the path, which the server render puts on the grid; /products may carry
11
- // one in the query. The server render puts the sort it resolved from the URL on the dropdown's `value`.
12
- const urlParams = new URLSearchParams(window.location.search)
13
- const initialCategory = gridContainer?.dataset.category ?? urlParams.get('category') ?? undefined
17
+ // one in the query. The server render puts the sort it resolved from the URL on the dropdown's `value`. The grid shows
18
+ // the server's page until this load settles, then follows the store (sort, filter, load more).
19
+ const initialCategory =
20
+ grid?.dataset.category ?? new URLSearchParams(window.location.search).get('category') ?? undefined
14
21
  const initialSort = (sortDropdown?.getAttribute('value') || 'newest') as ProductSortKey
15
-
16
- function renderProductCard(product: any): string {
17
- const imageUrl =
18
- product.imageUrl ??
19
- (Array.isArray(product.media) && product.media[0]?.url) ??
20
- `https://picsum.photos/seed/${product.slug}/600/600`
21
- // The "from" price, which the price sorts order by.
22
- const fromMinor = product.priceRange?.minMinor ?? product.priceMinor
23
- const price = fromMinor != null ? formatPrice(fromMinor, product.currency ?? 'GBP') : ''
24
-
25
- const slug = escapeHtml(product.slug)
26
- const name = escapeHtml(product.name)
27
-
28
- return `
29
- <a href="/product/${slug}" class="product-card" data-testid="product-card-${slug}">
30
- <img
31
- src="${escapeHtml(imageUrl)}"
32
- alt="${name}"
33
- class="product-card-image"
34
- loading="lazy"
35
- width="600"
36
- height="600"
37
- />
38
- <div class="product-card-body">
39
- <span class="product-card-name">${name}</span>
40
- ${price ? `<span class="product-card-price">${escapeHtml(price)}</span>` : ''}
41
- </div>
42
- </a>
43
- `
44
- }
45
-
46
- function renderGrid() {
47
- const state = client.productList.getState()
48
- const products = state.products ?? []
49
-
50
- if (!gridContainer) return
51
-
52
- // Don't overwrite the grid with an empty state while a load is in flight.
53
- // This prevents the SSR content from being replaced by "No products found"
54
- // during the brief window between applyFilter setting filters and the API
55
- // responding with the filtered set.
56
- if (products.length === 0 && state.loading) return
57
-
58
- if (products.length === 0) {
59
- gridContainer.innerHTML = `
60
- <div class="plp-empty">
61
- <h2 class="plp-empty-title">No products found</h2>
62
- <p class="plp-empty-text">Try adjusting your filters or search terms.</p>
63
- </div>
64
- `
65
- } else {
66
- gridContainer.innerHTML = products.map(renderProductCard).join('')
67
- }
68
-
69
- // Update count
70
- if (countEl) {
71
- countEl.textContent = `${products.length} product${products.length === 1 ? '' : 's'}`
72
- }
73
-
74
- // Toggle load more
75
- if (loadMoreBtn) {
76
- loadMoreBtn.style.display = state.pagination.hasMore ? '' : 'none'
77
- }
78
- }
79
-
80
- // The first load's own answer is the server-rendered grid, so it is not re-rendered: subscribing after it keeps the
81
- // subscription to re-renders (sort, filter, load-more). A sort or filter chosen while it was in flight reached the
82
- // store with nothing subscribed, so that state is rendered here.
83
- async function init() {
84
- await client.productList.load({ limit: 24, category: initialCategory, sort: initialSort })
85
- client.productList.subscribe(renderGrid)
86
- const state = client.productList.getState()
87
- const filters = Object.entries(state.filters)
88
- const changed =
89
- state.sort !== initialSort ||
90
- filters.length !== (initialCategory === undefined ? 0 : 1) ||
91
- filters.some(([name, value]) => name !== 'category' || value !== initialCategory)
92
- if (state.loading || changed) renderGrid()
93
- }
94
-
95
- void init()
22
+ void client.productList.load({ limit: 24, category: initialCategory, sort: initialSort })
96
23
 
97
24
  // The element's event, not its select's `change`: the select may be the element's own rather than the server's.
98
25
  // The URL carries the sort, so a reload or a shared link renders the same order server-side.
@@ -103,8 +30,3 @@ sortDropdown?.addEventListener('nymbal:sort:changed', (e) => {
103
30
  window.history.replaceState(window.history.state, '', url)
104
31
  void client.productList.setSort(sort)
105
32
  })
106
-
107
- // Load more handler
108
- loadMoreBtn?.addEventListener('click', () => {
109
- client.productList.loadMore()
110
- })
@@ -1,143 +1,19 @@
1
+ import { NymbalProductFilter } from '@nymbalhq/web-components/elements/nymbal-product-filter'
2
+ import { NymbalSearchResults } from '@nymbalhq/web-components/elements/nymbal-search-results'
1
3
  import { client } from '../lib/client.js'
2
- import { formatPrice } from '../lib/format.js'
3
4
 
4
- const resultsContainer = document.getElementById('search-results')
5
- const queryLabel = document.getElementById('search-query-label')
6
- const searchInput = document.getElementById('search-input') as HTMLInputElement | null
7
-
8
- /** Builds an element; dynamic values go in through setAttribute and text nodes, never through markup. */
9
- function el(
10
- tag: string,
11
- attrs: Record<string, string> = {},
12
- ...children: (Node | string)[]
13
- ): HTMLElement {
14
- const node = document.createElement(tag)
15
- for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value)
16
- node.append(...children)
17
- return node
18
- }
19
-
20
- function renderProductCard(product: any): HTMLElement {
21
- const slug = String(product.slug ?? '')
22
- const name = String(product.name ?? '')
23
- const imageUrl =
24
- product.imageUrl ??
25
- (Array.isArray(product.media) && product.media[0]?.url) ??
26
- `https://picsum.photos/seed/${encodeURIComponent(slug)}/600/600`
27
- const price =
28
- product.priceMinor != null ? formatPrice(product.priceMinor, product.currency ?? 'GBP') : ''
29
-
30
- const body = el(
31
- 'div',
32
- { class: 'product-card-body' },
33
- el('span', { class: 'product-card-name' }, name),
34
- )
35
- if (price) body.append(el('span', { class: 'product-card-price' }, price))
36
-
37
- return el(
38
- 'a',
39
- {
40
- href: `/product/${encodeURIComponent(slug)}`,
41
- class: 'product-card',
42
- 'data-testid': `product-card-${slug}`,
43
- },
44
- el('img', {
45
- src: String(imageUrl),
46
- alt: name,
47
- class: 'product-card-image',
48
- loading: 'lazy',
49
- width: '600',
50
- height: '600',
51
- }),
52
- body,
53
- )
54
- }
55
-
56
- function renderResults() {
57
- const state = client.search.getState()
58
- const results = state.results ?? []
59
- const query = state.query ?? ''
60
- // A load-more or category change keeps the results shown until its answer replaces them.
61
- const isLoading = (state.loading ?? false) && results.length === 0
62
-
63
- if (!resultsContainer) return
64
-
65
- // Update query label
66
- if (queryLabel) {
67
- if (query) {
68
- queryLabel.replaceChildren('Results for ', el('strong', {}, `"${query}"`))
69
- queryLabel.style.display = ''
70
- } else {
71
- queryLabel.style.display = 'none'
72
- }
73
- }
5
+ NymbalProductFilter.define()
6
+ NymbalSearchResults.define()
74
7
 
75
- if (isLoading) {
76
- resultsContainer.innerHTML = `
77
- <div class="search-loading">Searching...</div>
78
- `
79
- return
80
- }
81
-
82
- if (!query) {
83
- resultsContainer.innerHTML = `
84
- <div class="search-empty">
85
- <div class="search-empty-icon" aria-hidden="true">&#128269;</div>
86
- <h2 class="search-empty-title">Search our store</h2>
87
- <p class="search-empty-text">Type a keyword to find products.</p>
88
- </div>
89
- `
90
- return
91
- }
92
-
93
- if (results.length === 0) {
94
- resultsContainer.innerHTML = `
95
- <div class="search-empty">
96
- <div class="search-empty-icon" aria-hidden="true">&#128269;</div>
97
- <h2 class="search-empty-title">No results found</h2>
98
- <p class="search-empty-text">Try a different search term or browse our categories.</p>
99
- </div>
100
- `
101
- return
102
- }
103
-
104
- const total = state.total
105
- const children: HTMLElement[] = [
106
- el(
107
- 'div',
108
- { class: 'search-results-header' },
109
- el(
110
- 'span',
111
- { class: 'search-result-count', 'data-testid': 'search-total' },
112
- `${total} result${total === 1 ? '' : 's'}`,
113
- ),
114
- ),
115
- el('div', { class: 'product-grid' }, ...results.map(renderProductCard)),
116
- ]
117
- if (state.nextCursor !== null) {
118
- const loadMore = el(
119
- 'button',
120
- { type: 'button', class: 'btn btn-secondary', 'data-testid': 'search-load-more' },
121
- state.loading ? 'Loading...' : 'Load more',
122
- ) as HTMLButtonElement
123
- loadMore.disabled = state.loading
124
- loadMore.addEventListener('click', () => void client.search.loadMore())
125
- children.push(el('div', { class: 'plp-pagination' }, loadMore))
126
- }
127
- resultsContainer.replaceChildren(...children)
128
- }
129
-
130
- client.search.subscribe(renderResults)
8
+ // The page owns the term and the category: the URL and the search input search, `nymbal-search-results` renders.
9
+ const searchInput = document.getElementById('search-input') as HTMLInputElement | null
131
10
 
132
- // Read initial query and category from URL
133
11
  const urlParams = new URLSearchParams(window.location.search)
134
12
  const initialQuery = urlParams.get('q')
135
13
 
136
14
  if (initialQuery) {
137
15
  if (searchInput) searchInput.value = initialQuery
138
16
  client.search.search(initialQuery, { category: urlParams.get('category') })
139
- } else {
140
- renderResults()
141
17
  }
142
18
 
143
19
  // The category filter has narrowed the search; the URL follows, beside ?q.
@@ -149,12 +25,11 @@ document.addEventListener('nymbal:filter:changed', (event) => {
149
25
  window.history.replaceState({}, '', url.toString())
150
26
  })
151
27
 
152
- // Listen for search input changes: a new query searches every category again.
28
+ // A new query searches every category again.
153
29
  searchInput?.addEventListener('input', () => {
154
30
  const value = searchInput.value.trim()
155
31
  if (value.length >= 2) {
156
32
  client.search.search(value)
157
- // Update URL without reload
158
33
  const url = new URL(window.location.href)
159
34
  url.searchParams.set('q', value)
160
35
  url.searchParams.delete('category')
@@ -0,0 +1,4 @@
1
+ // Signed in or registered: on to the page that sent the shopper here (`?redirect`), or the account.
2
+ document.addEventListener('nymbal:auth:signed-in', () => {
3
+ window.location.href = new URLSearchParams(window.location.search).get('redirect') ?? '/account'
4
+ })
@@ -1,9 +1,9 @@
1
1
  /* ----------------------------------------------------------------
2
2
  Nymbal Global Styles
3
- Built on @nymbalhq/web-components design tokens (--nymbal-*)
3
+ Built on the design tokens in tokens.css (--nymbal-*)
4
4
  ---------------------------------------------------------------- */
5
5
 
6
- @import '@nymbalhq/web-components/tokens.css';
6
+ @import './tokens.css';
7
7
 
8
8
  /* ---- Reset ---- */
9
9
 
@@ -23,7 +23,15 @@ html {
23
23
  }
24
24
 
25
25
  body {
26
- font-family: var(--nymbal-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
26
+ font-family: var(
27
+ --nymbal-font-family,
28
+ system-ui,
29
+ -apple-system,
30
+ BlinkMacSystemFont,
31
+ 'Segoe UI',
32
+ Roboto,
33
+ sans-serif
34
+ );
27
35
  font-size: var(--nymbal-font-size-base, 1rem);
28
36
  font-weight: var(--nymbal-font-weight-normal, 400);
29
37
  line-height: 1.6;
@@ -68,7 +76,12 @@ a {
68
76
  text-decoration: none;
69
77
  }
70
78
 
71
- h1, h2, h3, h4, h5, h6 {
79
+ h1,
80
+ h2,
81
+ h3,
82
+ h4,
83
+ h5,
84
+ h6 {
72
85
  line-height: 1.2;
73
86
  font-weight: var(--nymbal-font-weight-bold, 700);
74
87
  overflow-wrap: break-word;
@@ -84,12 +97,24 @@ table {
84
97
 
85
98
  /* ---- Base typography ---- */
86
99
 
87
- h1 { font-size: 2rem; }
88
- h2 { font-size: 1.5rem; }
89
- h3 { font-size: 1.25rem; }
90
- h4 { font-size: 1.125rem; }
91
- h5 { font-size: 1rem; }
92
- h6 { font-size: var(--nymbal-font-size-sm, 0.875rem); }
100
+ h1 {
101
+ font-size: 2rem;
102
+ }
103
+ h2 {
104
+ font-size: 1.5rem;
105
+ }
106
+ h3 {
107
+ font-size: 1.25rem;
108
+ }
109
+ h4 {
110
+ font-size: 1.125rem;
111
+ }
112
+ h5 {
113
+ font-size: 1rem;
114
+ }
115
+ h6 {
116
+ font-size: var(--nymbal-font-size-sm, 0.875rem);
117
+ }
93
118
 
94
119
  small {
95
120
  font-size: var(--nymbal-font-size-sm, 0.875rem);
@@ -277,13 +302,13 @@ a:not([class]):focus-visible {
277
302
  /* ---- Form inputs ---- */
278
303
 
279
304
  .input,
280
- input[type="text"],
281
- input[type="email"],
282
- input[type="password"],
283
- input[type="search"],
284
- input[type="tel"],
285
- input[type="url"],
286
- input[type="number"],
305
+ input[type='text'],
306
+ input[type='email'],
307
+ input[type='password'],
308
+ input[type='search'],
309
+ input[type='tel'],
310
+ input[type='url'],
311
+ input[type='number'],
287
312
  select,
288
313
  textarea {
289
314
  display: block;
@@ -302,13 +327,13 @@ textarea {
302
327
  }
303
328
 
304
329
  .input:focus,
305
- input[type="text"]:focus,
306
- input[type="email"]:focus,
307
- input[type="password"]:focus,
308
- input[type="search"]:focus,
309
- input[type="tel"]:focus,
310
- input[type="url"]:focus,
311
- input[type="number"]:focus,
330
+ input[type='text']:focus,
331
+ input[type='email']:focus,
332
+ input[type='password']:focus,
333
+ input[type='search']:focus,
334
+ input[type='tel']:focus,
335
+ input[type='url']:focus,
336
+ input[type='number']:focus,
312
337
  select:focus,
313
338
  textarea:focus {
314
339
  outline: none;
@@ -339,24 +364,3 @@ label {
339
364
  color: var(--nymbal-color-error, #dc2626);
340
365
  margin-top: var(--nymbal-spacing-xs, 0.25rem);
341
366
  }
342
-
343
- /* ---- Product grid ---- */
344
-
345
- .product-grid {
346
- display: grid;
347
- gap: var(--nymbal-spacing-md, 1rem);
348
- grid-template-columns: 1fr;
349
- }
350
-
351
- @media (min-width: 641px) {
352
- .product-grid {
353
- grid-template-columns: repeat(2, 1fr);
354
- gap: var(--nymbal-spacing-lg, 1.5rem);
355
- }
356
- }
357
-
358
- @media (min-width: 1025px) {
359
- .product-grid {
360
- grid-template-columns: repeat(4, 1fr);
361
- }
362
- }
@@ -60,26 +60,6 @@
60
60
  max-width: 32rem;
61
61
  }
62
62
 
63
- .account-form-actions {
64
- display: flex;
65
- gap: var(--nymbal-spacing-sm, 0.5rem);
66
- margin-top: var(--nymbal-spacing-lg, 1.5rem);
67
- padding-top: var(--nymbal-spacing-lg, 1.5rem);
68
- border-top: 1px solid var(--nymbal-color-border, #e4e4e7);
69
- }
70
-
71
- .account-form-row {
72
- display: grid;
73
- grid-template-columns: 1fr;
74
- gap: var(--nymbal-spacing-md, 1rem);
75
- }
76
-
77
- @media (min-width: 641px) {
78
- .account-form-row {
79
- grid-template-columns: 1fr 1fr;
80
- }
81
- }
82
-
83
63
  /* ---- Order list ---- */
84
64
 
85
65
  .order-list {
@@ -189,70 +169,6 @@
189
169
  text-decoration: underline;
190
170
  }
191
171
 
192
- /* ---- Address cards ---- */
193
-
194
- .address-list {
195
- display: grid;
196
- grid-template-columns: 1fr;
197
- gap: var(--nymbal-spacing-md, 1rem);
198
- }
199
-
200
- @media (min-width: 641px) {
201
- .address-list {
202
- grid-template-columns: repeat(2, 1fr);
203
- }
204
- }
205
-
206
- .address-card {
207
- border: 1px solid var(--nymbal-color-border, #e4e4e7);
208
- border-radius: var(--nymbal-radius-lg, 0.75rem);
209
- padding: var(--nymbal-spacing-lg, 1.5rem);
210
- position: relative;
211
- }
212
-
213
- .address-card.default {
214
- border-color: var(--nymbal-color-accent, #2563eb);
215
- }
216
-
217
- .address-card-badge {
218
- display: inline-flex;
219
- font-size: var(--nymbal-font-size-xs, 0.75rem);
220
- font-weight: var(--nymbal-font-weight-medium, 500);
221
- color: var(--nymbal-color-accent, #2563eb);
222
- background-color: rgba(37, 99, 235, 0.08);
223
- padding: 2px var(--nymbal-spacing-sm, 0.5rem);
224
- border-radius: var(--nymbal-radius-full, 9999px);
225
- margin-bottom: var(--nymbal-spacing-sm, 0.5rem);
226
- }
227
-
228
- .address-card-name {
229
- font-size: var(--nymbal-font-size-sm, 0.875rem);
230
- font-weight: var(--nymbal-font-weight-bold, 700);
231
- color: var(--nymbal-color-primary, #18181b);
232
- margin-bottom: var(--nymbal-spacing-xs, 0.25rem);
233
- }
234
-
235
- .address-card-line {
236
- font-size: var(--nymbal-font-size-sm, 0.875rem);
237
- color: var(--nymbal-color-text-muted, #71717a);
238
- line-height: 1.5;
239
- }
240
-
241
- .address-card-actions {
242
- display: flex;
243
- gap: var(--nymbal-spacing-sm, 0.5rem);
244
- margin-top: var(--nymbal-spacing-md, 1rem);
245
- padding-top: var(--nymbal-spacing-md, 1rem);
246
- border-top: 1px solid var(--nymbal-color-border, #e4e4e7);
247
- }
248
-
249
- .address-card-actions button,
250
- .address-card-actions a {
251
- font-size: var(--nymbal-font-size-xs, 0.75rem);
252
- font-weight: var(--nymbal-font-weight-medium, 500);
253
- padding: var(--nymbal-spacing-xs, 0.25rem) var(--nymbal-spacing-sm, 0.5rem);
254
- }
255
-
256
172
  /* ---- Auth pages (login/register) ---- */
257
173
 
258
174
  .auth-page {
@@ -287,12 +203,6 @@
287
203
  margin-bottom: var(--nymbal-spacing-lg, 1.5rem);
288
204
  }
289
205
 
290
- .auth-card .btn {
291
- width: 100%;
292
- justify-content: center;
293
- margin-top: var(--nymbal-spacing-sm, 0.5rem);
294
- }
295
-
296
206
  .auth-footer {
297
207
  text-align: center;
298
208
  font-size: var(--nymbal-font-size-sm, 0.875rem);
@@ -44,6 +44,12 @@
44
44
 
45
45
  /* ---- Order details card ---- */
46
46
 
47
+ .confirmation-summary {
48
+ display: block;
49
+ margin-bottom: var(--nymbal-spacing-xl, 2rem);
50
+ text-align: left;
51
+ }
52
+
47
53
  .confirmation-card {
48
54
  text-align: left;
49
55
  background-color: #fafafa;