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,13 +1,8 @@
1
1
  ---
2
2
  import '../../styles/pages/pdp.css'
3
3
  import BaseLayout from '../../layouts/BaseLayout.astro'
4
- import Breadcrumb from '../../components/Breadcrumb.astro'
5
- import ImageGallery from '../../components/ImageGallery.astro'
6
- import PriceDisplay from '../../components/PriceDisplay.astro'
7
- import StockBadge from '../../components/StockBadge.astro'
8
- import ReviewList from '../../components/ReviewList.astro'
9
- import ReviewForm from '../../components/ReviewForm.astro'
10
- import RelatedProducts from '../../components/RelatedProducts.astro'
4
+ import NymbalElement from '@nymbalhq/web-components/astro'
5
+ import { cardProductOf } from '@nymbalhq/web-components/ssr'
11
6
  import { fetchProduct, fetchReviews, fetchProducts } from '../../lib/api'
12
7
  import { productJsonLd, breadcrumbJsonLd } from '../../lib/seo'
13
8
 
@@ -20,7 +15,7 @@ const [reviewsResult, relatedResult] = await Promise.all([
20
15
  fetchReviews(product.id),
21
16
  fetchProducts({
22
17
  limit: 4,
23
- category: product.categoryIds?.[0] ?? undefined,
18
+ category: product.categories?.[0]?.slug,
24
19
  }),
25
20
  ])
26
21
 
@@ -35,7 +30,8 @@ const images = Array.isArray(product.media) && product.media.length > 0
35
30
  : [{ url: `https://picsum.photos/seed/${product.slug}/600/600`, alt: product.name }]
36
31
 
37
32
  const defaultVariant = product.variants?.[0]
38
- const stock = defaultVariant?.stock ?? (product.inStock ? 10 : 0)
33
+ // A product with no variants has nothing to sell, so it reads as out of stock.
34
+ const stock = defaultVariant?.stock ?? 0
39
35
 
40
36
  const jsonLd = productJsonLd(product, Astro.url.origin)
