create-nymbal-app 0.0.0 → 0.3.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 (162) hide show
  1. package/bin/create-nymbal-app.mjs +9 -0
  2. package/dist/index.d.ts +37 -0
  3. package/dist/index.js +419 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +41 -7
  6. package/scaffold/project/astro/src/pages/index.astro +57 -0
  7. package/scaffold/templates/astro/README.md +26 -0
  8. package/scaffold/templates/astro/_dot.env.example +6 -0
  9. package/scaffold/templates/astro/_dot.gitignore +16 -0
  10. package/scaffold/templates/astro/_dot.node-version +1 -0
  11. package/scaffold/templates/astro/_dot.npmrc +1 -0
  12. package/scaffold/templates/astro/astro.config.mjs +14 -0
  13. package/scaffold/templates/astro/package.json +59 -0
  14. package/scaffold/templates/astro/src/components/AddressForm.astro +107 -0
  15. package/scaffold/templates/astro/src/components/Breadcrumb.astro +20 -0
  16. package/scaffold/templates/astro/src/components/CategoryNav.astro +27 -0
  17. package/scaffold/templates/astro/src/components/CheckoutSteps.astro +29 -0
  18. package/scaffold/templates/astro/src/components/Footer.astro +108 -0
  19. package/scaffold/templates/astro/src/components/Header.astro +192 -0
  20. package/scaffold/templates/astro/src/components/HeroSection.astro +17 -0
  21. package/scaffold/templates/astro/src/components/ImageGallery.astro +42 -0
  22. package/scaffold/templates/astro/src/components/NewsletterSignup.astro +39 -0
  23. package/scaffold/templates/astro/src/components/OrderSummary.astro +53 -0
  24. package/scaffold/templates/astro/src/components/Pagination.astro +13 -0
  25. package/scaffold/templates/astro/src/components/PriceDisplay.astro +25 -0
  26. package/scaffold/templates/astro/src/components/ProductCard.astro +44 -0
  27. package/scaffold/templates/astro/src/components/RelatedProducts.astro +28 -0
  28. package/scaffold/templates/astro/src/components/ReviewForm.astro +74 -0
  29. package/scaffold/templates/astro/src/components/ReviewList.astro +41 -0
  30. package/scaffold/templates/astro/src/components/SkipNav.astro +3 -0
  31. package/scaffold/templates/astro/src/components/SortDropdown.astro +26 -0
  32. package/scaffold/templates/astro/src/components/StockBadge.astro +32 -0
  33. package/scaffold/templates/astro/src/env.d.ts +2 -0
  34. package/scaffold/templates/astro/src/layouts/AccountLayout.astro +170 -0
  35. package/scaffold/templates/astro/src/layouts/BaseLayout.astro +55 -0
  36. package/scaffold/templates/astro/src/lib/api.ts +89 -0
  37. package/scaffold/templates/astro/src/lib/client.ts +13 -0
  38. package/scaffold/templates/astro/src/lib/format.ts +17 -0
  39. package/scaffold/templates/astro/src/lib/order-status.ts +14 -0
  40. package/scaffold/templates/astro/src/lib/seo.ts +62 -0
  41. package/scaffold/templates/astro/src/lib/submit-once.ts +44 -0
  42. package/scaffold/templates/astro/src/pages/404.astro +18 -0
  43. package/scaffold/templates/astro/src/pages/500.astro +17 -0
  44. package/scaffold/templates/astro/src/pages/account/addresses.astro +110 -0
  45. package/scaffold/templates/astro/src/pages/account/index.astro +67 -0
  46. package/scaffold/templates/astro/src/pages/account/login.astro +55 -0
  47. package/scaffold/templates/astro/src/pages/account/orders/[orderNumber].astro +108 -0
  48. package/scaffold/templates/astro/src/pages/account/orders.astro +81 -0
  49. package/scaffold/templates/astro/src/pages/account/profile.astro +69 -0
  50. package/scaffold/templates/astro/src/pages/account/register.astro +94 -0
  51. package/scaffold/templates/astro/src/pages/cart.astro +16 -0
  52. package/scaffold/templates/astro/src/pages/category/[slug].astro +73 -0
  53. package/scaffold/templates/astro/src/pages/checkout.astro +100 -0
  54. package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -0
  55. package/scaffold/templates/astro/src/pages/fragments/header.astro +5 -0
  56. package/scaffold/templates/astro/src/pages/fragments/nav.astro +9 -0
  57. package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +14 -0
  58. package/scaffold/templates/astro/src/pages/index.astro +49 -0
  59. package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +127 -0
  60. package/scaffold/templates/astro/src/pages/product/[slug].astro +107 -0
  61. package/scaffold/templates/astro/src/pages/products/index.astro +62 -0
  62. package/scaffold/templates/astro/src/pages/search.astro +37 -0
  63. package/scaffold/templates/astro/src/scripts/account-guard.ts +22 -0
  64. package/scaffold/templates/astro/src/scripts/auth-forms.ts +82 -0
  65. package/scaffold/templates/astro/src/scripts/cart-page.ts +143 -0
  66. package/scaffold/templates/astro/src/scripts/checkout.ts +272 -0
  67. package/scaffold/templates/astro/src/scripts/image-gallery.ts +22 -0
  68. package/scaffold/templates/astro/src/scripts/init-client.ts +16 -0
  69. package/scaffold/templates/astro/src/scripts/order-status.ts +27 -0
  70. package/scaffold/templates/astro/src/scripts/pdp.ts +67 -0
  71. package/scaffold/templates/astro/src/scripts/plp.ts +109 -0
  72. package/scaffold/templates/astro/src/scripts/profile-form.ts +57 -0
  73. package/scaffold/templates/astro/src/scripts/review-form.ts +27 -0
  74. package/scaffold/templates/astro/src/scripts/search.ts +145 -0
  75. package/scaffold/templates/astro/src/styles/global.css +362 -0
  76. package/scaffold/templates/astro/src/styles/pages/account.css +331 -0
  77. package/scaffold/templates/astro/src/styles/pages/cart.css +243 -0
  78. package/scaffold/templates/astro/src/styles/pages/checkout.css +270 -0
  79. package/scaffold/templates/astro/src/styles/pages/confirmation.css +142 -0
  80. package/scaffold/templates/astro/src/styles/pages/error.css +69 -0
  81. package/scaffold/templates/astro/src/styles/pages/home.css +269 -0
  82. package/scaffold/templates/astro/src/styles/pages/pdp.css +272 -0
  83. package/scaffold/templates/astro/src/styles/pages/plp.css +213 -0
  84. package/scaffold/templates/astro/src/styles/pages/search.css +152 -0
  85. package/scaffold/templates/astro/tsconfig.json +3 -0
  86. package/scaffold/templates/nextjs/README.md +26 -0
  87. package/scaffold/templates/nextjs/_dot.env.example +6 -0
  88. package/scaffold/templates/nextjs/_dot.gitignore +17 -0
  89. package/scaffold/templates/nextjs/_dot.node-version +1 -0
  90. package/scaffold/templates/nextjs/_dot.npmrc +1 -0
  91. package/scaffold/templates/nextjs/app/account/addresses/page.tsx +10 -0
  92. package/scaffold/templates/nextjs/app/account/layout.tsx +57 -0
  93. package/scaffold/templates/nextjs/app/account/login/page.tsx +23 -0
  94. package/scaffold/templates/nextjs/app/account/orders/[orderNumber]/page.tsx +192 -0
  95. package/scaffold/templates/nextjs/app/account/orders/page.tsx +123 -0
  96. package/scaffold/templates/nextjs/app/account/page.tsx +59 -0
  97. package/scaffold/templates/nextjs/app/account/profile/page.tsx +10 -0
  98. package/scaffold/templates/nextjs/app/account/register/page.tsx +10 -0
  99. package/scaffold/templates/nextjs/app/cart/page.tsx +10 -0
  100. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +58 -0
  101. package/scaffold/templates/nextjs/app/checkout/page.tsx +10 -0
  102. package/scaffold/templates/nextjs/app/error.tsx +49 -0
  103. package/scaffold/templates/nextjs/app/globals.css +473 -0
  104. package/scaffold/templates/nextjs/app/layout.tsx +29 -0
  105. package/scaffold/templates/nextjs/app/not-found.tsx +38 -0
  106. package/scaffold/templates/nextjs/app/order/[orderNumber]/page.tsx +127 -0
  107. package/scaffold/templates/nextjs/app/page.tsx +63 -0
  108. package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +111 -0
  109. package/scaffold/templates/nextjs/app/products/page.tsx +40 -0
  110. package/scaffold/templates/nextjs/app/search/page.tsx +24 -0
  111. package/scaffold/templates/nextjs/components/client/AddressManager.tsx +181 -0
  112. package/scaffold/templates/nextjs/components/client/AuthGuard.tsx +55 -0
  113. package/scaffold/templates/nextjs/components/client/CartDrawerToggle.tsx +39 -0
  114. package/scaffold/templates/nextjs/components/client/CartPage.tsx +120 -0
  115. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +523 -0
  116. package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +68 -0
  117. package/scaffold/templates/nextjs/components/client/LoginForm.tsx +89 -0
  118. package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +12 -0
  119. package/scaffold/templates/nextjs/components/client/NewsletterSignup.tsx +74 -0
  120. package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +24 -0
  121. package/scaffold/templates/nextjs/components/client/OrderStatusRefresh.tsx +48 -0
  122. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +148 -0
  123. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +61 -0
  124. package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +112 -0
  125. package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +131 -0
  126. package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +116 -0
  127. package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +27 -0
  128. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +141 -0
  129. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +35 -0
  130. package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +36 -0
  131. package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +29 -0
  132. package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +60 -0
  133. package/scaffold/templates/nextjs/components/server/Footer.module.css +64 -0
  134. package/scaffold/templates/nextjs/components/server/Footer.tsx +79 -0
  135. package/scaffold/templates/nextjs/components/server/Header.module.css +147 -0
  136. package/scaffold/templates/nextjs/components/server/Header.tsx +102 -0
  137. package/scaffold/templates/nextjs/components/server/HeroSection.tsx +18 -0
  138. package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +79 -0
  139. package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +31 -0
  140. package/scaffold/templates/nextjs/components/server/ProductCard.tsx +59 -0
  141. package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +31 -0
  142. package/scaffold/templates/nextjs/components/server/ReviewList.tsx +62 -0
  143. package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -0
  144. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +19 -0
  145. package/scaffold/templates/nextjs/lib/api.ts +89 -0
  146. package/scaffold/templates/nextjs/lib/client.ts +23 -0
  147. package/scaffold/templates/nextjs/lib/format.ts +7 -0
  148. package/scaffold/templates/nextjs/lib/order-status.ts +14 -0
  149. package/scaffold/templates/nextjs/lib/seo.ts +73 -0
  150. package/scaffold/templates/nextjs/next.config.mjs +9 -0
  151. package/scaffold/templates/nextjs/package.json +64 -0
  152. package/scaffold/templates/nextjs/styles/pages/account.module.css +377 -0
  153. package/scaffold/templates/nextjs/styles/pages/cart.module.css +240 -0
  154. package/scaffold/templates/nextjs/styles/pages/checkout.module.css +404 -0
  155. package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +237 -0
  156. package/scaffold/templates/nextjs/styles/pages/error.module.css +132 -0
  157. package/scaffold/templates/nextjs/styles/pages/home.module.css +285 -0
  158. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +319 -0
  159. package/scaffold/templates/nextjs/styles/pages/plp.module.css +280 -0
  160. package/scaffold/templates/nextjs/styles/pages/search.module.css +192 -0
  161. package/scaffold/templates/nextjs/tsconfig.json +41 -0
  162. package/README.md +0 -4
