create-nymbal-app 0.2.0 → 0.4.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.js +9 -10
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/scaffold/templates/astro/package.json +25 -26
- package/scaffold/templates/astro/src/components/Header.astro +7 -1
- package/scaffold/templates/astro/src/components/ReviewForm.astro +1 -27
- package/scaffold/templates/astro/src/components/StockBadge.astro +5 -22
- package/scaffold/templates/astro/src/layouts/AccountLayout.astro +2 -0
- package/scaffold/templates/astro/src/lib/api.ts +18 -4
- package/scaffold/templates/astro/src/lib/client.ts +2 -0
- package/scaffold/templates/astro/src/lib/submit-once.ts +44 -0
- package/scaffold/templates/astro/src/pages/account/addresses.astro +2 -2
- package/scaffold/templates/astro/src/pages/account/profile.astro +1 -63
- package/scaffold/templates/astro/src/pages/category/[slug].astro +26 -7
- package/scaffold/templates/astro/src/pages/checkout.astro +12 -4
- package/scaffold/templates/astro/src/pages/product/[slug].astro +4 -1
- package/scaffold/templates/astro/src/pages/products/index.astro +26 -7
- package/scaffold/templates/astro/src/pages/search.astro +11 -5
- package/scaffold/templates/astro/src/scripts/auth-forms.ts +15 -22
- package/scaffold/templates/astro/src/scripts/cart-page.ts +10 -35
- package/scaffold/templates/astro/src/scripts/checkout.ts +52 -58
- package/scaffold/templates/astro/src/scripts/pdp.ts +35 -44
- package/scaffold/templates/astro/src/scripts/plp.ts +6 -5
- package/scaffold/templates/astro/src/scripts/profile-form.ts +57 -0
- package/scaffold/templates/astro/src/scripts/review-form.ts +27 -0
- package/scaffold/templates/astro/src/scripts/search.ts +32 -8
- package/scaffold/templates/astro/src/styles/pages/cart.css +0 -28
- package/scaffold/templates/astro/src/styles/pages/pdp.css +6 -2
- package/scaffold/templates/astro/src/styles/pages/search.css +19 -0
- package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +7 -4
- package/scaffold/templates/nextjs/app/checkout/page.tsx +9 -2
- package/scaffold/templates/nextjs/app/products/page.tsx +7 -7
- package/scaffold/templates/nextjs/components/client/CartPage.tsx +1 -0
- package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +50 -91
- package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +39 -50
- package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +41 -20
- package/scaffold/templates/nextjs/components/client/SearchResults.tsx +87 -47
- package/scaffold/templates/nextjs/components/server/Header.tsx +9 -4
- package/scaffold/templates/nextjs/lib/api.ts +24 -4
- package/scaffold/templates/nextjs/lib/client.ts +3 -0
- package/scaffold/templates/nextjs/package.json +26 -28
- package/scaffold/templates/nextjs/styles/pages/pdp.module.css +6 -2
- package/scaffold/templates/nextjs/styles/pages/plp.module.css +3 -2
- package/scaffold/templates/nextjs/styles/pages/search.module.css +24 -0
- package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
- package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
- package/scaffold/templates/nextjs/components/server/StockBadge.tsx +0 -19
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
3
|
+
import { useRef, useState } from 'react'
|
|
4
|
+
import type { DenormalisedProduct } from '@nymbalhq/sdk'
|
|
5
|
+
import { STOCK_LABELS, stockLevel } from '@nymbalhq/types'
|
|
6
|
+
import { useProduct, VariantSelector, AddToCart, QuantitySelector } from '@nymbalhq/react'
|
|
5
7
|
import { formatPrice } from '@/lib/format'
|
|
6
8
|
import styles from '@/styles/pages/pdp.module.css'
|
|
7
9
|
|
|
@@ -15,18 +17,23 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
|
15
17
|
const variant = selectedVariant ?? product.variants?.[0]
|
|
16
18
|
const price = variant?.priceMinor ?? product.priceMinor ?? 0
|
|
17
19
|
const currency = product.currency ?? 'GBP'
|
|
18
|
-
|
|
20
|
+
// A product with no variants has nothing to sell, so it reads as out of stock.
|
|
21
|
+
const stock = variant?.stock ?? 0
|
|
22
|
+
const level = stockLevel(stock)
|
|
23
|
+
|
|
24
|
+
// The quantity belongs to the variant it was chosen for, so a variant change starts again at 1.
|
|
25
|
+
const [chosen, setChosen] = useState<{ variantId: string | undefined; qty: number }>({
|
|
26
|
+
variantId: variant?.id,
|
|
27
|
+
qty: 1,
|
|
28
|
+
})
|
|
29
|
+
// Reset on the change itself, so a quantity left on a variant is not there again when the shopper comes back to it.
|
|
30
|
+
if (chosen.variantId !== variant?.id) setChosen({ variantId: variant?.id, qty: 1 })
|
|
31
|
+
const qty = chosen.variantId === variant?.id ? chosen.qty : 1
|
|
32
|
+
const addToCartRef = useRef<HTMLElement>(null)
|
|
19
33
|
|
|
20
34
|
// The selector derives its option groups from the product's variants.
|
|
21
35
|
const hasVariantChoice = (product.variants?.length ?? 0) > 1
|
|
22
36
|
|
|
23
|
-
const handleVariantSelected = (e: CustomEvent<{ variant: unknown }>) => {
|
|
24
|
-
const v = e.detail.variant as DenormalisedVariant | undefined
|
|
25
|
-
if (v) {
|
|
26
|
-
// The product store handles variant selection internally
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
37
|
return (
|
|
31
38
|
<div>
|
|
32
39
|
<h1 className={styles.productTitle} data-testid="product-title">
|
|
@@ -37,24 +44,38 @@ export function ProductInteractive({ product }: ProductInteractiveProps) {
|
|
|
37
44
|
{formatPrice(price, currency)}
|
|
38
45
|
</div>
|
|
39
46
|
|
|
40
|
-
<div className={styles.stockBadge} data-testid="stock-badge">
|
|
41
|
-
{
|
|
42
|
-
|
|
43
|
-
) : stock < 5 ? (
|
|
44
|
-
<span style={{ color: 'var(--nymbal-color-warning)' }}>Low Stock — {stock} left</span>
|
|
45
|
-
) : (
|
|
46
|
-
<span style={{ color: 'var(--nymbal-color-success)' }}>In Stock</span>
|
|
47
|
-
)}
|
|
47
|
+
<div className={styles.stockBadge} data-testid="stock-badge" data-level={level}>
|
|
48
|
+
<span className={styles.stockDot} aria-hidden="true" />
|
|
49
|
+
{STOCK_LABELS[level]}
|
|
48
50
|
</div>
|
|
49
51
|
|
|
50
52
|
{hasVariantChoice && (
|
|
51
53
|
<div className={styles.variantSection}>
|
|
52
|
-
<VariantSelector
|
|
54
|
+
<VariantSelector />
|
|
53
55
|
</div>
|
|
54
56
|
)}
|
|
55
57
|
|
|
56
58
|
<div className={styles.addToCartSection}>
|
|
57
|
-
<
|
|
59
|
+
<div className={styles.quantityWrapper}>
|
|
60
|
+
<QuantitySelector
|
|
61
|
+
key={variant?.id}
|
|
62
|
+
value={1}
|
|
63
|
+
min={1}
|
|
64
|
+
max={99}
|
|
65
|
+
onQuantityChange={(e) => {
|
|
66
|
+
// A typed quantity changes on blur, and the press on Add is what blurs it: the wrapper's effect lands
|
|
67
|
+
// after a fast click has already read `qty`, so the element gets it here, in the same event.
|
|
68
|
+
addToCartRef.current?.setAttribute('qty', String(e.detail.value))
|
|
69
|
+
setChosen({ variantId: variant?.id, qty: e.detail.value })
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
<AddToCart
|
|
74
|
+
ref={addToCartRef}
|
|
75
|
+
variantId={variant?.id ?? product.id}
|
|
76
|
+
qty={qty}
|
|
77
|
+
disabled={stock <= 0}
|
|
78
|
+
/>
|
|
58
79
|
</div>
|
|
59
80
|
</div>
|
|
60
81
|
)
|
|
@@ -1,23 +1,42 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect } from 'react'
|
|
3
|
+
import { useCallback, useEffect } from 'react'
|
|
4
4
|
import { useSearchParams } from 'next/navigation'
|
|
5
5
|
import Link from 'next/link'
|
|
6
6
|
import Image from 'next/image'
|
|
7
|
-
import { useSearch } from '@nymbalhq/react'
|
|
7
|
+
import { ProductFilter, useSearch } from '@nymbalhq/react'
|
|
8
8
|
import { formatPrice } from '@/lib/format'
|
|
9
9
|
import styles from '@/styles/pages/search.module.css'
|
|
10
10
|
|
|
11
11
|
export function SearchResults() {
|
|
12
12
|
const searchParams = useSearchParams()
|
|
13
13
|
const query = searchParams.get('q') ?? ''
|
|
14
|
-
const
|
|
14
|
+
const category = searchParams.get('category')
|
|
15
|
+
const {
|
|
16
|
+
results,
|
|
17
|
+
total,
|
|
18
|
+
nextCursor,
|
|
19
|
+
facets,
|
|
20
|
+
loading,
|
|
21
|
+
query: searched,
|
|
22
|
+
category: searchedCategory,
|
|
23
|
+
search,
|
|
24
|
+
loadMore,
|
|
25
|
+
} = useSearch()
|
|
15
26
|
|
|
16
27
|
useEffect(() => {
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
// Runs when the URL changes; the filter has already searched the category it writes there.
|
|
29
|
+
if (query && (query !== searched || category !== searchedCategory)) {
|
|
30
|
+
void search(query, { category })
|
|
19
31
|
}
|
|
20
|
-
}, [query, search])
|
|
32
|
+
}, [query, category, search])
|
|
33
|
+
|
|
34
|
+
const onFilterChange = useCallback((event: CustomEvent<{ name: string; value: string }>) => {
|
|
35
|
+
const url = new URL(window.location.href)
|
|
36
|
+
if (event.detail.value) url.searchParams.set('category', event.detail.value)
|
|
37
|
+
else url.searchParams.delete('category')
|
|
38
|
+
window.history.replaceState(null, '', url.toString())
|
|
39
|
+
}, [])
|
|
21
40
|
|
|
22
41
|
return (
|
|
23
42
|
<div className={`container ${styles.page}`}>
|
|
@@ -30,7 +49,7 @@ export function SearchResults() {
|
|
|
30
49
|
)}
|
|
31
50
|
</div>
|
|
32
51
|
|
|
33
|
-
{loading ? (
|
|
52
|
+
{loading && results.length === 0 ? (
|
|
34
53
|
<div className={styles.loading}>
|
|
35
54
|
<div className="spinner spinner-lg" role="status">
|
|
36
55
|
<span className="visually-hidden">Searching...</span>
|
|
@@ -57,48 +76,69 @@ export function SearchResults() {
|
|
|
57
76
|
Use the search bar above to find what you are looking for.
|
|
58
77
|
</p>
|
|
59
78
|
</div>
|
|
60
|
-
) : results
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
) : results.length > 0 ? (
|
|
80
|
+
<div className={facets.length > 0 ? styles.layout : undefined}>
|
|
81
|
+
{facets.length > 0 && (
|
|
82
|
+
<aside className={styles.sidebar}>
|
|
83
|
+
<ProductFilter source="search" onFilterChange={onFilterChange} />
|
|
84
|
+
</aside>
|
|
85
|
+
)}
|
|
86
|
+
<div>
|
|
87
|
+
<p className={styles.resultCount} data-testid="search-total">
|
|
88
|
+
{total} result{total !== 1 ? 's' : ''} found
|
|
89
|
+
</p>
|
|
90
|
+
<div className={styles.grid}>
|
|
91
|
+
{results.map((product) => {
|
|
92
|
+
const media = Array.isArray(product.media) ? product.media : []
|
|
93
|
+
const imageUrl =
|
|
94
|
+
media.length > 0 && typeof (media[0] as Record<string, unknown>)?.url === 'string'
|
|
95
|
+
? (media[0] as Record<string, string>).url
|
|
96
|
+
: null
|
|
97
|
+
return (
|
|
98
|
+
<Link
|
|
99
|
+
key={product.id}
|
|
100
|
+
href={`/product/${product.slug}`}
|
|
101
|
+
className={styles.productCard}
|
|
102
|
+
data-testid={`product-card-${product.slug}`}
|
|
103
|
+
>
|
|
104
|
+
<div className={styles.productImage}>
|
|
105
|
+
{imageUrl && (
|
|
106
|
+
<Image
|
|
107
|
+
src={imageUrl}
|
|
108
|
+
alt={product.name}
|
|
109
|
+
width={400}
|
|
110
|
+
height={400}
|
|
111
|
+
sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 25vw"
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
<div className={styles.productInfo}>
|
|
116
|
+
<p className={styles.productName}>{product.name}</p>
|
|
117
|
+
{product.priceMinor != null && product.currency && (
|
|
118
|
+
<p className={styles.productPrice}>
|
|
119
|
+
{formatPrice(product.priceMinor, product.currency)}
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</Link>
|
|
124
|
+
)
|
|
125
|
+
})}
|
|
126
|
+
</div>
|
|
127
|
+
{nextCursor !== null && (
|
|
128
|
+
<div className={styles.pagination}>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="btn btn-secondary"
|
|
132
|
+
onClick={() => void loadMore()}
|
|
133
|
+
disabled={loading}
|
|
134
|
+
data-testid="search-load-more"
|
|
77
135
|
>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
alt={product.name}
|
|
83
|
-
width={400}
|
|
84
|
-
height={400}
|
|
85
|
-
sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 25vw"
|
|
86
|
-
/>
|
|
87
|
-
)}
|
|
88
|
-
</div>
|
|
89
|
-
<div className={styles.productInfo}>
|
|
90
|
-
<p className={styles.productName}>{product.name}</p>
|
|
91
|
-
{product.priceMinor != null && product.currency && (
|
|
92
|
-
<p className={styles.productPrice}>
|
|
93
|
-
{formatPrice(product.priceMinor, product.currency)}
|
|
94
|
-
</p>
|
|
95
|
-
)}
|
|
96
|
-
</div>
|
|
97
|
-
</Link>
|
|
98
|
-
)
|
|
99
|
-
})}
|
|
136
|
+
{loading ? 'Loading...' : 'Load more'}
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
100
140
|
</div>
|
|
101
|
-
|
|
141
|
+
</div>
|
|
102
142
|
) : (
|
|
103
143
|
<div className={styles.empty}>
|
|
104
144
|
<div className={styles.emptyIcon}>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Link from 'next/link'
|
|
2
2
|
import { SearchBarWrapper } from '@/components/client/SearchBarWrapper'
|
|
3
3
|
import { MiniCartWrapper } from '@/components/client/MiniCartWrapper'
|
|
4
|
+
import { fetchCapabilities } from '@/lib/api'
|
|
4
5
|
import styles from './Header.module.css'
|
|
5
6
|
|
|
6
7
|
const NAV_LINKS = [
|
|
@@ -9,7 +10,9 @@ const NAV_LINKS = [
|
|
|
9
10
|
{ href: '/products?category=sale', label: 'Sale' },
|
|
10
11
|
]
|
|
11
12
|
|
|
12
|
-
export function Header() {
|
|
13
|
+
export async function Header() {
|
|
14
|
+
const capabilities = await fetchCapabilities()
|
|
15
|
+
const canSearch = capabilities?.search.capabilities.includes('search') ?? false
|
|
13
16
|
return (
|
|
14
17
|
<header className={styles.header} data-testid="site-header">
|
|
15
18
|
<div className={`container ${styles.inner}`}>
|
|
@@ -30,9 +33,11 @@ export function Header() {
|
|
|
30
33
|
</nav>
|
|
31
34
|
|
|
32
35
|
<div className={styles.actions}>
|
|
33
|
-
|
|
34
|
-
<
|
|
35
|
-
|
|
36
|
+
{canSearch && (
|
|
37
|
+
<div className={styles.search}>
|
|
38
|
+
<SearchBarWrapper />
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
36
41
|
<Link href="/account" className={styles.iconLink} aria-label="Account">
|
|
37
42
|
<svg
|
|
38
43
|
width="20"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { unstable_rethrow } from 'next/navigation'
|
|
1
2
|
import type { DenormalisedProduct, ProductListResult, ReviewsListResult } from '@nymbalhq/sdk'
|
|
2
3
|
import { DEFAULT_PRODUCT_SORT, PRODUCT_SORT_KEYS } from '@nymbalhq/types'
|
|
3
|
-
import type { Category, Order, ProductSortKey } from '@nymbalhq/types'
|
|
4
|
+
import type { Category, Order, ProductSortKey, StoreCapabilities } from '@nymbalhq/types'
|
|
4
5
|
|
|
5
6
|
const API_URL = process.env.NYMBAL_API_URL ?? 'http://localhost:3001'
|
|
6
7
|
|
|
@@ -27,10 +28,29 @@ export async function fetchProducts(params?: {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
/**
|
|
31
|
-
export function
|
|
31
|
+
/** What the store can do, which decides what the page renders; null when the API did not answer. */
|
|
32
|
+
export async function fetchCapabilities(): Promise<StoreCapabilities | null> {
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`${API_URL}/api/capabilities`, { cache: 'no-store' })
|
|
35
|
+
if (!res.ok) return null
|
|
36
|
+
const envelope = (await res.json()) as { data: StoreCapabilities }
|
|
37
|
+
return envelope.data ?? null
|
|
38
|
+
} catch (err) {
|
|
39
|
+
// Next signals a request-time render by throwing from fetch at build; that must reach Next, not become null.
|
|
40
|
+
unstable_rethrow(err)
|
|
41
|
+
console.error('[api] fetchCapabilities failed', err)
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The sort a listing URL asks for, among the store's keys; a missing or unknown `?sort=` is the store's default. */
|
|
47
|
+
export function sortFromSearchParams(
|
|
48
|
+
params: { sort?: string | string[] },
|
|
49
|
+
catalog?: StoreCapabilities['catalog'],
|
|
50
|
+
): ProductSortKey {
|
|
32
51
|
const sort = Array.isArray(params.sort) ? params.sort[0] : params.sort
|
|
33
|
-
|
|
52
|
+
const keys = catalog?.sortKeys ?? PRODUCT_SORT_KEYS
|
|
53
|
+
return keys.find((key) => key === sort) ?? catalog?.defaultSort ?? DEFAULT_PRODUCT_SORT
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
export async function fetchProduct(slug: string): Promise<DenormalisedProduct | null> {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { createNymbalClient, type NymbalClient } from '@nymbalhq/sdk'
|
|
2
2
|
|
|
3
|
+
// A payment driver of the project's own is registered here, at module load, before any checkout renders:
|
|
4
|
+
// registerPaymentDriver(name, driver) from @nymbalhq/sdk, named by the driver of the payments adapter's client half.
|
|
5
|
+
|
|
3
6
|
let instance: NymbalClient | null = null
|
|
4
7
|
let restoring: Promise<void> | null = null
|
|
5
8
|
|
|
@@ -17,19 +17,17 @@
|
|
|
17
17
|
"seed": "nymbal seed"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"nymbal": "
|
|
21
|
-
"@nymbalhq/config": "
|
|
22
|
-
"@nymbalhq/platform": "
|
|
23
|
-
"@nymbalhq/stack": "
|
|
20
|
+
"nymbal": "1.1.0",
|
|
21
|
+
"@nymbalhq/config": "1.1.0",
|
|
22
|
+
"@nymbalhq/platform": "1.1.0",
|
|
23
|
+
"@nymbalhq/stack": "1.1.0",
|
|
24
24
|
"next": "^16.4.2",
|
|
25
25
|
"react": "^19.3.1",
|
|
26
26
|
"react-dom": "^19.3.1",
|
|
27
|
-
"@nymbalhq/sdk": "
|
|
28
|
-
"@nymbalhq/react": "
|
|
29
|
-
"@nymbalhq/web-components": "
|
|
30
|
-
"@nymbalhq/types": "
|
|
31
|
-
"@stripe/stripe-js": "^4.0.0",
|
|
32
|
-
"@stripe/react-stripe-js": "^3.0.0"
|
|
27
|
+
"@nymbalhq/sdk": "1.1.0",
|
|
28
|
+
"@nymbalhq/react": "1.1.0",
|
|
29
|
+
"@nymbalhq/web-components": "1.1.0",
|
|
30
|
+
"@nymbalhq/types": "1.1.0"
|
|
33
31
|
},
|
|
34
32
|
"devDependencies": {
|
|
35
33
|
"@types/node": "^22.9.0",
|
|
@@ -39,26 +37,26 @@
|
|
|
39
37
|
},
|
|
40
38
|
"pnpm": {
|
|
41
39
|
"overrides": {
|
|
42
|
-
"nymbal": "
|
|
43
|
-
"@nymbalhq/config": "
|
|
44
|
-
"@nymbalhq/http": "
|
|
45
|
-
"@nymbalhq/platform": "
|
|
46
|
-
"@nymbalhq/react": "
|
|
47
|
-
"@nymbalhq/sdk": "
|
|
48
|
-
"@nymbalhq/stack": "
|
|
49
|
-
"@nymbalhq/types": "
|
|
50
|
-
"@nymbalhq/web-components": "
|
|
40
|
+
"nymbal": "1.1.0",
|
|
41
|
+
"@nymbalhq/config": "1.1.0",
|
|
42
|
+
"@nymbalhq/http": "1.1.0",
|
|
43
|
+
"@nymbalhq/platform": "1.1.0",
|
|
44
|
+
"@nymbalhq/react": "1.1.0",
|
|
45
|
+
"@nymbalhq/sdk": "1.1.0",
|
|
46
|
+
"@nymbalhq/stack": "1.1.0",
|
|
47
|
+
"@nymbalhq/types": "1.1.0",
|
|
48
|
+
"@nymbalhq/web-components": "1.1.0"
|
|
51
49
|
}
|
|
52
50
|
},
|
|
53
51
|
"overrides": {
|
|
54
|
-
"nymbal": "
|
|
55
|
-
"@nymbalhq/config": "
|
|
56
|
-
"@nymbalhq/http": "
|
|
57
|
-
"@nymbalhq/platform": "
|
|
58
|
-
"@nymbalhq/react": "
|
|
59
|
-
"@nymbalhq/sdk": "
|
|
60
|
-
"@nymbalhq/stack": "
|
|
61
|
-
"@nymbalhq/types": "
|
|
62
|
-
"@nymbalhq/web-components": "
|
|
52
|
+
"nymbal": "1.1.0",
|
|
53
|
+
"@nymbalhq/config": "1.1.0",
|
|
54
|
+
"@nymbalhq/http": "1.1.0",
|
|
55
|
+
"@nymbalhq/platform": "1.1.0",
|
|
56
|
+
"@nymbalhq/react": "1.1.0",
|
|
57
|
+
"@nymbalhq/sdk": "1.1.0",
|
|
58
|
+
"@nymbalhq/stack": "1.1.0",
|
|
59
|
+
"@nymbalhq/types": "1.1.0",
|
|
60
|
+
"@nymbalhq/web-components": "1.1.0"
|
|
63
61
|
}
|
|
64
62
|
}
|
|
@@ -148,12 +148,16 @@
|
|
|
148
148
|
margin-bottom: var(--nymbal-spacing-md);
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
.
|
|
151
|
+
.stockBadge[data-level='in'] {
|
|
152
152
|
background-color: #dcfce7;
|
|
153
153
|
color: var(--nymbal-color-success);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
.
|
|
156
|
+
.stockBadge[data-level='low'] {
|
|
157
|
+
color: var(--nymbal-color-warning);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.stockBadge[data-level='out'] {
|
|
157
161
|
background-color: #fef2f2;
|
|
158
162
|
color: var(--nymbal-color-error);
|
|
159
163
|
}
|
|
@@ -118,7 +118,8 @@
|
|
|
118
118
|
color: var(--nymbal-color-text-muted);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
.
|
|
121
|
+
/* The sort element renders the select. */
|
|
122
|
+
.sortSelect select {
|
|
122
123
|
padding: 0.5rem 0.75rem;
|
|
123
124
|
font-size: var(--nymbal-font-size-sm);
|
|
124
125
|
border: 1px solid var(--nymbal-color-border);
|
|
@@ -129,7 +130,7 @@
|
|
|
129
130
|
min-width: 10rem;
|
|
130
131
|
}
|
|
131
132
|
|
|
132
|
-
.sortSelect:focus {
|
|
133
|
+
.sortSelect select:focus {
|
|
133
134
|
outline: none;
|
|
134
135
|
border-color: var(--nymbal-color-accent);
|
|
135
136
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
|
@@ -143,6 +143,30 @@
|
|
|
143
143
|
margin-inline: auto;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
/* ── Category filter and load more ───────────────────────────────── */
|
|
147
|
+
|
|
148
|
+
.layout {
|
|
149
|
+
display: grid;
|
|
150
|
+
grid-template-columns: 1fr;
|
|
151
|
+
gap: var(--nymbal-spacing-lg);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@media (min-width: 769px) {
|
|
155
|
+
.layout {
|
|
156
|
+
grid-template-columns: 240px minmax(0, 1fr);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.sidebar {
|
|
161
|
+
align-self: start;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.pagination {
|
|
165
|
+
display: flex;
|
|
166
|
+
justify-content: center;
|
|
167
|
+
margin-top: var(--nymbal-spacing-xl);
|
|
168
|
+
}
|
|
169
|
+
|
|
146
170
|
/* ── Loading ──────────────────────────────────────────────────────── */
|
|
147
171
|
|
|
148
172
|
.loading {
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import type { ProductSortKey } from '@nymbalhq/types'
|
|
3
|
-
|
|
4
|
-
interface Props {
|
|
5
|
-
sort: ProductSortKey
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const { sort } = Astro.props
|
|
9
|
-
|
|
10
|
-
const OPTIONS: Array<{ value: ProductSortKey; label: string }> = [
|
|
11
|
-
{ value: 'newest', label: 'Newest' },
|
|
12
|
-
{ value: 'price-asc', label: 'Price: Low to High' },
|
|
13
|
-
{ value: 'price-desc', label: 'Price: High to Low' },
|
|
14
|
-
{ value: 'name-asc', label: 'Name: A to Z' },
|
|
15
|
-
{ value: 'name-desc', label: 'Name: Z to A' },
|
|
16
|
-
]
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
<div class="plp-sort-bar">
|
|
20
|
-
<label for="sort-select">Sort by</label>
|
|
21
|
-
<select id="sort-select" data-testid="sort-dropdown">
|
|
22
|
-
{OPTIONS.map((option) => (
|
|
23
|
-
<option value={option.value} selected={option.value === sort}>{option.label}</option>
|
|
24
|
-
))}
|
|
25
|
-
</select>
|
|
26
|
-
</div>
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
'use client'
|
|
2
|
-
|
|
3
|
-
import type { ProductSortKey } from '@nymbalhq/types'
|
|
4
|
-
import styles from '@/styles/pages/plp.module.css'
|
|
5
|
-
|
|
6
|
-
const OPTIONS: Array<{ value: ProductSortKey; label: string }> = [
|
|
7
|
-
{ value: 'newest', label: 'Newest' },
|
|
8
|
-
{ value: 'price-asc', label: 'Price: Low to High' },
|
|
9
|
-
{ value: 'price-desc', label: 'Price: High to Low' },
|
|
10
|
-
{ value: 'name-asc', label: 'Name: A to Z' },
|
|
11
|
-
{ value: 'name-desc', label: 'Name: Z to A' },
|
|
12
|
-
]
|
|
13
|
-
|
|
14
|
-
interface SortDropdownProps {
|
|
15
|
-
value: ProductSortKey
|
|
16
|
-
onChange: (sort: ProductSortKey) => void
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function SortDropdown({ value, onChange }: SortDropdownProps) {
|
|
20
|
-
return (
|
|
21
|
-
<select
|
|
22
|
-
className={styles.sortSelect}
|
|
23
|
-
value={value}
|
|
24
|
-
onChange={(e) => onChange(e.target.value as ProductSortKey)}
|
|
25
|
-
data-testid="sort-dropdown"
|
|
26
|
-
aria-label="Sort products"
|
|
27
|
-
>
|
|
28
|
-
{OPTIONS.map((option) => (
|
|
29
|
-
<option key={option.value} value={option.value}>
|
|
30
|
-
{option.label}
|
|
31
|
-
</option>
|
|
32
|
-
))}
|
|
33
|
-
</select>
|
|
34
|
-
)
|
|
35
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import styles from '@/styles/pages/pdp.module.css'
|
|
2
|
-
|
|
3
|
-
interface StockBadgeProps {
|
|
4
|
-
stock: number
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function StockBadge({ stock }: StockBadgeProps) {
|
|
8
|
-
const inStock = stock > 0
|
|
9
|
-
|
|
10
|
-
return (
|
|
11
|
-
<span
|
|
12
|
-
className={`${styles.stockBadge} ${inStock ? styles.inStock : styles.outOfStock}`}
|
|
13
|
-
data-testid="stock-badge"
|
|
14
|
-
>
|
|
15
|
-
<span className={styles.stockDot} aria-hidden="true" />
|
|
16
|
-
{inStock ? (stock <= 5 ? `Only ${stock} left` : 'In Stock') : 'Out of Stock'}
|
|
17
|
-
</span>
|
|
18
|
-
)
|
|
19
|
-
}
|