@pradip1995/segment-animation-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,42 @@
1
+ {
2
+ "name": "@pradip1995/segment-animation-shop-grid",
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "sideEffects": [
9
+ "./src/animation-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.4.0",
28
+ "@pradip1995/segment-product-card": "^0.3.10",
29
+ "@pradip1995/segment-tokens": "^0.3.7",
30
+ "framer-motion": "^12.0.0"
31
+ },
32
+ "devDependencies": {
33
+ "@pradip1995/plugin-sdk": "^0.2.0",
34
+ "@types/react": "^19",
35
+ "react": "19.0.3",
36
+ "typescript": "^5.7.2"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "lint": "tsc --noEmit"
41
+ }
42
+ }
@@ -0,0 +1,315 @@
1
+ .animation-shop-grid {
2
+ width: 100%;
3
+ padding: 2.5rem 1.5rem 4rem 1.5rem;
4
+ background-color: var(--page-bg, #ffffff);
5
+ color: var(--text-color, #111111);
6
+ font-family: inherit;
7
+ box-sizing: border-box;
8
+ }
9
+
10
+ @media (min-width: 1024px) {
11
+ .animation-shop-grid {
12
+ padding: 3.5rem 3rem 6rem 3rem;
13
+ }
14
+ }
15
+
16
+ .animation-shop-grid__header {
17
+ display: flex;
18
+ flex-direction: column;
19
+ gap: 1.5rem;
20
+ margin-bottom: 2rem;
21
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08);
22
+ padding-bottom: 2rem;
23
+ }
24
+
25
+ @media (min-width: 768px) {
26
+ .animation-shop-grid__header {
27
+ flex-direction: row;
28
+ align-items: flex-end;
29
+ justify-content: space-between;
30
+ }
31
+ }
32
+
33
+ .animation-shop-grid__intro {
34
+ display: flex;
35
+ flex-direction: column;
36
+ gap: 0.35rem;
37
+ }
38
+
39
+ .animation-shop-grid__eyebrow {
40
+ font-size: 0.75rem;
41
+ letter-spacing: 0.15em;
42
+ text-transform: uppercase;
43
+ font-weight: 600;
44
+ color: #666666;
45
+ }
46
+
47
+ .animation-shop-grid__title {
48
+ font-size: 2rem;
49
+ font-weight: 700;
50
+ letter-spacing: -0.02em;
51
+ line-height: 1.15;
52
+ color: #000000;
53
+ margin: 0;
54
+ text-transform: uppercase;
55
+ }
56
+
57
+ @media (min-width: 768px) {
58
+ .animation-shop-grid__title {
59
+ font-size: 2.5rem;
60
+ }
61
+ }
62
+
63
+ .animation-shop-grid__search {
64
+ font-size: 0.95rem;
65
+ color: #555555;
66
+ margin: 0.25rem 0 0 0;
67
+ font-style: italic;
68
+ }
69
+
70
+ .animation-shop-grid__count {
71
+ font-size: 0.85rem;
72
+ color: #777777;
73
+ margin-top: 0.25rem;
74
+ letter-spacing: 0.02em;
75
+ }
76
+
77
+ .animation-shop-grid__sort {
78
+ display: flex;
79
+ flex-wrap: wrap;
80
+ gap: 0.5rem;
81
+ align-items: center;
82
+ }
83
+
84
+ .animation-shop-grid__sort-pill {
85
+ font-size: 0.8rem;
86
+ font-weight: 600;
87
+ letter-spacing: 0.05em;
88
+ text-transform: uppercase;
89
+ padding: 0.5rem 1rem;
90
+ border-radius: 9999px;
91
+ border: 1px solid rgba(0, 0, 0, 0.15);
92
+ background-color: transparent;
93
+ color: #333333;
94
+ text-decoration: none;
95
+ transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
96
+ display: inline-flex;
97
+ align-items: center;
98
+ justify-content: center;
99
+ }
100
+
101
+ .animation-shop-grid__sort-pill:hover {
102
+ border-color: #000000;
103
+ color: #000000;
104
+ background-color: rgba(0, 0, 0, 0.03);
105
+ }
106
+
107
+ .animation-shop-grid__sort-pill--active {
108
+ background-color: #000000;
109
+ color: #ffffff !important;
110
+ border-color: #000000;
111
+ }
112
+
113
+ .animation-shop-grid__applied {
114
+ display: flex;
115
+ flex-wrap: wrap;
116
+ align-items: center;
117
+ gap: 0.75rem;
118
+ margin-bottom: 2rem;
119
+ padding: 0.75rem 1rem;
120
+ background-color: rgba(0, 0, 0, 0.02);
121
+ border: 1px solid rgba(0, 0, 0, 0.06);
122
+ border-radius: 8px;
123
+ }
124
+
125
+ .animation-shop-grid__applied-label {
126
+ font-size: 0.75rem;
127
+ font-weight: 700;
128
+ letter-spacing: 0.1em;
129
+ text-transform: uppercase;
130
+ color: #888888;
131
+ }
132
+
133
+ .animation-shop-grid__applied-list {
134
+ display: flex;
135
+ flex-wrap: wrap;
136
+ gap: 0.5rem;
137
+ list-style: none;
138
+ margin: 0;
139
+ padding: 0;
140
+ }
141
+
142
+ .animation-shop-grid__applied-chip {
143
+ display: inline-flex;
144
+ align-items: center;
145
+ gap: 0.35rem;
146
+ font-size: 0.8rem;
147
+ font-weight: 500;
148
+ background-color: #ffffff;
149
+ border: 1px solid rgba(0, 0, 0, 0.12);
150
+ padding: 0.3rem 0.65rem;
151
+ border-radius: 4px;
152
+ color: #111111;
153
+ text-decoration: none;
154
+ transition: border-color 0.15s ease, background-color 0.15s ease;
155
+ }
156
+
157
+ .animation-shop-grid__applied-chip:hover {
158
+ border-color: #000000;
159
+ background-color: #f8f8f8;
160
+ }
161
+
162
+ .animation-shop-grid__applied-x {
163
+ font-size: 1.1rem;
164
+ line-height: 0.8;
165
+ color: #777777;
166
+ }
167
+
168
+ .animation-shop-grid__applied-clear {
169
+ font-size: 0.75rem;
170
+ font-weight: 600;
171
+ letter-spacing: 0.05em;
172
+ text-transform: uppercase;
173
+ color: #e23b00;
174
+ text-decoration: underline;
175
+ margin-left: auto;
176
+ cursor: pointer;
177
+ }
178
+
179
+ .animation-shop-grid__applied-clear:hover {
180
+ color: #b02e00;
181
+ }
182
+
183
+ .animation-shop-grid__progress {
184
+ width: 100%;
185
+ height: 2px;
186
+ background-color: rgba(0, 0, 0, 0.08);
187
+ border-radius: 2px;
188
+ overflow: hidden;
189
+ margin-bottom: 2.5rem;
190
+ }
191
+
192
+ .animation-shop-grid__progress-fill {
193
+ display: block;
194
+ height: 100%;
195
+ background-color: #000000;
196
+ transition: width 0.4s cubic-bezier(0.16, 1, 0.3, 1);
197
+ }
198
+
199
+ .animation-shop-grid__grid {
200
+ display: grid;
201
+ grid-template-columns: repeat(2, 1fr);
202
+ gap: 1.25rem 1rem;
203
+ }
204
+
205
+ @media (min-width: 640px) {
206
+ .animation-shop-grid__grid {
207
+ grid-template-columns: repeat(3, 1fr);
208
+ gap: 2rem 1.5rem;
209
+ }
210
+ }
211
+
212
+ @media (min-width: 1024px) {
213
+ .animation-shop-grid__grid {
214
+ grid-template-columns: repeat(4, 1fr);
215
+ gap: 2.5rem 1.75rem;
216
+ }
217
+ }
218
+
219
+ .animation-shop-grid__empty {
220
+ text-align: center;
221
+ padding: 4rem 1rem;
222
+ max-width: 480px;
223
+ margin: 0 auto;
224
+ }
225
+
226
+ .animation-shop-grid__empty p {
227
+ font-size: 1.05rem;
228
+ color: #555555;
229
+ margin-bottom: 1.5rem;
230
+ line-height: 1.5;
231
+ }
232
+
233
+ .animation-shop-grid__empty-cta {
234
+ display: inline-block;
235
+ padding: 0.85rem 2rem;
236
+ background-color: #000000;
237
+ color: #ffffff;
238
+ font-size: 0.85rem;
239
+ font-weight: 600;
240
+ letter-spacing: 0.1em;
241
+ text-transform: uppercase;
242
+ border-radius: 4px;
243
+ text-decoration: none;
244
+ transition: opacity 0.2s ease;
245
+ }
246
+
247
+ .animation-shop-grid__empty-cta:hover {
248
+ opacity: 0.85;
249
+ }
250
+
251
+ .animation-shop-grid__footer {
252
+ margin-top: 3.5rem;
253
+ display: flex;
254
+ flex-direction: column;
255
+ align-items: center;
256
+ justify-content: center;
257
+ gap: 1rem;
258
+ }
259
+
260
+ .animation-shop-grid__loading {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: 0.65rem;
264
+ font-size: 0.85rem;
265
+ font-weight: 500;
266
+ letter-spacing: 0.05em;
267
+ text-transform: uppercase;
268
+ color: #666666;
269
+ }
270
+
271
+ .animation-shop-grid__spinner {
272
+ width: 18px;
273
+ height: 18px;
274
+ border: 2px solid rgba(0, 0, 0, 0.15);
275
+ border-top-color: #000000;
276
+ border-radius: 50%;
277
+ animation: animation-shop-spin 0.75s linear infinite;
278
+ }
279
+
280
+ @keyframes animation-shop-spin {
281
+ to {
282
+ transform: rotate(360deg);
283
+ }
284
+ }
285
+
286
+ .animation-shop-grid__error {
287
+ text-align: center;
288
+ color: #d93025;
289
+ }
290
+
291
+ .animation-shop-grid__retry {
292
+ margin-top: 0.5rem;
293
+ font-size: 0.8rem;
294
+ font-weight: 600;
295
+ text-transform: uppercase;
296
+ padding: 0.4rem 1rem;
297
+ border: 1px solid #d93025;
298
+ background: transparent;
299
+ color: #d93025;
300
+ cursor: pointer;
301
+ border-radius: 4px;
302
+ }
303
+
304
+ .animation-shop-grid__end {
305
+ font-size: 0.85rem;
306
+ letter-spacing: 0.05em;
307
+ text-transform: uppercase;
308
+ color: #888888;
309
+ font-weight: 500;
310
+ }
311
+
312
+ .animation-shop-grid__sentinel {
313
+ height: 1px;
314
+ width: 100%;
315
+ }
@@ -0,0 +1,475 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
4
+ import { motion, AnimatePresence } from "framer-motion"
5
+ import ProductCard, { type ProductCardVariant } 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 "./animation-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
+ type StoreGridFilterProps = {
30
+ sortBy?: string
31
+ q?: string
32
+ category?: string | string[]
33
+ collection?: string | string[]
34
+ gender?: string | string[]
35
+ type?: string | string[]
36
+ material?: string | string[]
37
+ color?: string | string[]
38
+ minPrice?: string
39
+ maxPrice?: string
40
+ }
41
+
42
+ function buildStoreSearchParams(props: StoreGridFilterProps): URLSearchParams {
43
+ const params = new URLSearchParams()
44
+ params.set("sortBy", props.sortBy ?? "created_at_desc")
45
+
46
+ const category = paramValue(props.category)
47
+ if (category) params.set("category", category)
48
+
49
+ const collection = paramValue(props.collection)
50
+ if (collection) params.set("collection", collection)
51
+
52
+ if (props.q) params.set("q", props.q)
53
+
54
+ const gender = paramValue(props.gender)
55
+ if (gender) params.set("gender", gender)
56
+
57
+ const type = paramValue(props.type)
58
+ if (type) params.set("type", type)
59
+
60
+ const material = paramValue(props.material)
61
+ if (material) params.set("material", material)
62
+
63
+ const color = paramValue(props.color)
64
+ if (color) params.set("color", color)
65
+
66
+ if (props.minPrice) params.set("min_price", props.minPrice)
67
+ if (props.maxPrice) params.set("max_price", props.maxPrice)
68
+
69
+ return params
70
+ }
71
+
72
+ function buildSortHref(searchParams: URLSearchParams, sortValue: string): string {
73
+ const params = new URLSearchParams(searchParams.toString())
74
+ params.set("sortBy", sortValue)
75
+ params.delete("page")
76
+ const query = params.toString()
77
+ return query ? `/store?${query}` : "/store"
78
+ }
79
+
80
+ function toStoreHref(params: URLSearchParams): string {
81
+ const query = params.toString()
82
+ return query ? `/store?${query}` : "/store"
83
+ }
84
+
85
+ function buildClearParamHref(searchParams: URLSearchParams, key: string): string {
86
+ const params = new URLSearchParams(searchParams.toString())
87
+ params.delete(key)
88
+ params.delete("page")
89
+ return toStoreHref(params)
90
+ }
91
+
92
+ function buildClearAllFiltersHref(searchParams: URLSearchParams): string {
93
+ const params = new URLSearchParams(searchParams.toString())
94
+ for (const key of [
95
+ "category",
96
+ "collection",
97
+ "q",
98
+ "gender",
99
+ "type",
100
+ "material",
101
+ "color",
102
+ "min_price",
103
+ "max_price",
104
+ ]) {
105
+ params.delete(key)
106
+ }
107
+ params.delete("page")
108
+ return toStoreHref(params)
109
+ }
110
+
111
+ function matchLabel(
112
+ current: string | undefined,
113
+ items: { id: string; handle?: string | null; name?: string; title?: string }[]
114
+ ): string | undefined {
115
+ if (!current) return undefined
116
+ const hit = items.find(
117
+ (item) => item.id === current || (!!item.handle && item.handle === current)
118
+ )
119
+ return hit?.name || hit?.title || undefined
120
+ }
121
+
122
+ const containerVariants = {
123
+ hidden: { opacity: 0 },
124
+ visible: {
125
+ opacity: 1,
126
+ transition: {
127
+ staggerChildren: 0.05,
128
+ delayChildren: 0.05,
129
+ },
130
+ },
131
+ }
132
+
133
+ const cardVariants = {
134
+ hidden: { opacity: 0, y: 16 },
135
+ visible: {
136
+ opacity: 1,
137
+ y: 0,
138
+ transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] },
139
+ },
140
+ }
141
+
142
+ export default function AnimationShopGrid(props: {
143
+ countryCode?: string
144
+ products?: HttpTypes.StoreProduct[]
145
+ productCount?: number
146
+ page?: string
147
+ sortBy?: string
148
+ q?: string
149
+ category?: string | string[]
150
+ collection?: string | string[]
151
+ categories?: { id: string; name?: string; handle?: string | null }[]
152
+ collections?: { id: string; title?: string; handle?: string | null }[]
153
+ gender?: string | string[]
154
+ type?: string | string[]
155
+ material?: string | string[]
156
+ color?: string | string[]
157
+ minPrice?: string
158
+ maxPrice?: string
159
+ title?: string
160
+ eyebrow?: string
161
+ emptyMessage?: string
162
+ productCardVariant?: ProductCardVariant
163
+ }) {
164
+ const countryCode = props.countryCode?.toLowerCase() || "in"
165
+
166
+ const initialPage = props.page ? parseInt(props.page, 10) : 1
167
+ const sortBy = props.sortBy || "created_at_desc"
168
+ const title = props.title || "All Products"
169
+ const eyebrow = props.eyebrow || "STORE COLLECTION"
170
+ const emptyMessage =
171
+ props.emptyMessage ||
172
+ "No products match your filters. Try adjusting your selection or browse the full collection."
173
+
174
+ const filters: StoreLoadMoreFilters = useMemo(
175
+ () => ({
176
+ sortBy,
177
+ q: props.q,
178
+ category: paramValue(props.category),
179
+ collection: paramValue(props.collection),
180
+ gender: paramValue(props.gender),
181
+ type: paramValue(props.type),
182
+ material: paramValue(props.material),
183
+ color: paramValue(props.color),
184
+ minPrice: props.minPrice,
185
+ maxPrice: props.maxPrice,
186
+ }),
187
+ [
188
+ sortBy,
189
+ props.q,
190
+ props.category,
191
+ props.collection,
192
+ props.gender,
193
+ props.type,
194
+ props.material,
195
+ props.color,
196
+ props.minPrice,
197
+ props.maxPrice,
198
+ ]
199
+ )
200
+
201
+ const filterSignature = useMemo(() => JSON.stringify(filters), [filters])
202
+ const storeSearchParams = useMemo(
203
+ () =>
204
+ buildStoreSearchParams({
205
+ sortBy,
206
+ q: props.q,
207
+ category: props.category,
208
+ collection: props.collection,
209
+ gender: props.gender,
210
+ type: props.type,
211
+ material: props.material,
212
+ color: props.color,
213
+ minPrice: props.minPrice,
214
+ maxPrice: props.maxPrice,
215
+ }),
216
+ [
217
+ sortBy,
218
+ props.q,
219
+ props.category,
220
+ props.collection,
221
+ props.gender,
222
+ props.type,
223
+ props.material,
224
+ props.color,
225
+ props.minPrice,
226
+ props.maxPrice,
227
+ ]
228
+ )
229
+ const initialProducts = props.products ?? []
230
+ const totalCount = props.productCount ?? initialProducts.length
231
+
232
+ const [items, setItems] = useState(initialProducts)
233
+ const [currentPage, setCurrentPage] = useState(initialPage)
234
+ const [hasMore, setHasMore] = useState(
235
+ sortBy !== "bestsellers" && totalCount > initialPage * PAGE_SIZE
236
+ )
237
+ const [loadError, setLoadError] = useState<string | null>(null)
238
+ const [pending, startTransition] = useTransition()
239
+
240
+ const sentinelRef = useRef<HTMLDivElement | null>(null)
241
+ const loadingRef = useRef(false)
242
+
243
+ useEffect(() => {
244
+ setItems(props.products ?? [])
245
+ setCurrentPage(initialPage)
246
+ setHasMore(sortBy !== "bestsellers" && totalCount > initialPage * PAGE_SIZE)
247
+ setLoadError(null)
248
+ loadingRef.current = false
249
+ }, [filterSignature, initialPage, sortBy, totalCount])
250
+
251
+ const loadNextPage = useCallback(() => {
252
+ if (loadingRef.current || pending || !hasMore || sortBy === "bestsellers") return
253
+
254
+ loadingRef.current = true
255
+ const nextPage = currentPage + 1
256
+
257
+ startTransition(async () => {
258
+ try {
259
+ const result = await loadMoreStoreProducts(countryCode, nextPage, filters)
260
+ setItems((prev) => {
261
+ const seen = new Set(prev.map((p) => p.id).filter(Boolean))
262
+ const merged = [...prev]
263
+ for (const product of result.products) {
264
+ if (product.id && seen.has(product.id)) continue
265
+ merged.push(product)
266
+ }
267
+ return merged
268
+ })
269
+ setCurrentPage(nextPage)
270
+ setHasMore(result.hasMore)
271
+ setLoadError(null)
272
+ } catch {
273
+ setLoadError("Could not load more products. Scroll to try again.")
274
+ } finally {
275
+ loadingRef.current = false
276
+ }
277
+ })
278
+ }, [countryCode, currentPage, filters, hasMore, pending, sortBy])
279
+
280
+ useEffect(() => {
281
+ const node = sentinelRef.current
282
+ if (!node || !hasMore) return
283
+
284
+ const observer = new IntersectionObserver(
285
+ (entries) => {
286
+ if (entries[0]?.isIntersecting) loadNextPage()
287
+ },
288
+ { rootMargin: "240px 0px" }
289
+ )
290
+
291
+ observer.observe(node)
292
+ return () => observer.disconnect()
293
+ }, [hasMore, loadNextPage])
294
+
295
+ const showingCount = items.length
296
+ const progressPct = totalCount > 0 ? Math.min(100, (showingCount / totalCount) * 100) : 100
297
+
298
+ const activeCategory = paramValue(props.category)
299
+ const activeCollection = paramValue(props.collection)
300
+ const categoryLabel =
301
+ matchLabel(activeCategory, props.categories || []) ||
302
+ (activeCategory ? "Category" : undefined)
303
+ const collectionLabel =
304
+ matchLabel(activeCollection, props.collections || []) ||
305
+ (activeCollection ? "Collection" : undefined)
306
+
307
+ const appliedFilters: { key: string; label: string; href: string }[] = []
308
+ if (props.q) {
309
+ appliedFilters.push({
310
+ key: "q",
311
+ label: `Search: ${props.q}`,
312
+ href: buildClearParamHref(storeSearchParams, "q"),
313
+ })
314
+ }
315
+ if (activeCategory && categoryLabel) {
316
+ appliedFilters.push({
317
+ key: "category",
318
+ label: categoryLabel,
319
+ href: buildClearParamHref(storeSearchParams, "category"),
320
+ })
321
+ }
322
+ if (activeCollection && collectionLabel) {
323
+ appliedFilters.push({
324
+ key: "collection",
325
+ label: collectionLabel,
326
+ href: buildClearParamHref(storeSearchParams, "collection"),
327
+ })
328
+ }
329
+
330
+ return (
331
+ <section className="animation-shop-grid flex-1 min-w-0" aria-label="Product results">
332
+ <motion.header
333
+ className="animation-shop-grid__header"
334
+ initial={{ opacity: 0, y: -10 }}
335
+ animate={{ opacity: 1, y: 0 }}
336
+ transition={{ duration: 0.5, ease: [0.16, 1, 0.3, 1] }}
337
+ >
338
+ <div className="animation-shop-grid__intro">
339
+ <p className="animation-shop-grid__eyebrow">{eyebrow}</p>
340
+ <h2 className="animation-shop-grid__title">{title}</h2>
341
+ {props.q ? (
342
+ <p className="animation-shop-grid__search">
343
+ Results for &ldquo;{props.q}&rdquo;
344
+ </p>
345
+ ) : null}
346
+ {totalCount > 0 ? (
347
+ <p className="animation-shop-grid__count">
348
+ Showing {showingCount} of {totalCount} {totalCount === 1 ? "product" : "products"}
349
+ </p>
350
+ ) : null}
351
+ </div>
352
+
353
+ <div className="animation-shop-grid__sort" role="group" aria-label="Sort products">
354
+ {SORT_OPTIONS.map((opt) => {
355
+ const active = sortBy === opt.value
356
+ return (
357
+ <LocalizedLink
358
+ key={opt.value}
359
+ href={buildSortHref(storeSearchParams, opt.value)}
360
+ className={`animation-shop-grid__sort-pill${active ? " animation-shop-grid__sort-pill--active" : ""}`}
361
+ countryCode={countryCode}
362
+ >
363
+ {opt.label}
364
+ </LocalizedLink>
365
+ )
366
+ })}
367
+ </div>
368
+ </motion.header>
369
+
370
+ <AnimatePresence>
371
+ {appliedFilters.length > 0 ? (
372
+ <motion.div
373
+ className="animation-shop-grid__applied"
374
+ role="group"
375
+ aria-label="Applied filters"
376
+ initial={{ opacity: 0, height: 0 }}
377
+ animate={{ opacity: 1, height: "auto" }}
378
+ exit={{ opacity: 0, height: 0 }}
379
+ >
380
+ <span className="animation-shop-grid__applied-label">Filters</span>
381
+ <ul className="animation-shop-grid__applied-list">
382
+ {appliedFilters.map((filter) => (
383
+ <li key={filter.key}>
384
+ <LocalizedLink
385
+ href={filter.href}
386
+ className="animation-shop-grid__applied-chip"
387
+ countryCode={countryCode}
388
+ aria-label={`Remove ${filter.label} filter`}
389
+ >
390
+ <span>{filter.label}</span>
391
+ <span className="animation-shop-grid__applied-x" aria-hidden>
392
+ Ă—
393
+ </span>
394
+ </LocalizedLink>
395
+ </li>
396
+ ))}
397
+ </ul>
398
+ <LocalizedLink
399
+ href={buildClearAllFiltersHref(storeSearchParams)}
400
+ className="animation-shop-grid__applied-clear"
401
+ countryCode={countryCode}
402
+ >
403
+ Clear all
404
+ </LocalizedLink>
405
+ </motion.div>
406
+ ) : null}
407
+ </AnimatePresence>
408
+
409
+ {totalCount > 0 && sortBy !== "bestsellers" ? (
410
+ <div className="animation-shop-grid__progress" aria-hidden>
411
+ <span className="animation-shop-grid__progress-fill" style={{ width: `${progressPct}%` }} />
412
+ </div>
413
+ ) : null}
414
+
415
+ {items.length === 0 ? (
416
+ <motion.div
417
+ className="animation-shop-grid__empty"
418
+ initial={{ opacity: 0, scale: 0.98 }}
419
+ animate={{ opacity: 1, scale: 1 }}
420
+ transition={{ duration: 0.4 }}
421
+ >
422
+ <p>{emptyMessage}</p>
423
+ <LocalizedLink href="/store" className="animation-shop-grid__empty-cta" countryCode={countryCode}>
424
+ Explore All Products
425
+ </LocalizedLink>
426
+ </motion.div>
427
+ ) : (
428
+ <>
429
+ <motion.div
430
+ className="animation-shop-grid__grid"
431
+ variants={containerVariants}
432
+ initial="hidden"
433
+ animate="visible"
434
+ >
435
+ {items.map((product, index) => (
436
+ <motion.div key={productKey(product, index)} variants={cardVariants}>
437
+ <ProductCard
438
+ product={product}
439
+ countryCode={countryCode}
440
+ variant={props.productCardVariant ?? "motion"}
441
+ />
442
+ </motion.div>
443
+ ))}
444
+ </motion.div>
445
+
446
+ <div className="animation-shop-grid__footer">
447
+ {pending ? (
448
+ <div className="animation-shop-grid__loading" role="status" aria-live="polite">
449
+ <span className="animation-shop-grid__spinner" aria-hidden />
450
+ Loading more products…
451
+ </div>
452
+ ) : null}
453
+
454
+ {loadError ? (
455
+ <div className="animation-shop-grid__error">
456
+ <p>{loadError}</p>
457
+ <button type="button" className="animation-shop-grid__retry" onClick={loadNextPage}>
458
+ Try again
459
+ </button>
460
+ </div>
461
+ ) : null}
462
+
463
+ {!hasMore && items.length > 0 ? (
464
+ <p className="animation-shop-grid__end">
465
+ You&apos;ve viewed all {totalCount} {totalCount === 1 ? "product" : "products"}
466
+ </p>
467
+ ) : null}
468
+
469
+ {hasMore ? <div ref={sentinelRef} className="animation-shop-grid__sentinel" aria-hidden /> : null}
470
+ </div>
471
+ </>
472
+ )}
473
+ </section>
474
+ )
475
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { default } from "./segment"
2
+ export { default as manifest } from "./manifest"
@@ -0,0 +1,83 @@
1
+ "use server"
2
+
3
+ import { listStoreProductsWithSort } from "@pradip1995/medusa-connector"
4
+ import type { HttpTypes } from "@medusajs/types"
5
+
6
+ const PAGE_SIZE = 12
7
+
8
+ export type StoreLoadMoreFilters = {
9
+ sortBy?: string
10
+ category?: string
11
+ collection?: string
12
+ q?: string
13
+ gender?: string
14
+ type?: string
15
+ material?: string
16
+ color?: string
17
+ minPrice?: string
18
+ maxPrice?: string
19
+ }
20
+
21
+ function buildQueryParams(filters: StoreLoadMoreFilters): HttpTypes.FindParams &
22
+ HttpTypes.StoreProductListParams &
23
+ Record<string, unknown> {
24
+ const queryParams: HttpTypes.FindParams &
25
+ HttpTypes.StoreProductListParams &
26
+ Record<string, unknown> = {
27
+ limit: PAGE_SIZE,
28
+ }
29
+
30
+ if (filters.q) queryParams.q = filters.q
31
+ if (filters.collection) queryParams.collection_id = filters.collection
32
+ if (filters.category) queryParams.category_id = filters.category
33
+
34
+ const sortBy = filters.sortBy || "created_at_desc"
35
+ if (sortBy && sortBy !== "bestsellers") {
36
+ queryParams.order = sortBy === "created_at" ? "created_at_desc" : sortBy
37
+ }
38
+
39
+ if (filters.gender) queryParams.gender = filters.gender
40
+ if (filters.type) queryParams.type = filters.type
41
+ if (filters.material) queryParams.material = filters.material
42
+ if (filters.color) queryParams.color = filters.color
43
+ if (filters.minPrice) queryParams.min_price = filters.minPrice
44
+ if (filters.maxPrice) queryParams.max_price = filters.maxPrice
45
+
46
+ return queryParams
47
+ }
48
+
49
+ export async function loadMoreStoreProducts(
50
+ countryCode: string,
51
+ page: number,
52
+ filters: StoreLoadMoreFilters
53
+ ): Promise<{ products: HttpTypes.StoreProduct[]; count: number; hasMore: boolean }> {
54
+ const sortBy = filters.sortBy || "created_at_desc"
55
+
56
+ if (sortBy === "bestsellers") {
57
+ const { response } = await listStoreProductsWithSort({
58
+ page,
59
+ sortBy: "created_at_desc",
60
+ countryCode,
61
+ queryParams: { limit: PAGE_SIZE, tags: ["bestseller"] },
62
+ }).catch(() => ({ response: { products: [], count: 0 } }))
63
+
64
+ const products = response.products ?? []
65
+ return { products, count: products.length, hasMore: false }
66
+ }
67
+
68
+ const { response } = await listStoreProductsWithSort({
69
+ page,
70
+ sortBy,
71
+ countryCode,
72
+ queryParams: buildQueryParams(filters),
73
+ }).catch(() => ({ response: { products: [], count: 0 } }))
74
+
75
+ const count = response.count ?? 0
76
+ const hasMore = count > page * PAGE_SIZE
77
+
78
+ return {
79
+ products: response.products ?? [],
80
+ count,
81
+ hasMore,
82
+ }
83
+ }
@@ -0,0 +1,4 @@
1
+ export default {
2
+ name: "@pradip1995/segment-animation-shop-grid",
3
+ type: "segment",
4
+ }
@@ -0,0 +1,3 @@
1
+ import AnimationShopGrid from "./animation-shop-grid"
2
+
3
+ export default AnimationShopGrid