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.
Files changed (47) hide show
  1. package/dist/index.js +9 -10
  2. package/dist/index.js.map +1 -1
  3. package/package.json +1 -1
  4. package/scaffold/templates/astro/package.json +25 -26
  5. package/scaffold/templates/astro/src/components/Header.astro +7 -1
  6. package/scaffold/templates/astro/src/components/ReviewForm.astro +1 -27
  7. package/scaffold/templates/astro/src/components/StockBadge.astro +5 -22
  8. package/scaffold/templates/astro/src/layouts/AccountLayout.astro +2 -0
  9. package/scaffold/templates/astro/src/lib/api.ts +18 -4
  10. package/scaffold/templates/astro/src/lib/client.ts +2 -0
  11. package/scaffold/templates/astro/src/lib/submit-once.ts +44 -0
  12. package/scaffold/templates/astro/src/pages/account/addresses.astro +2 -2
  13. package/scaffold/templates/astro/src/pages/account/profile.astro +1 -63
  14. package/scaffold/templates/astro/src/pages/category/[slug].astro +26 -7
  15. package/scaffold/templates/astro/src/pages/checkout.astro +12 -4
  16. package/scaffold/templates/astro/src/pages/product/[slug].astro +4 -1
  17. package/scaffold/templates/astro/src/pages/products/index.astro +26 -7
  18. package/scaffold/templates/astro/src/pages/search.astro +11 -5
  19. package/scaffold/templates/astro/src/scripts/auth-forms.ts +15 -22
  20. package/scaffold/templates/astro/src/scripts/cart-page.ts +10 -35
  21. package/scaffold/templates/astro/src/scripts/checkout.ts +52 -58
  22. package/scaffold/templates/astro/src/scripts/pdp.ts +35 -44
  23. package/scaffold/templates/astro/src/scripts/plp.ts +6 -5
  24. package/scaffold/templates/astro/src/scripts/profile-form.ts +57 -0
  25. package/scaffold/templates/astro/src/scripts/review-form.ts +27 -0
  26. package/scaffold/templates/astro/src/scripts/search.ts +32 -8
  27. package/scaffold/templates/astro/src/styles/pages/cart.css +0 -28
  28. package/scaffold/templates/astro/src/styles/pages/pdp.css +6 -2
  29. package/scaffold/templates/astro/src/styles/pages/search.css +19 -0
  30. package/scaffold/templates/nextjs/app/category/[slug]/page.tsx +7 -4
  31. package/scaffold/templates/nextjs/app/checkout/page.tsx +9 -2
  32. package/scaffold/templates/nextjs/app/products/page.tsx +7 -7
  33. package/scaffold/templates/nextjs/components/client/CartPage.tsx +1 -0
  34. package/scaffold/templates/nextjs/components/client/CheckoutForm.tsx +50 -91
  35. package/scaffold/templates/nextjs/components/client/ProductGrid.tsx +39 -50
  36. package/scaffold/templates/nextjs/components/client/ProductInteractive.tsx +41 -20
  37. package/scaffold/templates/nextjs/components/client/SearchResults.tsx +87 -47
  38. package/scaffold/templates/nextjs/components/server/Header.tsx +9 -4
  39. package/scaffold/templates/nextjs/lib/api.ts +24 -4
  40. package/scaffold/templates/nextjs/lib/client.ts +3 -0
  41. package/scaffold/templates/nextjs/package.json +26 -28
  42. package/scaffold/templates/nextjs/styles/pages/pdp.module.css +6 -2
  43. package/scaffold/templates/nextjs/styles/pages/plp.module.css +3 -2
  44. package/scaffold/templates/nextjs/styles/pages/search.module.css +24 -0
  45. package/scaffold/templates/astro/src/components/SortDropdown.astro +0 -26
  46. package/scaffold/templates/nextjs/components/client/SortDropdown.tsx +0 -35
  47. package/scaffold/templates/nextjs/components/server/StockBadge.tsx +0 -19
@@ -1,7 +1,9 @@
1
1
  'use client'
2
2
 
3
- import type { DenormalisedProduct, DenormalisedVariant } from '@nymbalhq/sdk'
4
- import { useProduct, VariantSelector, AddToCart } from '@nymbalhq/react'
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
- const stock = variant?.stock ?? (product.inStock ? 10 : 0)
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
- {stock === 0 ? (
42
- <span style={{ color: 'var(--nymbal-color-error)' }}>Out of Stock</span>
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 onVariantSelected={handleVariantSelected} />
54
+ <VariantSelector />
53
55
  </div>
54
56
  )}
55
57
 
56
58
  <div className={styles.addToCartSection}>
57
- <AddToCart variantId={variant?.id ?? product.id} disabled={stock === 0} />
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 { results, loading, search } = useSearch()
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
- if (query) {
18
- search(query)
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 && results.length > 0 ? (
61
- <>
62
- <p className={styles.resultCount}>
63
- {results.length} result{results.length !== 1 ? 's' : ''} found
64
- </p>
65
- <div className={styles.grid}>
66
- {results.map((product) => {
67
- const media = Array.isArray(product.media) ? product.media : []
68
- const imageUrl = media.length > 0 && typeof (media[0] as Record<string, unknown>)?.url === 'string'
69
- ? (media[0] as Record<string, string>).url
70
- : null
71
- return (
72
- <Link
73
- key={product.id}
74
- href={`/product/${product.slug}`}
75
- className={styles.productCard}
76
- data-testid={`product-card-${product.slug}`}
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
- <div className={styles.productImage}>
79
- {imageUrl && (
80
- <Image
81
- src={imageUrl}
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
- <div className={styles.search}>
34
- <SearchBarWrapper />
35
- </div>
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
- /** The sort a listing URL asks for; a missing or unknown `?sort=` is the default. */
31
- export function sortFromSearchParams(params: { sort?: string | string[] }): ProductSortKey {
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
- return PRODUCT_SORT_KEYS.find((key) => key === sort) ?? DEFAULT_PRODUCT_SORT
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": "0.2.0",
21
- "@nymbalhq/config": "0.2.0",
22
- "@nymbalhq/platform": "0.2.0",
23
- "@nymbalhq/stack": "0.2.0",
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": "0.2.0",
28
- "@nymbalhq/react": "0.2.0",
29
- "@nymbalhq/web-components": "0.2.0",
30
- "@nymbalhq/types": "0.2.0",
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": "0.2.0",
43
- "@nymbalhq/config": "0.2.0",
44
- "@nymbalhq/http": "0.2.0",
45
- "@nymbalhq/platform": "0.2.0",
46
- "@nymbalhq/react": "0.2.0",
47
- "@nymbalhq/sdk": "0.2.0",
48
- "@nymbalhq/stack": "0.2.0",
49
- "@nymbalhq/types": "0.2.0",
50
- "@nymbalhq/web-components": "0.2.0"
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": "0.2.0",
55
- "@nymbalhq/config": "0.2.0",
56
- "@nymbalhq/http": "0.2.0",
57
- "@nymbalhq/platform": "0.2.0",
58
- "@nymbalhq/react": "0.2.0",
59
- "@nymbalhq/sdk": "0.2.0",
60
- "@nymbalhq/stack": "0.2.0",
61
- "@nymbalhq/types": "0.2.0",
62
- "@nymbalhq/web-components": "0.2.0"
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
- .inStock {
151
+ .stockBadge[data-level='in'] {
152
152
  background-color: #dcfce7;
153
153
  color: var(--nymbal-color-success);
154
154
  }
155
155
 
156
- .outOfStock {
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
- .sortSelect {
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
- }