@pradip1995/segment-beauty-shop-grid 0.1.1

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/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@pradip1995/segment-beauty-shop-grid",
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "sideEffects": [
9
+ "./src/beauty-shop-grid.css"
10
+ ],
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "exports": {
15
+ ".": "./src/index.ts",
16
+ "./manifest": "./src/manifest.ts"
17
+ },
18
+ "peerDependencies": {
19
+ "@pradip1995/commerce-core": "^4.0.0",
20
+ "@pradip1995/medusa-connector": "^0.2.0",
21
+ "@pradip1995/plugin-sdk": "^0.2.0",
22
+ "react": ">=19",
23
+ "react-dom": ">=19",
24
+ "next": ">=15"
25
+ },
26
+ "dependencies": {
27
+ "@pradip1995/segment-primitives": "0.3.0",
28
+ "@pradip1995/segment-product-card": "0.3.3",
29
+ "@pradip1995/segment-tokens": "0.3.2"
30
+ },
31
+ "devDependencies": {
32
+ "@pradip1995/plugin-sdk": "^0.2.0",
33
+ "@types/react": "^19",
34
+ "react": "19.0.3",
35
+ "typescript": "^5.7.2"
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit",
39
+ "lint": "tsc --noEmit"
40
+ }
41
+ }
@@ -0,0 +1,192 @@
1
+ .beauty-shop-grid {
2
+ padding: 0.5rem 0 3rem;
3
+ }
4
+
5
+ .beauty-shop-grid__header {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: 1.25rem;
9
+ margin-bottom: 1.5rem;
10
+ padding-bottom: 1.25rem;
11
+ border-bottom: 1px solid #ebebeb;
12
+ }
13
+
14
+ .beauty-shop-grid__eyebrow {
15
+ margin: 0;
16
+ font-size: 0.6875rem;
17
+ font-weight: 600;
18
+ letter-spacing: 0.2em;
19
+ text-transform: uppercase;
20
+ color: var(--color-brand-accent, #8b3a62);
21
+ }
22
+
23
+ .beauty-shop-grid__title {
24
+ margin: 0.35rem 0 0;
25
+ font-family: var(--font-heading, Georgia, serif);
26
+ font-size: clamp(1.75rem, 3vw, 2.375rem);
27
+ font-weight: 500;
28
+ line-height: 1.12;
29
+ letter-spacing: 0.01em;
30
+ color: var(--color-text-heading, #0a0a0a);
31
+ }
32
+
33
+ .beauty-shop-grid__search,
34
+ .beauty-shop-grid__count {
35
+ margin: 0.5rem 0 0;
36
+ font-size: 0.875rem;
37
+ color: var(--color-text-muted, #737373);
38
+ }
39
+
40
+ .beauty-shop-grid__sort {
41
+ display: flex;
42
+ flex-wrap: wrap;
43
+ gap: 0.5rem;
44
+ }
45
+
46
+ .beauty-shop-grid__sort-pill {
47
+ display: inline-flex;
48
+ align-items: center;
49
+ padding: 0.5rem 0.875rem;
50
+ border: 1px solid #d8d8d8;
51
+ background: #ffffff;
52
+ font-size: 0.6875rem;
53
+ font-weight: 600;
54
+ letter-spacing: 0.08em;
55
+ text-transform: uppercase;
56
+ text-decoration: none;
57
+ color: var(--color-text-body, #4d4d4d);
58
+ transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
59
+ }
60
+
61
+ .beauty-shop-grid__sort-pill:hover {
62
+ border-color: #000000;
63
+ color: var(--color-text-heading, #0a0a0a);
64
+ }
65
+
66
+ .beauty-shop-grid__sort-pill--active {
67
+ background: #000000;
68
+ border-color: #000000;
69
+ color: #ffffff;
70
+ }
71
+
72
+ .beauty-shop-grid__progress {
73
+ height: 2px;
74
+ margin-bottom: 1.5rem;
75
+ background: #f0f0f0;
76
+ overflow: hidden;
77
+ }
78
+
79
+ .beauty-shop-grid__progress-fill {
80
+ display: block;
81
+ height: 100%;
82
+ background: linear-gradient(90deg, #8b3a62 0%, #c94b7a 100%);
83
+ transition: width 0.35s ease;
84
+ }
85
+
86
+ .beauty-shop-grid__grid {
87
+ display: grid;
88
+ grid-template-columns: repeat(2, minmax(0, 1fr));
89
+ gap: 1rem 0.875rem;
90
+ }
91
+
92
+ @media (min-width: 768px) {
93
+ .beauty-shop-grid__grid {
94
+ grid-template-columns: repeat(3, minmax(0, 1fr));
95
+ gap: 1.5rem 1.25rem;
96
+ }
97
+ }
98
+
99
+ .beauty-shop-grid__empty {
100
+ padding: 4rem 1.5rem;
101
+ text-align: center;
102
+ border: 1px solid #ebebeb;
103
+ background: linear-gradient(180deg, #faf7f5 0%, #ffffff 100%);
104
+ }
105
+
106
+ .beauty-shop-grid__empty p {
107
+ margin: 0 0 1.25rem;
108
+ font-size: 0.9375rem;
109
+ color: var(--color-text-body, #4d4d4d);
110
+ }
111
+
112
+ .beauty-shop-grid__empty-cta {
113
+ display: inline-flex;
114
+ min-height: 2.75rem;
115
+ padding: 0 1.5rem;
116
+ align-items: center;
117
+ justify-content: center;
118
+ text-decoration: none;
119
+ }
120
+
121
+ .beauty-shop-grid__footer {
122
+ display: flex;
123
+ flex-direction: column;
124
+ align-items: center;
125
+ gap: 0.75rem;
126
+ margin-top: 2rem;
127
+ min-height: 3rem;
128
+ }
129
+
130
+ .beauty-shop-grid__loading {
131
+ display: inline-flex;
132
+ align-items: center;
133
+ gap: 0.625rem;
134
+ font-size: 0.75rem;
135
+ font-weight: 600;
136
+ letter-spacing: 0.1em;
137
+ text-transform: uppercase;
138
+ color: var(--color-text-muted, #737373);
139
+ }
140
+
141
+ .beauty-shop-grid__spinner {
142
+ width: 1rem;
143
+ height: 1rem;
144
+ border: 2px solid #e0e0e0;
145
+ border-top-color: var(--color-brand-accent, #8b3a62);
146
+ border-radius: 50%;
147
+ animation: beauty-shop-spin 0.7s linear infinite;
148
+ }
149
+
150
+ @keyframes beauty-shop-spin {
151
+ to {
152
+ transform: rotate(360deg);
153
+ }
154
+ }
155
+
156
+ .beauty-shop-grid__error {
157
+ text-align: center;
158
+ }
159
+
160
+ .beauty-shop-grid__error p {
161
+ margin: 0 0 0.5rem;
162
+ font-size: 0.875rem;
163
+ color: var(--color-brand-sale, #c1272d);
164
+ }
165
+
166
+ .beauty-shop-grid__retry {
167
+ padding: 0;
168
+ border: none;
169
+ background: none;
170
+ font-size: 0.75rem;
171
+ font-weight: 600;
172
+ letter-spacing: 0.08em;
173
+ text-transform: uppercase;
174
+ text-decoration: underline;
175
+ text-underline-offset: 0.2rem;
176
+ color: var(--color-text-heading, #0a0a0a);
177
+ cursor: pointer;
178
+ }
179
+
180
+ .beauty-shop-grid__end {
181
+ margin: 0;
182
+ font-size: 0.75rem;
183
+ font-weight: 500;
184
+ letter-spacing: 0.06em;
185
+ text-transform: uppercase;
186
+ color: var(--color-text-muted, #737373);
187
+ }
188
+
189
+ .beauty-shop-grid__sentinel {
190
+ width: 100%;
191
+ height: 1px;
192
+ }
@@ -0,0 +1,255 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
4
+ import { useSearchParams } from "next/navigation"
5
+ import ProductCard from "@pradip1995/segment-product-card"
6
+ import LocalizedLink from "@pradip1995/segment-primitives/localized-link"
7
+ import type { HttpTypes } from "@medusajs/types"
8
+ import { loadMoreStoreProducts, type StoreLoadMoreFilters } from "./load-more-products"
9
+ import "./beauty-shop-grid.css"
10
+
11
+ const PAGE_SIZE = 12
12
+
13
+ const SORT_OPTIONS = [
14
+ { value: "created_at_desc", label: "Newest" },
15
+ { value: "price_asc", label: "Price · Low to high" },
16
+ { value: "price_desc", label: "Price · High to low" },
17
+ { value: "bestsellers", label: "Bestsellers" },
18
+ ]
19
+
20
+ function paramValue(value?: string | string[]): string | undefined {
21
+ if (value === undefined) return undefined
22
+ return Array.isArray(value) ? value.join(",") : value
23
+ }
24
+
25
+ function productKey(product: HttpTypes.StoreProduct, index: number): string {
26
+ return product.id ?? product.handle ?? `product-${index}`
27
+ }
28
+
29
+ export default function BeautyShopGrid(props: {
30
+ countryCode?: string
31
+ products?: HttpTypes.StoreProduct[]
32
+ productCount?: number
33
+ page?: string
34
+ sortBy?: string
35
+ q?: string
36
+ category?: string | string[]
37
+ collection?: string | string[]
38
+ gender?: string | string[]
39
+ type?: string | string[]
40
+ material?: string | string[]
41
+ color?: string | string[]
42
+ minPrice?: string
43
+ maxPrice?: string
44
+ title?: string
45
+ emptyMessage?: string
46
+ }) {
47
+ const searchParams = useSearchParams()
48
+ const countryCode =
49
+ props.countryCode?.toLowerCase() ??
50
+ process.env.NEXT_PUBLIC_DEFAULT_REGION?.toLowerCase() ??
51
+ "in"
52
+
53
+ const initialPage = props.page ? parseInt(props.page, 10) : 1
54
+ const sortBy = props.sortBy || "created_at_desc"
55
+ const title = props.title || "All beauty"
56
+ const emptyMessage =
57
+ props.emptyMessage ||
58
+ "No products match your filters. Try adjusting your selection or browse the full collection."
59
+
60
+ const filters: StoreLoadMoreFilters = useMemo(
61
+ () => ({
62
+ sortBy,
63
+ q: props.q,
64
+ category: paramValue(props.category),
65
+ collection: paramValue(props.collection),
66
+ gender: paramValue(props.gender),
67
+ type: paramValue(props.type),
68
+ material: paramValue(props.material),
69
+ color: paramValue(props.color),
70
+ minPrice: props.minPrice,
71
+ maxPrice: props.maxPrice,
72
+ }),
73
+ [
74
+ sortBy,
75
+ props.q,
76
+ props.category,
77
+ props.collection,
78
+ props.gender,
79
+ props.type,
80
+ props.material,
81
+ props.color,
82
+ props.minPrice,
83
+ props.maxPrice,
84
+ ]
85
+ )
86
+
87
+ const filterSignature = useMemo(() => JSON.stringify(filters), [filters])
88
+ const initialProducts = props.products ?? []
89
+ const totalCount = props.productCount ?? initialProducts.length
90
+
91
+ const [items, setItems] = useState(initialProducts)
92
+ const [currentPage, setCurrentPage] = useState(initialPage)
93
+ const [hasMore, setHasMore] = useState(
94
+ sortBy !== "bestsellers" && totalCount > initialPage * PAGE_SIZE
95
+ )
96
+ const [loadError, setLoadError] = useState<string | null>(null)
97
+ const [pending, startTransition] = useTransition()
98
+
99
+ const sentinelRef = useRef<HTMLDivElement | null>(null)
100
+ const loadingRef = useRef(false)
101
+
102
+ useEffect(() => {
103
+ setItems(initialProducts)
104
+ setCurrentPage(initialPage)
105
+ setHasMore(sortBy !== "bestsellers" && totalCount > initialPage * PAGE_SIZE)
106
+ setLoadError(null)
107
+ loadingRef.current = false
108
+ }, [filterSignature, initialProducts, initialPage, sortBy, totalCount])
109
+
110
+ const buildSortHref = (value: string) => {
111
+ const params = new URLSearchParams(searchParams?.toString() ?? "")
112
+ params.set("sortBy", value)
113
+ params.delete("page")
114
+ const query = params.toString()
115
+ return query ? `/store?${query}` : "/store"
116
+ }
117
+
118
+ const loadNextPage = useCallback(() => {
119
+ if (loadingRef.current || pending || !hasMore || sortBy === "bestsellers") return
120
+
121
+ loadingRef.current = true
122
+ const nextPage = currentPage + 1
123
+
124
+ startTransition(async () => {
125
+ try {
126
+ const result = await loadMoreStoreProducts(countryCode, nextPage, filters)
127
+ setItems((prev) => {
128
+ const seen = new Set(prev.map((p) => p.id).filter(Boolean))
129
+ const merged = [...prev]
130
+ for (const product of result.products) {
131
+ if (product.id && seen.has(product.id)) continue
132
+ merged.push(product)
133
+ }
134
+ return merged
135
+ })
136
+ setCurrentPage(nextPage)
137
+ setHasMore(result.hasMore)
138
+ setLoadError(null)
139
+ } catch {
140
+ setLoadError("Could not load more products. Scroll to try again.")
141
+ } finally {
142
+ loadingRef.current = false
143
+ }
144
+ })
145
+ }, [countryCode, currentPage, filters, hasMore, pending, sortBy])
146
+
147
+ useEffect(() => {
148
+ const node = sentinelRef.current
149
+ if (!node || !hasMore) return
150
+
151
+ const observer = new IntersectionObserver(
152
+ (entries) => {
153
+ if (entries[0]?.isIntersecting) loadNextPage()
154
+ },
155
+ { rootMargin: "240px 0px" }
156
+ )
157
+
158
+ observer.observe(node)
159
+ return () => observer.disconnect()
160
+ }, [hasMore, loadNextPage])
161
+
162
+ const showingCount = items.length
163
+ const progressPct = totalCount > 0 ? Math.min(100, (showingCount / totalCount) * 100) : 100
164
+
165
+ return (
166
+ <section className="beauty-shop-grid flex-1 min-w-0" aria-label="Product results">
167
+ <header className="beauty-shop-grid__header">
168
+ <div className="beauty-shop-grid__intro">
169
+ <p className="beauty-shop-grid__eyebrow">Shop Lumière</p>
170
+ <h1 className="beauty-shop-grid__title">{title}</h1>
171
+ {props.q ? (
172
+ <p className="beauty-shop-grid__search">
173
+ Results for &ldquo;{props.q}&rdquo;
174
+ </p>
175
+ ) : null}
176
+ {totalCount > 0 ? (
177
+ <p className="beauty-shop-grid__count">
178
+ {showingCount} of {totalCount} {totalCount === 1 ? "product" : "products"}
179
+ </p>
180
+ ) : null}
181
+ </div>
182
+
183
+ <div className="beauty-shop-grid__sort" role="group" aria-label="Sort products">
184
+ {SORT_OPTIONS.map((opt) => {
185
+ const active = sortBy === opt.value
186
+ return (
187
+ <LocalizedLink
188
+ key={opt.value}
189
+ href={buildSortHref(opt.value)}
190
+ className={`beauty-shop-grid__sort-pill${active ? " beauty-shop-grid__sort-pill--active" : ""}`}
191
+ countryCode={countryCode}
192
+ >
193
+ {opt.label}
194
+ </LocalizedLink>
195
+ )
196
+ })}
197
+ </div>
198
+ </header>
199
+
200
+ {totalCount > 0 && sortBy !== "bestsellers" ? (
201
+ <div className="beauty-shop-grid__progress" aria-hidden>
202
+ <span className="beauty-shop-grid__progress-fill" style={{ width: `${progressPct}%` }} />
203
+ </div>
204
+ ) : null}
205
+
206
+ {items.length === 0 ? (
207
+ <div className="beauty-shop-grid__empty">
208
+ <p>{emptyMessage}</p>
209
+ <LocalizedLink href="/store" className="beauty-shop-grid__empty-cta btn-primary" countryCode={countryCode}>
210
+ View all beauty
211
+ </LocalizedLink>
212
+ </div>
213
+ ) : (
214
+ <>
215
+ <div className="beauty-shop-grid__grid">
216
+ {items.map((product, index) => (
217
+ <ProductCard
218
+ key={productKey(product, index)}
219
+ product={product}
220
+ countryCode={countryCode}
221
+ variant="beauty"
222
+ />
223
+ ))}
224
+ </div>
225
+
226
+ <div className="beauty-shop-grid__footer">
227
+ {pending ? (
228
+ <div className="beauty-shop-grid__loading" role="status" aria-live="polite">
229
+ <span className="beauty-shop-grid__spinner" aria-hidden />
230
+ Loading more…
231
+ </div>
232
+ ) : null}
233
+
234
+ {loadError ? (
235
+ <div className="beauty-shop-grid__error">
236
+ <p>{loadError}</p>
237
+ <button type="button" className="beauty-shop-grid__retry" onClick={loadNextPage}>
238
+ Try again
239
+ </button>
240
+ </div>
241
+ ) : null}
242
+
243
+ {!hasMore && items.length > 0 ? (
244
+ <p className="beauty-shop-grid__end">
245
+ You&apos;ve seen all {totalCount} {totalCount === 1 ? "product" : "products"}
246
+ </p>
247
+ ) : null}
248
+
249
+ {hasMore ? <div ref={sentinelRef} className="beauty-shop-grid__sentinel" aria-hidden /> : null}
250
+ </div>
251
+ </>
252
+ )}
253
+ </section>
254
+ )
255
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
@@ -0,0 +1,82 @@
1
+ "use server"
2
+
3
+ import { getProductsByTag } from "@pradip1995/commerce-core/data/products"
4
+ import { listStoreProductsWithSort } from "@pradip1995/medusa-connector"
5
+ import type { HttpTypes } from "@medusajs/types"
6
+
7
+ const PAGE_SIZE = 12
8
+
9
+ export type StoreLoadMoreFilters = {
10
+ sortBy?: string
11
+ category?: string
12
+ collection?: string
13
+ q?: string
14
+ gender?: string
15
+ type?: string
16
+ material?: string
17
+ color?: string
18
+ minPrice?: string
19
+ maxPrice?: string
20
+ }
21
+
22
+ function buildQueryParams(filters: StoreLoadMoreFilters): HttpTypes.FindParams &
23
+ HttpTypes.StoreProductListParams &
24
+ Record<string, unknown> {
25
+ const queryParams: HttpTypes.FindParams &
26
+ HttpTypes.StoreProductListParams &
27
+ Record<string, unknown> = {
28
+ limit: PAGE_SIZE,
29
+ }
30
+
31
+ if (filters.q) queryParams.q = filters.q
32
+ if (filters.collection) queryParams.collection_id = filters.collection
33
+ if (filters.category) queryParams.category_id = filters.category
34
+
35
+ const sortBy = filters.sortBy || "created_at_desc"
36
+ if (sortBy && sortBy !== "bestsellers") {
37
+ queryParams.order = sortBy === "created_at" ? "created_at_desc" : sortBy
38
+ }
39
+
40
+ if (filters.gender) queryParams.gender = filters.gender
41
+ if (filters.type) queryParams.type = filters.type
42
+ if (filters.material) queryParams.material = filters.material
43
+ if (filters.color) queryParams.color = filters.color
44
+ if (filters.minPrice) queryParams.min_price = filters.minPrice
45
+ if (filters.maxPrice) queryParams.max_price = filters.maxPrice
46
+
47
+ return queryParams
48
+ }
49
+
50
+ export async function loadMoreStoreProducts(
51
+ countryCode: string,
52
+ page: number,
53
+ filters: StoreLoadMoreFilters
54
+ ): Promise<{ products: HttpTypes.StoreProduct[]; count: number; hasMore: boolean }> {
55
+ const sortBy = filters.sortBy || "created_at_desc"
56
+
57
+ if (sortBy === "bestsellers") {
58
+ const products = await getProductsByTag({
59
+ tagValue: "bestseller",
60
+ limit: PAGE_SIZE,
61
+ countryCode,
62
+ }).catch(() => [])
63
+
64
+ return { products, count: products.length, hasMore: false }
65
+ }
66
+
67
+ const { response } = await listStoreProductsWithSort({
68
+ page,
69
+ sortBy,
70
+ countryCode,
71
+ queryParams: buildQueryParams(filters),
72
+ }).catch(() => ({ response: { products: [], count: 0 } }))
73
+
74
+ const count = response.count ?? 0
75
+ const hasMore = count > page * PAGE_SIZE
76
+
77
+ return {
78
+ products: response.products ?? [],
79
+ count,
80
+ hasMore,
81
+ }
82
+ }
@@ -0,0 +1,11 @@
1
+ import type { SegmentManifest } from "@pradip1995/plugin-sdk"
2
+
3
+ const manifest: SegmentManifest = {
4
+ id: "beauty-shop-grid",
5
+ type: "segment",
6
+ version: "0.1.0",
7
+ compatibleFramework: ["^1.0.0"],
8
+ dataKey: "store",
9
+ }
10
+
11
+ export default manifest
@@ -0,0 +1 @@
1
+ export { default } from "./beauty-shop-grid"