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.
- package/dist/index.d.ts +2 -1
- package/dist/index.js +14 -12
- 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 -27
- 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 +126 -20
- package/scaffold/templates/astro/src/lib/chrome.ts +59 -0
- package/scaffold/templates/astro/src/lib/client.ts +4 -2
- 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 +41 -26
- package/scaffold/templates/astro/src/pages/checkout.astro +34 -90
- 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 +46 -29
- package/scaffold/templates/astro/src/pages/products/index.astro +40 -26
- package/scaffold/templates/astro/src/pages/search.astro +13 -8
- package/scaffold/templates/astro/src/scripts/init-client.ts +14 -0
- package/scaffold/templates/astro/src/scripts/pdp.ts +47 -53
- package/scaffold/templates/astro/src/scripts/plp.ts +21 -98
- package/scaffold/templates/astro/src/scripts/search.ts +19 -120
- 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 -167
- package/scaffold/templates/astro/src/styles/pages/plp.css +3 -42
- package/scaffold/templates/astro/src/styles/pages/search.css +13 -100
- 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 +64 -28
- package/scaffold/templates/nextjs/components/client/elements.tsx +15 -0
- package/scaffold/templates/nextjs/components/server/Header.tsx +33 -97
- 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 +162 -46
- package/scaffold/templates/nextjs/lib/chrome.ts +40 -0
- package/scaffold/templates/nextjs/lib/client.ts +3 -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 -29
- 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 -179
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +8 -141
- package/scaffold/templates/nextjs/styles/pages/search.module.css +8 -128
- 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 -192
- 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/SortDropdown.astro +0 -26
- package/scaffold/templates/astro/src/components/StockBadge.astro +0 -32
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +0 -82
- package/scaffold/templates/astro/src/scripts/cart-page.ts +0 -143
- package/scaffold/templates/astro/src/scripts/checkout.ts +0 -272
- 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 -243
- 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 -58
- package/scaffold/templates/nextjs/app/checkout/page.tsx +0 -10
- 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 -120
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +0 -523
- 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 -148
- 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 -141
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
- 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/components/server/StockBadge.tsx +0 -19
- 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,13 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
import '../../styles/pages/pdp.css'
|
|
3
3
|
import BaseLayout from '../../layouts/BaseLayout.astro'
|
|
4
|
-
import
|
|
5
|
-
import
|
|
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.
|
|
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
|
-
|
|
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
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
<
|
|
98
|
-
<
|
|
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
|
-
|
|
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
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import
|
|
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
|
|
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
|
-
<
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
<
|
|
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 '
|
|
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
|
-
<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
30
|
+
function setQuantity(qty: number): void {
|
|
31
|
+
quantity?.setAttribute('value', String(qty))
|
|
32
|
+
addToCart?.setAttribute('qty', String(qty))
|
|
33
|
+
}
|
|
15
34
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
49
|
+
if (addToCart && variant) {
|
|
50
|
+
addToCart.setAttribute('variant-id', variant.id)
|
|
51
|
+
addToCart.toggleAttribute('disabled', (variant.stock ?? 0) <= 0)
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
NymbalBreadcrumb.define()
|
|
9
|
+
NymbalProductFilter.define()
|
|
10
|
+
NymbalSortDropdown.define()
|
|
11
|
+
NymbalProductGrid.define()
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
99
|
-
const sort =
|
|
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
|
-
})
|