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.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/scaffold/project/astro/src/pages/index.astro +13 -15
- package/scaffold/templates/astro/_dot.node-version +1 -1
- package/scaffold/templates/astro/astro.config.mjs +23 -0
- package/scaffold/templates/astro/package.json +26 -26
- package/scaffold/templates/astro/src/components/SkipNav.astro +3 -1
- package/scaffold/templates/astro/src/elements/index.ts +11 -0
- package/scaffold/templates/astro/src/layouts/BaseLayout.astro +15 -4
- package/scaffold/templates/astro/src/lib/api.ts +113 -21
- package/scaffold/templates/astro/src/lib/chrome.ts +59 -0
- package/scaffold/templates/astro/src/lib/client.ts +4 -4
- package/scaffold/templates/astro/src/lib/fragments.ts +48 -0
- package/scaffold/templates/astro/src/lib/server-elements.ts +5 -0
- package/scaffold/templates/astro/src/middleware.ts +37 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +5 -97
- package/scaffold/templates/astro/src/pages/account/login.astro +7 -34
- package/scaffold/templates/astro/src/pages/account/profile.astro +6 -56
- package/scaffold/templates/astro/src/pages/account/register.astro +7 -73
- package/scaffold/templates/astro/src/pages/cart.astro +14 -7
- package/scaffold/templates/astro/src/pages/category/[slug].astro +17 -21
- package/scaffold/templates/astro/src/pages/checkout.astro +30 -94
- package/scaffold/templates/astro/src/pages/fragments/footer.astro +5 -2
- package/scaffold/templates/astro/src/pages/fragments/header.astro +12 -2
- package/scaffold/templates/astro/src/pages/fragments/nav.astro +8 -5
- package/scaffold/templates/astro/src/pages/fragments/product/[slug].astro +9 -10
- package/scaffold/templates/astro/src/pages/index.astro +18 -15
- package/scaffold/templates/astro/src/pages/order/[orderNumber].astro +22 -44
- package/scaffold/templates/astro/src/pages/product/[slug].astro +44 -30
- package/scaffold/templates/astro/src/pages/products/index.astro +16 -21
- package/scaffold/templates/astro/src/pages/search.astro +10 -11
- package/scaffold/templates/astro/src/scripts/init-client.ts +14 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +24 -21
- package/scaffold/templates/astro/src/scripts/plp.ts +15 -93
- package/scaffold/templates/astro/src/scripts/search.ts +7 -132
- package/scaffold/templates/astro/src/scripts/signed-in-redirect.ts +4 -0
- package/scaffold/templates/astro/src/styles/global.css +49 -45
- package/scaffold/templates/astro/src/styles/pages/account.css +0 -90
- package/scaffold/templates/astro/src/styles/pages/confirmation.css +6 -0
- package/scaffold/templates/astro/src/styles/pages/home.css +9 -100
- package/scaffold/templates/astro/src/styles/pages/pdp.css +5 -171
- package/scaffold/templates/astro/src/styles/pages/plp.css +3 -42
- package/scaffold/templates/astro/src/styles/pages/search.css +4 -110
- package/scaffold/templates/astro/src/styles/tokens.css +56 -0
- package/scaffold/templates/nextjs/_dot.node-version +1 -1
- package/scaffold/templates/nextjs/app/(catalog)/category/[slug]/page.tsx +74 -0
- package/scaffold/templates/nextjs/app/(catalog)/home/[key]/page.tsx +80 -0
- package/scaffold/templates/nextjs/app/(catalog)/layout.tsx +13 -0
- package/scaffold/templates/nextjs/app/(catalog)/not-found.tsx +7 -0
- package/scaffold/templates/nextjs/app/(catalog)/product/[slug]/page.tsx +182 -0
- package/scaffold/templates/nextjs/app/(catalog)/products/page.tsx +62 -0
- package/scaffold/templates/nextjs/app/(catalog)/search/SearchView.tsx +90 -0
- package/scaffold/templates/nextjs/app/(catalog)/search/page.tsx +46 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/addresses/AddressesView.tsx +5 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/addresses/page.tsx +17 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/login/LoginView.tsx +28 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/login/page.tsx +23 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/profile/ProfileView.tsx +5 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/profile/page.tsx +20 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/register/RegisterView.tsx +26 -0
- package/scaffold/templates/nextjs/app/(shopper)/account/register/page.tsx +11 -0
- package/scaffold/templates/nextjs/app/(shopper)/cart/CartView.tsx +5 -0
- package/scaffold/templates/nextjs/app/(shopper)/cart/page.tsx +22 -0
- package/scaffold/templates/nextjs/app/(shopper)/checkout/CheckoutView.tsx +27 -0
- package/scaffold/templates/nextjs/app/(shopper)/checkout/page.tsx +39 -0
- package/scaffold/templates/nextjs/app/(shopper)/layout.tsx +12 -0
- package/scaffold/templates/nextjs/app/{order → (shopper)/order}/[orderNumber]/page.tsx +24 -5
- package/scaffold/templates/nextjs/app/error.tsx +3 -7
- package/scaffold/templates/nextjs/app/fragments/footer/route.ts +13 -0
- package/scaffold/templates/nextjs/app/fragments/header/[key]/route.ts +18 -0
- package/scaffold/templates/nextjs/app/fragments/nav/[key]/route.ts +23 -0
- package/scaffold/templates/nextjs/app/fragments/product/[slug]/route.ts +22 -0
- package/scaffold/templates/nextjs/app/globals.css +5 -23
- package/scaffold/templates/nextjs/app/layout.tsx +8 -5
- package/scaffold/templates/nextjs/app/not-found.tsx +9 -33
- package/scaffold/templates/nextjs/app/tokens.css +56 -0
- package/scaffold/templates/nextjs/components/client/ListingControls.tsx +87 -0
- package/scaffold/templates/nextjs/components/client/NymbalSetup.tsx +48 -6
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +30 -15
- package/scaffold/templates/nextjs/components/client/elements.tsx +15 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +33 -102
- package/scaffold/templates/nextjs/components/server/NotFoundView.tsx +39 -0
- package/scaffold/templates/nextjs/components/server/SkipNav.tsx +7 -3
- package/scaffold/templates/nextjs/elements/index.ts +12 -0
- package/scaffold/templates/nextjs/lib/api.ts +151 -55
- package/scaffold/templates/nextjs/lib/chrome.ts +40 -0
- package/scaffold/templates/nextjs/lib/fragments.ts +16 -0
- package/scaffold/templates/nextjs/lib/header.ts +39 -0
- package/scaffold/templates/nextjs/lib/render.ts +14 -0
- package/scaffold/templates/nextjs/next.config.mjs +32 -0
- package/scaffold/templates/nextjs/package.json +28 -27
- package/scaffold/templates/nextjs/styles/pages/account.module.css +0 -70
- package/scaffold/templates/nextjs/styles/pages/confirmation.module.css +6 -85
- package/scaffold/templates/nextjs/styles/pages/home.module.css +2 -103
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +3 -183
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +9 -143
- package/scaffold/templates/nextjs/styles/pages/search.module.css +1 -145
- package/scaffold/templates/nextjs/tsconfig.dist.json +5 -0
- package/scaffold/templates/astro/src/components/AddressForm.astro +0 -107
- package/scaffold/templates/astro/src/components/Breadcrumb.astro +0 -20
- package/scaffold/templates/astro/src/components/CategoryNav.astro +0 -27
- package/scaffold/templates/astro/src/components/CheckoutSteps.astro +0 -29
- package/scaffold/templates/astro/src/components/Footer.astro +0 -108
- package/scaffold/templates/astro/src/components/Header.astro +0 -198
- package/scaffold/templates/astro/src/components/ImageGallery.astro +0 -42
- package/scaffold/templates/astro/src/components/OrderSummary.astro +0 -53
- package/scaffold/templates/astro/src/components/Pagination.astro +0 -13
- package/scaffold/templates/astro/src/components/PriceDisplay.astro +0 -25
- package/scaffold/templates/astro/src/components/ProductCard.astro +0 -44
- package/scaffold/templates/astro/src/components/RelatedProducts.astro +0 -28
- package/scaffold/templates/astro/src/components/ReviewForm.astro +0 -74
- package/scaffold/templates/astro/src/components/ReviewList.astro +0 -41
- package/scaffold/templates/astro/src/components/StockBadge.astro +0 -15
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +0 -82
- package/scaffold/templates/astro/src/scripts/cart-page.ts +0 -118
- package/scaffold/templates/astro/src/scripts/checkout.ts +0 -271
- package/scaffold/templates/astro/src/scripts/image-gallery.ts +0 -22
- package/scaffold/templates/astro/src/scripts/profile-form.ts +0 -57
- package/scaffold/templates/astro/src/scripts/review-form.ts +0 -27
- package/scaffold/templates/astro/src/styles/pages/cart.css +0 -215
- package/scaffold/templates/astro/src/styles/pages/checkout.css +0 -270
- package/scaffold/templates/nextjs/app/account/addresses/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/account/login/page.tsx +0 -23
- package/scaffold/templates/nextjs/app/account/profile/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/account/register/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/cart/page.tsx +0 -10
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +0 -61
- package/scaffold/templates/nextjs/app/checkout/page.tsx +0 -17
- package/scaffold/templates/nextjs/app/page.tsx +0 -63
- package/scaffold/templates/nextjs/app/product/[slug]/page.tsx +0 -111
- package/scaffold/templates/nextjs/app/products/page.tsx +0 -40
- package/scaffold/templates/nextjs/app/search/page.tsx +0 -24
- package/scaffold/templates/nextjs/components/client/AddressManager.tsx +0 -181
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +0 -121
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +0 -482
- package/scaffold/templates/nextjs/components/client/ImageGallery.tsx +0 -68
- package/scaffold/templates/nextjs/components/client/LoginForm.tsx +0 -89
- package/scaffold/templates/nextjs/components/client/MiniCartWrapper.tsx +0 -12
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +0 -137
- package/scaffold/templates/nextjs/components/client/ProfileForm.tsx +0 -112
- package/scaffold/templates/nextjs/components/client/RegisterForm.tsx +0 -131
- package/scaffold/templates/nextjs/components/client/ReviewForm.tsx +0 -116
- package/scaffold/templates/nextjs/components/client/SearchBarWrapper.tsx +0 -27
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +0 -181
- package/scaffold/templates/nextjs/components/server/Breadcrumb.tsx +0 -36
- package/scaffold/templates/nextjs/components/server/CategoryNav.tsx +0 -29
- package/scaffold/templates/nextjs/components/server/CheckoutSteps.tsx +0 -60
- package/scaffold/templates/nextjs/components/server/Footer.module.css +0 -64
- package/scaffold/templates/nextjs/components/server/Footer.tsx +0 -79
- package/scaffold/templates/nextjs/components/server/Header.module.css +0 -147
- package/scaffold/templates/nextjs/components/server/OrderSummary.tsx +0 -79
- package/scaffold/templates/nextjs/components/server/PriceDisplay.tsx +0 -31
- package/scaffold/templates/nextjs/components/server/ProductCard.tsx +0 -59
- package/scaffold/templates/nextjs/components/server/RelatedProducts.tsx +0 -31
- package/scaffold/templates/nextjs/components/server/ReviewList.tsx +0 -62
- package/scaffold/templates/nextjs/styles/pages/cart.module.css +0 -240
- package/scaffold/templates/nextjs/styles/pages/checkout.module.css +0 -404
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/layout.tsx +0 -0
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/[orderNumber]/page.tsx +0 -0
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/orders/page.tsx +0 -0
- /package/scaffold/templates/nextjs/app/{account → (shopper)/account}/page.tsx +0 -0
|
@@ -1,6 +1,26 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
13
|
-
const initialCategory =
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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">🔍</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">🔍</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
|
-
//
|
|
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')
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/* ----------------------------------------------------------------
|
|
2
2
|
Nymbal Global Styles
|
|
3
|
-
Built on
|
|
3
|
+
Built on the design tokens in tokens.css (--nymbal-*)
|
|
4
4
|
---------------------------------------------------------------- */
|
|
5
5
|
|
|
6
|
-
@import '
|
|
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(
|
|
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,
|
|
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 {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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=
|
|
281
|
-
input[type=
|
|
282
|
-
input[type=
|
|
283
|
-
input[type=
|
|
284
|
-
input[type=
|
|
285
|
-
input[type=
|
|
286
|
-
input[type=
|
|
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=
|
|
306
|
-
input[type=
|
|
307
|
-
input[type=
|
|
308
|
-
input[type=
|
|
309
|
-
input[type=
|
|
310
|
-
input[type=
|
|
311
|
-
input[type=
|
|
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);
|