@@ -0,0 +1,109 @@
1
+ import type { ProductSortKey } from '@nymbalhq/types'
2
+ import { client } from '../lib/client.js'
3
+ import { escapeHtml, formatPrice } from '../lib/format.js'
4
+
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')
9
+
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()
96
+
97
+ // 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
100
+ const url = new URL(window.location.href)
101
+ url.searchParams.set('sort', sort)
102
+ window.history.replaceState(window.history.state, '', url)
103
+ void client.productList.setSort(sort)
104
+ })
105
+
106
+ // Load more handler
107
+ loadMoreBtn?.addEventListener('click', () => {
108
+ client.productList.loadMore()
109
+ })
@@ -0,0 +1,57 @@
1
+ import { client } from '../lib/client.js'
2
+ import { submitOnce } from '../lib/submit-once.js'
3
+
4
+ const form = document.getElementById('profile-form') as HTMLFormElement | null
5
+ const messageEl = document.getElementById('profile-message')
6
+
7
+ function populateForm() {
8
+ const profile = client.auth.getState().customer
9
+ if (!profile) return
10
+
11
+ const firstNameEl = document.getElementById('profile-firstName') as HTMLInputElement
12
+ const lastNameEl = document.getElementById('profile-lastName') as HTMLInputElement
13
+ const emailEl = document.getElementById('profile-email') as HTMLInputElement
14
+ const phoneEl = document.getElementById('profile-phone') as HTMLInputElement
15
+
16
+ if (firstNameEl) firstNameEl.value = profile.firstName ?? ''
17
+ if (lastNameEl) lastNameEl.value = profile.lastName ?? ''
18
+ if (emailEl) emailEl.value = profile.email ?? ''
19
+ if (phoneEl) phoneEl.value = profile.phone ?? ''
20
+ }
21
+
22
+ // Load profile data
23
+ async function init() {
24
+ try {
25
+ await client.auth.loadProfile()
26
+ } catch {
27
+ // ignore
28
+ }
29
+ populateForm()
30
+ }
31
+
32
+ client.auth.subscribe(populateForm)
33
+ init()
34
+
35
+ submitOnce(form, async () => {
36
+ if (!form || !messageEl) return
37
+
38
+ const firstName = (document.getElementById('profile-firstName') as HTMLInputElement)?.value
39
+ const lastName = (document.getElementById('profile-lastName') as HTMLInputElement)?.value
40
+ const phone = (document.getElementById('profile-phone') as HTMLInputElement)?.value
41
+
42
+ const submitBtn = form.querySelector('[data-testid="profile-save"]') as HTMLButtonElement | null
43
+ if (submitBtn) submitBtn.textContent = 'Saving...'
44
+
45
+ try {
46
+ await client.auth.updateProfile({ firstName, lastName, phone })
47
+ messageEl.textContent = 'Profile updated successfully.'
48
+ messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
49
+ messageEl.style.display = 'block'
50
+ } catch (err) {
51
+ messageEl.textContent = err instanceof Error ? err.message : 'Failed to update profile.'
52
+ messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
53
+ messageEl.style.display = 'block'
54
+ } finally {
55
+ if (submitBtn) submitBtn.textContent = 'Save Changes'
56
+ }
57
+ })
@@ -0,0 +1,27 @@
1
+ import { client } from '../lib/client.js'
2
+ import { submitOnce } from '../lib/submit-once.js'
3
+
4
+ const form = document.getElementById('review-form') as HTMLFormElement | null
5
+ const messageEl = document.getElementById('review-form-message')
6
+
7
+ submitOnce(form, async () => {
8
+ if (!form || !messageEl) return
9
+
10
+ const productId = form.dataset.productId ?? ''
11
+ const formData = new FormData(form)
12
+ const rating = Number(formData.get('rating'))
13
+ const title = String(formData.get('title'))
14
+ const body = String(formData.get('body'))
15
+
16
+ try {
17
+ await client.adapter.reviews.submit({ productId, rating, title, body })
18
+ messageEl.textContent = 'Thank you for your review!'
19
+ messageEl.style.display = 'block'
20
+ messageEl.style.color = 'var(--nymbal-color-success, #16a34a)'
21
+ form.reset()
22
+ } catch {
23
+ messageEl.textContent = 'Something went wrong. Please try again.'
24
+ messageEl.style.display = 'block'
25
+ messageEl.style.color = 'var(--nymbal-color-error, #dc2626)'
26
+ }
27
+ })
@@ -0,0 +1,145 @@
1
+ import { client } from '../lib/client.js'
2
+ import { formatPrice } from '../lib/format.js'
3
+
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
+ const isLoading = state.loading ?? false
61
+
62
+ if (!resultsContainer) return
63
+
64
+ // Update query label
65
+ if (queryLabel) {
66
+ if (query) {
67
+ queryLabel.replaceChildren('Results for ', el('strong', {}, `"${query}"`))
68
+ queryLabel.style.display = ''
69
+ } else {
70
+ queryLabel.style.display = 'none'
71
+ }
72
+ }
73
+
74
+ if (isLoading) {
75
+ resultsContainer.innerHTML = `
76
+ <div class="search-loading">Searching...</div>
77
+ `
78
+ return
79
+ }
80
+
81
+ if (!query) {
82
+ resultsContainer.innerHTML = `
83
+ <div class="search-empty">
84
+ <div class="search-empty-icon" aria-hidden="true">&#128269;</div>
85
+ <h2 class="search-empty-title">Search our store</h2>
86
+ <p class="search-empty-text">Type a keyword to find products.</p>
87
+ </div>
88
+ `
89
+ return
90
+ }
91
+
92
+ if (results.length === 0) {
93
+ resultsContainer.innerHTML = `
94
+ <div class="search-empty">
95
+ <div class="search-empty-icon" aria-hidden="true">&#128269;</div>
96
+ <h2 class="search-empty-title">No results found</h2>
97
+ <p class="search-empty-text">Try a different search term or browse our categories.</p>
98
+ </div>
99
+ `
100
+ return
101
+ }
102
+
103
+ resultsContainer.replaceChildren(
104
+ el(
105
+ 'div',
106
+ { class: 'search-results-header' },
107
+ el(
108
+ 'span',
109
+ { class: 'search-result-count' },
110
+ `${results.length} result${results.length === 1 ? '' : 's'}`,
111
+ ),
112
+ ),
113
+ el('div', { class: 'product-grid' }, ...results.map(renderProductCard)),
114
+ )
115
+ }
116
+
117
+ client.search.subscribe(renderResults)
118
+
119
+ // Read initial query from URL
120
+ const urlParams = new URLSearchParams(window.location.search)
121
+ const initialQuery = urlParams.get('q')
122
+
123
+ if (initialQuery) {
124
+ if (searchInput) searchInput.value = initialQuery
125
+ client.search.search(initialQuery)
126
+ } else {
127
+ renderResults()
128
+ }
129
+
130
+ // Listen for search input changes
131
+ searchInput?.addEventListener('input', () => {
132
+ const value = searchInput.value.trim()
133
+ if (value.length >= 2) {
134
+ client.search.search(value)
135
+ // Update URL without reload
136
+ const url = new URL(window.location.href)
137
+ url.searchParams.set('q', value)
138
+ window.history.replaceState({}, '', url.toString())
139
+ } else if (value.length === 0) {
140
+ client.search.clearSearch()
141
+ const url = new URL(window.location.href)
142
+ url.searchParams.delete('q')
143
+ window.history.replaceState({}, '', url.toString())
144
+ }
145
+ })
@@ -0,0 +1,362 @@
1
+ /* ----------------------------------------------------------------
2
+ Nymbal Global Styles
3
+ Built on @nymbalhq/web-components design tokens (--nymbal-*)
4
+ ---------------------------------------------------------------- */
5
+
6
+ @import '@nymbalhq/web-components/tokens.css';
7
+
8
+ /* ---- Reset ---- */
9
+
10
+ *,
11
+ *::before,
12
+ *::after {
13
+ box-sizing: border-box;
14
+ margin: 0;
15
+ padding: 0;
16
+ }
17
+
18
+ html {
19
+ -webkit-text-size-adjust: 100%;
20
+ -moz-text-size-adjust: 100%;
21
+ text-size-adjust: 100%;
22
+ scroll-behavior: smooth;
23
+ }
24
+
25
+ body {
26
+ font-family: var(--nymbal-font-family, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
27
+ font-size: var(--nymbal-font-size-base, 1rem);
28
+ font-weight: var(--nymbal-font-weight-normal, 400);
29
+ line-height: 1.6;
30
+ color: var(--nymbal-color-text, #18181b);
31
+ background-color: var(--nymbal-color-surface, #fff);
32
+ -webkit-font-smoothing: antialiased;
33
+ -moz-osx-font-smoothing: grayscale;
34
+ }
35
+
36
+ img,
37
+ picture,
38
+ video,
39
+ canvas,
40
+ svg {
41
+ display: block;
42
+ max-width: 100%;
43
+ }
44
+
45
+ img {
46
+ height: auto;
47
+ }
48
+
49
+ input,
50
+ button,
51
+ textarea,
52
+ select {
53
+ font: inherit;
54
+ color: inherit;
55
+ }
56
+
57
+ button {
58
+ cursor: pointer;
59
+ }
60
+
61
+ ul,
62
+ ol {
63
+ list-style: none;
64
+ }
65
+
66
+ a {
67
+ color: inherit;
68
+ text-decoration: none;
69
+ }
70
+
71
+ h1, h2, h3, h4, h5, h6 {
72
+ line-height: 1.2;
73
+ font-weight: var(--nymbal-font-weight-bold, 700);
74
+ overflow-wrap: break-word;
75
+ }
76
+
77
+ p {
78
+ overflow-wrap: break-word;
79
+ }
80
+
81
+ table {
82
+ border-collapse: collapse;
83
+ }
84
+
85
+ /* ---- Base typography ---- */
86
+
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); }
93
+
94
+ small {
95
+ font-size: var(--nymbal-font-size-sm, 0.875rem);
96
+ }
97
+
98
+ /* ---- Link styles ---- */
99
+
100
+ a:not([class]) {
101
+ color: var(--nymbal-color-accent, #2563eb);
102
+ text-decoration: underline;
103
+ text-underline-offset: 2px;
104
+ transition: color var(--nymbal-transition-fast, 150ms) ease;
105
+ }
106
+
107
+ a:not([class]):hover {
108
+ color: var(--nymbal-color-primary, #18181b);
109
+ }
110
+
111
+ a:not([class]):focus-visible {
112
+ outline: 2px solid var(--nymbal-color-accent, #2563eb);
113
+ outline-offset: 2px;
114
+ border-radius: var(--nymbal-radius-sm, 0.25rem);
115
+ }
116
+
117
+ /* ---- Skip navigation ---- */
118
+
119
+ .skip-nav {
120
+ position: absolute;
121
+ top: -100%;
122
+ left: 50%;
123
+ transform: translateX(-50%);
124
+ z-index: var(--nymbal-z-index-tooltip, 1000);
125
+ padding: var(--nymbal-spacing-sm, 0.5rem) var(--nymbal-spacing-md, 1rem);
126
+ background: var(--nymbal-color-primary, #18181b);
127
+ color: var(--nymbal-color-text-inverse, #fff);
128
+ font-weight: var(--nymbal-font-weight-medium, 500);
129
+ border-radius: var(--nymbal-radius-md, 0.5rem);
130
+ text-decoration: none;
131
+ transition: top var(--nymbal-transition-fast, 150ms) ease;
132
+ }
133
+
134
+ .skip-nav:focus {
135
+ top: var(--nymbal-spacing-sm, 0.5rem);
136
+ outline: 2px solid var(--nymbal-color-accent, #2563eb);
137
+ outline-offset: 2px;
138
+ }
139
+
140
+ /* ---- Layout ---- */
141
+
142
+ .container {
143
+ width: 100%;
144
+ max-width: 72rem;
145
+ margin-inline: auto;
146
+ padding-inline: var(--nymbal-spacing-md, 1rem);
147
+ }
148
+
149
+ @media (min-width: 641px) {
150
+ .container {
151
+ padding-inline: var(--nymbal-spacing-lg, 1.5rem);
152
+ }
153
+ }
154
+
155
+ .section {
156
+ padding-block: var(--nymbal-spacing-xl, 2rem);
157
+ }
158
+
159
+ @media (min-width: 641px) {
160
+ .section {
161
+ padding-block: 3rem;
162
+ }
163
+ }
164
+
165
+ @media (min-width: 1025px) {
166
+ .section {
167
+ padding-block: 4rem;
168
+ }
169
+ }
170
+
171
+ .section-title {
172
+ font-size: 1.5rem;
173
+ font-weight: var(--nymbal-font-weight-bold, 700);
174
+ color: var(--nymbal-color-primary, #18181b);
175
+ margin-bottom: var(--nymbal-spacing-lg, 1.5rem);
176
+ }
177
+
178
+ @media (min-width: 641px) {
179
+ .section-title {
180
+ font-size: 1.75rem;
181
+ }
182
+ }
183
+
184
+ /* ---- Accessibility ---- */
185
+
186
+ .visually-hidden {
187
+ position: absolute !important;
188
+ width: 1px !important;
189
+ height: 1px !important;
190
+ padding: 0 !important;
191
+ margin: -1px !important;
192
+ overflow: hidden !important;
193
+ clip: rect(0, 0, 0, 0) !important;
194
+ white-space: nowrap !important;
195
+ border: 0 !important;
196
+ }
197
+
198
+ /* ---- Buttons ---- */
199
+
200
+ .btn {
201
+ display: inline-flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ gap: var(--nymbal-spacing-xs, 0.25rem);
205
+ padding: var(--nymbal-spacing-sm, 0.5rem) var(--nymbal-spacing-md, 1rem);
206
+ font-size: var(--nymbal-font-size-sm, 0.875rem);
207
+ font-weight: var(--nymbal-font-weight-medium, 500);
208
+ line-height: 1.4;
209
+ border: 1px solid transparent;
210
+ border-radius: var(--nymbal-radius-md, 0.5rem);
211
+ cursor: pointer;
212
+ transition:
213
+ background-color var(--nymbal-transition-fast, 150ms) ease,
214
+ border-color var(--nymbal-transition-fast, 150ms) ease,
215
+ color var(--nymbal-transition-fast, 150ms) ease,
216
+ box-shadow var(--nymbal-transition-fast, 150ms) ease;
217
+ text-decoration: none;
218
+ white-space: nowrap;
219
+ user-select: none;
220
+ }
221
+
222
+ .btn:focus-visible {
223
+ outline: 2px solid var(--nymbal-color-accent, #2563eb);
224
+ outline-offset: 2px;
225
+ }
226
+
227
+ .btn:disabled {
228
+ opacity: 0.5;
229
+ cursor: not-allowed;
230
+ pointer-events: none;
231
+ }
232
+
233
+ .btn-primary {
234
+ background-color: var(--nymbal-color-primary, #18181b);
235
+ color: var(--nymbal-color-text-inverse, #fff);
236
+ border-color: var(--nymbal-color-primary, #18181b);
237
+ }
238
+
239
+ .btn-primary:hover {
240
+ background-color: #27272a;
241
+ border-color: #27272a;
242
+ }
243
+
244
+ .btn-primary:active {
245
+ background-color: #09090b;
246
+ }
247
+
248
+ .btn-secondary {
249
+ background-color: transparent;
250
+ color: var(--nymbal-color-primary, #18181b);
251
+ border-color: var(--nymbal-color-border, #e4e4e7);
252
+ }
253
+
254
+ .btn-secondary:hover {
255
+ background-color: var(--nymbal-color-border, #e4e4e7);
256
+ }
257
+
258
+ .btn-secondary:active {
259
+ background-color: #d4d4d8;
260
+ }
261
+
262
+ .btn-outline {
263
+ background-color: transparent;
264
+ color: var(--nymbal-color-accent, #2563eb);
265
+ border-color: var(--nymbal-color-accent, #2563eb);
266
+ }
267
+
268
+ .btn-outline:hover {
269
+ background-color: var(--nymbal-color-accent, #2563eb);
270
+ color: var(--nymbal-color-text-inverse, #fff);
271
+ }
272
+
273
+ .btn-outline:active {
274
+ background-color: #1d4ed8;
275
+ }
276
+
277
+ /* ---- Form inputs ---- */
278
+
279
+ .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"],
287
+ select,
288
+ textarea {
289
+ display: block;
290
+ width: 100%;
291
+ padding: var(--nymbal-spacing-sm, 0.5rem) var(--nymbal-spacing-md, 1rem);
292
+ font-size: var(--nymbal-font-size-sm, 0.875rem);
293
+ line-height: 1.5;
294
+ color: var(--nymbal-color-text, #18181b);
295
+ background-color: var(--nymbal-color-surface, #fff);
296
+ border: 1px solid var(--nymbal-color-border, #e4e4e7);
297
+ border-radius: var(--nymbal-radius-md, 0.5rem);
298
+ transition:
299
+ border-color var(--nymbal-transition-fast, 150ms) ease,
300
+ box-shadow var(--nymbal-transition-fast, 150ms) ease;
301
+ appearance: none;
302
+ }
303
+
304
+ .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,
312
+ select:focus,
313
+ textarea:focus {
314
+ outline: none;
315
+ border-color: var(--nymbal-color-accent, #2563eb);
316
+ box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
317
+ }
318
+
319
+ .input::placeholder,
320
+ input::placeholder,
321
+ textarea::placeholder {
322
+ color: var(--nymbal-color-text-muted, #71717a);
323
+ }
324
+
325
+ label {
326
+ display: block;
327
+ font-size: var(--nymbal-font-size-sm, 0.875rem);
328
+ font-weight: var(--nymbal-font-weight-medium, 500);
329
+ color: var(--nymbal-color-text, #18181b);
330
+ margin-bottom: var(--nymbal-spacing-xs, 0.25rem);
331
+ }
332
+
333
+ .form-group {
334
+ margin-bottom: var(--nymbal-spacing-md, 1rem);
335
+ }
336
+
337
+ .form-error {
338
+ font-size: var(--nymbal-font-size-xs, 0.75rem);
339
+ color: var(--nymbal-color-error, #dc2626);
340
+ margin-top: var(--nymbal-spacing-xs, 0.25rem);
341
+ }
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
+ }