41
37
  const breadcrumb = breadcrumbJsonLd([
@@ -49,38 +45,51 @@ const hasVariantChoice = (product.variants?.length ?? 0) > 1
49
45
  ---
50
46
 
51
47
  <BaseLayout title={`${product.name} | Nymbal`} description={product.shortDescription ?? product.description ?? ''} jsonLd={jsonLd}>
48
+ <!-- The gallery's main image is the page's LCP: the browser starts it before it parses the body. -->
49
+ <link slot="head" rel="preload" as="image" href={images[0]!.url} fetchpriority="high" />
52
50
  <div class="container section" data-product-slug={product.slug}>
53
- <Breadcrumb
54
- items={[
55
- { label: 'Home', href: '/' },
56
- { label: 'Products', href: '/products' },
57
- { label: product.name },
58
- ]}
51
+ <NymbalElement
52
+ tag="nymbal-breadcrumb"
53
+ attributes={{
54
+ items: JSON.stringify([
55
+ { label: 'Home', href: '/' },
56
+ { label: 'Products', href: '/products' },
57
+ { label: product.name },
58
+ ]),
59
+ }}
59
60
  />
60
61
 
61
62
  <div class="pdp-layout">
62
- <ImageGallery images={images} />
63
+ <NymbalElement tag="nymbal-image-gallery" attributes={{ class: 'pdp-gallery', images: JSON.stringify(images) }} />
63
64
 
64
65
  <div class="pdp-info">
65
66
  <h1 class="pdp-title" data-testid="product-title">{product.name}</h1>
66
67
 
67
- <PriceDisplay
68
- priceMinor={defaultVariant?.priceMinor ?? product.priceMinor}
69
- currency={product.currency ?? 'GBP'}
68
+ <NymbalElement
69
+ tag="nymbal-product-price"
70
+ attributes={{
71
+ 'product-id': product.id,
72
+ 'price-minor': String(defaultVariant?.priceMinor ?? product.priceMinor ?? 0),
73
+ currency: product.currency ?? 'GBP',
74
+ }}
70
75
  />
71
-
72
- <StockBadge stock={stock} />
76
+ <NymbalElement tag="nymbal-stock-badge" attributes={{ 'product-id': product.id, stock: String(stock) }} />
73
77
 
74
78
  <div class="pdp-actions">
75
79
  {hasVariantChoice && (
76
- <nymbal-variant-selector></nymbal-variant-selector>
80
+ <NymbalElement tag="nymbal-variant-selector" attributes={{ product: JSON.stringify(product) }} />
77
81
  )}
78
82
 
79
83
  <div class="pdp-add-row">
80
- <nymbal-quantity-selector value="1" min="1" max="99"></nymbal-quantity-selector>
81
- <nymbal-add-to-cart
82
- variant-id={defaultVariant?.id ?? product.id}
83
- ></nymbal-add-to-cart>
84
+ <NymbalElement tag="nymbal-quantity-selector" attributes={{ value: '1', min: '1', max: '99' }} />
85
+ <NymbalElement
86
+ tag="nymbal-add-to-cart"
87
+ attributes={{
88
+ 'variant-id': defaultVariant?.id ?? product.id,
89
+ qty: '1',
90
+ ...(stock <= 0 && { disabled: '' }),
91
+ }}
92
+ />
84
93
  </div>
85
94
  </div>
86
95
 
@@ -94,11 +103,19 @@ const hasVariantChoice = (product.variants?.length ?? 0) > 1
94
103
 
95
104
  <section class="pdp-reviews">
96
105
  <h2 class="pdp-reviews-title">Customer Reviews</h2>
97
- <ReviewList reviews={reviews} />
98
- <ReviewForm productId={product.id} />
106
+ <NymbalElement tag="nymbal-review-list" attributes={{ reviews: JSON.stringify(reviews) }} />
107
+ <NymbalElement tag="nymbal-review-form" attributes={{ class: 'pdp-review-form', 'product-id': product.id }} />
99
108
  </section>
100
109
 
101
- <RelatedProducts products={relatedProducts} />
110
+ {relatedProducts.length > 0 && (
111
+ <section class="section">
112
+ <h2 class="section-title">You may also like</h2>
113
+ <NymbalElement
114
+ tag="nymbal-product-grid"
115
+ attributes={{ products: JSON.stringify(relatedProducts.map(cardProductOf)), lazy: '' }}
116
+ />
117
+ </section>
118
+ )}
102
119
  </div>
103
120
  </BaseLayout>
104
121
 
@@ -1,57 +1,71 @@
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 SortDropdown from '../../components/SortDropdown.astro'
8
- import Pagination from '../../components/Pagination.astro'
9
- import { fetchProducts, fetchCategories, sortFromUrl } from '../../lib/api'
4
+ import NymbalElement from '@nymbalhq/web-components/astro'
5
+ import { cardImageOf, cardProductOf } from '@nymbalhq/web-components/ssr'
6
+ import { PRODUCT_SORT_LABELS } from '@nymbalhq/types'
7
+ import { fetchProducts, fetchCategories, fetchCapabilities, sortFromUrl } from '../../lib/api'
10
8
 
11
- const sort = sortFromUrl(Astro.url)
9
+ const capabilities = await fetchCapabilities()
10
+ const sortKeys = capabilities?.catalog.sortKeys ?? []
11
+ const sort = sortFromUrl(Astro.url, capabilities?.catalog)
12
12
 
13
13
  const [productResult, categories] = await Promise.all([
14
14
  fetchProducts({ limit: 24, sort }),
15
15
  fetchCategories(),
16
16
  ])
17
17
 
18
- const products = productResult.items
18
+ const products = productResult.items.map(cardProductOf)
19
+ // The first card's image is the page's LCP, and the preload scanner never reaches it inside the grid's shadow root:
20
+ // the head starts it.
21
+ const firstImage = products[0] === undefined ? null : cardImageOf(products[0]).url
19
22
  const facets = productResult.facets ?? []
20
23
  ---
21
24
 
22
25
  <BaseLayout title="All Products | Nymbal" description="Browse our full product collection.">
26
+ {firstImage && <link slot="head" rel="preload" as="image" href={firstImage} fetchpriority="high" />}
23
27
  <div class="container section">
24
- <Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Products' }]} />
28
+ <NymbalElement tag="nymbal-breadcrumb" attributes={{ items: JSON.stringify([{ label: 'Home', href: '/' }, { label: 'Products' }]) }} />
25
29
 
26
30
  <div class="plp-header">
27
31
  <h1 class="plp-title">All Products</h1>
28
- <p class="plp-count" id="product-count">{products.length} products</p>
29
32
  </div>
30
33
 
31
34
  <div class="plp-layout">
32
35
  <aside class="plp-sidebar">
33
- <nymbal-product-filter
34
- facets={JSON.stringify(facets)}
35
- ></nymbal-product-filter>
36
+ {facets.length > 0 && (
37
+ <nymbal-product-filter
38
+ facets={JSON.stringify(facets)}
39
+ ></nymbal-product-filter>
40
+ )}
36
41
  </aside>
37
42
 
38
43
  <div class="plp-grid-area">
39
- <SortDropdown sort={sort} />
40
-
41
- <div class="product-grid" id="product-grid">
42
- {products.map((product) => (
43
- <ProductCard product={product} />
44
- ))}
45
- </div>
46
-
47
- {products.length === 0 && (
48
- <div class="plp-empty">
49
- <h2 class="plp-empty-title">No products found</h2>
50
- <p class="plp-empty-text">Try adjusting your filters.</p>
44
+ {sortKeys.length > 0 && (
45
+ <div class="plp-sort-bar">
46
+ <label for="sort-select">Sort by</label>
47
+ <nymbal-sort-dropdown
48
+ sort-keys={JSON.stringify(sortKeys)}
49
+ value={sort}
50
+ default-sort={capabilities?.catalog.defaultSort}
51
+ >
52
+ <select id="sort-select" data-testid="sort-dropdown">
53
+ {sortKeys.map((key) => (
54
+ <option value={key} selected={key === sort}>{PRODUCT_SORT_LABELS[key]}</option>
55
+ ))}
56
+ </select>
57
+ </nymbal-sort-dropdown>
51
58
  </div>
52
59
  )}
53
60
 
54
- <Pagination />
61
+ <NymbalElement
62
+ tag="nymbal-product-grid"
63
+ attributes={{
64
+ products: JSON.stringify(products),
65
+ source: 'product-list',
66
+ ...(productResult.nextCursor !== null && { 'has-more': '' }),
67
+ }}
68
+ />
55
69
  </div>
56
70
  </div>
57
71
  </div>
@@ -1,7 +1,14 @@
1
1
  ---
2
2
  import '../styles/pages/search.css'
3
- import '../styles/pages/home.css'
3
+ import NymbalElement from '@nymbalhq/web-components/astro'
4
4
  import BaseLayout from '../layouts/BaseLayout.astro'
5
+ import { fetchSearch } from '../lib/api'
6
+
7
+ const q = Astro.url.searchParams.get('q')?.trim() ?? ''
8
+ const search = q === '' ? null : await fetchSearch(q, Astro.url.searchParams.get('category'))
9
+ // The results the page's search would show, so they are in the HTML; the search store takes over once it answers.
10
+ const results: Record<string, string> =
11
+ search === null ? {} : { results: JSON.stringify(search.items), query: q }
5
12
  ---
6
13
 
7
14
  <BaseLayout title="Search | Nymbal" description="Search our product catalogue.">
@@ -21,13 +28,11 @@ import BaseLayout from '../layouts/BaseLayout.astro'
21
28
  </div>
22
29
 
23
30
  <div class="container section">
24
- <p class="search-query-label" id="search-query-label" style="display:none; margin-bottom: var(--nymbal-spacing-md, 1rem);"></p>
25
- <div id="search-results" data-testid="search-results">
26
- <div class="search-empty">
27
- <div class="search-empty-icon" aria-hidden="true">&#128269;</div>
28
- <h2 class="search-empty-title">Search our store</h2>
29
- <p class="search-empty-text">Type a keyword to find products.</p>
30
- </div>
31
+ <div class="search-layout">
32
+ <aside class="search-sidebar">
33
+ <nymbal-product-filter source="search" empty></nymbal-product-filter>
34
+ </aside>
35
+ <NymbalElement tag="nymbal-search-results" attributes={{ 'data-testid': 'search-results', ...results }} />
31
36
  </div>
32
37
  </div>
33
38
  </BaseLayout>
@@ -1,4 +1,18 @@
1
1
  import '../lib/client.js'
2
+ import { NymbalCartDrawer } from '@nymbalhq/web-components/elements/nymbal-cart-drawer'
3
+ import { NymbalMiniCart } from '@nymbalhq/web-components/elements/nymbal-mini-cart'
4
+ import { NymbalSearchBar } from '@nymbalhq/web-components/elements/nymbal-search-bar'
5
+ import { NymbalSiteFooter } from '@nymbalhq/web-components/elements/nymbal-site-footer'
6
+ import { NymbalSiteHeader } from '@nymbalhq/web-components/elements/nymbal-site-header'
7
+ import { NymbalToast } from '@nymbalhq/web-components/elements/nymbal-toast'
8
+
9
+ // The layout's elements; each page script defines the ones its page renders.
10
+ NymbalSiteHeader.define()
11
+ NymbalSiteFooter.define()
12
+ NymbalSearchBar.define()
13
+ NymbalMiniCart.define()
14
+ NymbalCartDrawer.define()
15
+ NymbalToast.define()
2
16
 
3
17
  // Navigate to checkout when the cart drawer checkout button is clicked
4
18
  document.addEventListener('nymbal:cart:checkout-clicked', () => {
@@ -1,67 +1,61 @@
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'
1
11
  import { client } from '../lib/client.js'
2
- import { formatPrice } from '../lib/format.js'
3
12
 
4
- const slug = document.querySelector<HTMLElement>('[data-product-slug]')?.dataset.productSlug
5
-
6
- if (slug) {
7
- client.product.loadBySlug(slug)
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()
8
24
 
9
- client.product.subscribe(() => {
10
- const state = client.product.getState()
11
- if (!state.product) return
25
+ const slug = document.querySelector<HTMLElement>('[data-product-slug]')?.dataset.productSlug
26
+ const addRow = document.querySelector<HTMLElement>('.pdp-add-row')
27
+ const quantity = addRow?.querySelector('nymbal-quantity-selector')
28
+ const addToCart = addRow?.querySelector('nymbal-add-to-cart')
12
29
 
13
- const product = state.product
14
- const selectedVariant = state.selectedVariant ?? product.variants?.[0]
30
+ function setQuantity(qty: number): void {
31
+ quantity?.setAttribute('value', String(qty))
32
+ addToCart?.setAttribute('qty', String(qty))
33
+ }
15
34
 
16
- // Update price display
17
- const priceEl = document.querySelector('[data-testid="product-price"]')
18
- if (priceEl && selectedVariant) {
19
- const price = formatPrice(
20
- selectedVariant.priceMinor ?? product.priceMinor,
21
- product.currency ?? 'GBP',
22
- )
23
- priceEl.innerHTML = `<span>${price}</span>`
24
- }
35
+ if (slug) {
36
+ // The server rendered variants[0]; a change from it resets the quantity.
37
+ let shownVariantId = addToCart?.getAttribute('variant-id') ?? null
25
38
 
26
- // Update stock badge
27
- const stockEl = document.querySelector('[data-testid="stock-badge"]')
28
- if (stockEl && selectedVariant) {
29
- const stock = selectedVariant.stock ?? 0
30
- let statusClass: string
31
- let label: string
39
+ addRow?.addEventListener('nymbal:quantity:changed', ((e: CustomEvent<{ value: number }>) => {
40
+ addToCart?.setAttribute('qty', String(e.detail.value))
41
+ }) as EventListener)
32
42
 
33
- if (stock <= 0) {
34
- statusClass = 'out-of-stock'
35
- label = 'Out of Stock'
36
- } else if (stock < 5) {
37
- statusClass = 'low-stock'
38
- label = 'Low Stock'
39
- } else {
40
- statusClass = 'in-stock'
41
- label = 'In Stock'
42
- }
43
+ // The price and the stock badge follow the store themselves; the add row is the page's.
44
+ client.product.subscribe(() => {
45
+ const { product, selectedVariant } = client.product.getState()
46
+ if (!product) return
47
+ const variant = selectedVariant ?? product.variants?.[0]
43
48
 
44
- stockEl.className = `pdp-stock ${statusClass}`
45
- stockEl.innerHTML = `<span class="pdp-stock-dot" aria-hidden="true"></span>${label}`
49
+ if (addToCart && variant) {
50
+ addToCart.setAttribute('variant-id', variant.id)
51
+ addToCart.toggleAttribute('disabled', (variant.stock ?? 0) <= 0)
46
52
  }
47
53
 
48
- // Update add-to-cart variant-id
49
- const addToCart = document.querySelector('nymbal-add-to-cart')
50
- if (addToCart && selectedVariant) {
51
- addToCart.setAttribute('variant-id', selectedVariant.id)
52
- if (selectedVariant.stock <= 0) {
53
- addToCart.setAttribute('disabled', '')
54
- } else {
55
- addToCart.removeAttribute('disabled')
56
- }
54
+ if (variant && variant.id !== shownVariantId) {
55
+ shownVariantId = variant.id
56
+ setQuantity(1)
57
57
  }
58
58
  })
59
59
 
60
- // Listen for variant selection from the web component
61
- document.addEventListener('nymbal:variant:selected', ((e: CustomEvent) => {
62
- const variantId = e.detail?.variantId
63
- if (variantId) {
64
- client.product.selectVariant(variantId)
65
- }
66
- }) as EventListener)
60
+ client.product.loadBySlug(slug)
67
61
  }
@@ -1,109 +1,32 @@
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')
6
- const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null
7
- const loadMoreBtn = document.getElementById('load-more-btn')
8
- const countEl = document.getElementById('product-count')
8
+ NymbalBreadcrumb.define()
9
+ NymbalProductFilter.define()
10
+ NymbalSortDropdown.define()
11
+ NymbalProductGrid.define()
9
12
 
10
- // 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 also selected the URL's sort in the dropdown.
12
- const urlParams = new URLSearchParams(window.location.search)
13
- const initialCategory = gridContainer?.dataset.category ?? urlParams.get('category') ?? undefined
14
- const initialSort = (sortSelect?.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()
13
+ const grid = document.querySelector<HTMLElement>('nymbal-product-grid')
14
+ const sortDropdown = document.querySelector('nymbal-sort-dropdown')
96
15
 
16
+ // A category page carries its category in the path, which the server render puts on the grid; /products may carry
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
21
+ const initialSort = (sortDropdown?.getAttribute('value') || 'newest') as ProductSortKey
22
+ void client.productList.load({ limit: 24, category: initialCategory, sort: initialSort })
23
+
24
+ // The element's event, not its select's `change`: the select may be the element's own rather than the server's.
97
25
  // The URL carries the sort, so a reload or a shared link renders the same order server-side.
98
- sortSelect?.addEventListener('change', () => {
99
- const sort = sortSelect.value as ProductSortKey
26
+ sortDropdown?.addEventListener('nymbal:sort:changed', (e) => {
27
+ const sort = (e as CustomEvent<{ sort: ProductSortKey }>).detail.sort
100
28
  const url = new URL(window.location.href)
101
29
  url.searchParams.set('sort', sort)
102
30
  window.history.replaceState(window.history.state, '', url)
103
31
  void client.productList.setSort(sort)
104
32
  })
105
-
106
- // Load more handler
107
- loadMoreBtn?.addEventListener('click', () => {
108
- client.productList.loadMore()
109
- })