Skip to content

Commit a5532e2

Browse files
Cichorekclaude
andauthored
Add Swiper carousel for products display (#26)
* INIT products carousel * FIX coderabbit comments * MOD Move fetching products to hooks * FIX a11y and stale fetch cleanup in ProductCarousel - Add disabled attribute to carousel nav buttons when at beginning/end so they are removed from tab order and accessibility tree - Hoist cancelled flag in useCarouselProducts useEffect so cleanup always runs, preventing stale setState from in-flight fetches Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * FIX carousel navigation ref --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent de8da87 commit a5532e2

File tree

7 files changed

+401
-266
lines changed

7 files changed

+401
-266
lines changed

package-lock.json

Lines changed: 201 additions & 184 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"next": "^16",
2323
"react": "^19",
2424
"react-dom": "^19",
25-
"react-svg-credit-card-payment-icons": "^5.1.2"
25+
"react-svg-credit-card-payment-icons": "^5.1.2",
26+
"swiper": "^12.1.2"
2627
},
2728
"overrides": {
2829
"minimatch": "^10.2.1"

src/app/[country]/[locale]/(storefront)/FeaturedProducts.tsx

Lines changed: 0 additions & 79 deletions
This file was deleted.

src/app/[country]/[locale]/(storefront)/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Link from "next/link";
22
import { CheckIcon, LightningBoltIcon, SupportIcon } from "@/components/icons";
3-
import { FeaturedProducts } from "./FeaturedProducts";
3+
import { ProductCarousel } from "@/components/products/ProductCarousel";
44

55
interface HomePageProps {
66
params: Promise<{
@@ -57,7 +57,7 @@ export default async function HomePage({ params }: HomePageProps) {
5757
View all &rarr;
5858
</Link>
5959
</div>
60-
<FeaturedProducts basePath={basePath} />
60+
<ProductCarousel basePath={basePath} />
6161
</section>
6262

6363
{/* Features Section */}

src/app/globals.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,9 @@ body {
4141
font-weight: 400;
4242
}
4343

44+
/* Hide default Swiper navigation (using custom buttons) */
45+
.product-carousel .swiper-button-next,
46+
.product-carousel .swiper-button-prev {
47+
display: none;
48+
}
49+
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"use client";
2+
3+
import type { ReactElement } from "react";
4+
import { useCallback, useRef, useState } from "react";
5+
import type Swiper from "swiper";
6+
import { Navigation } from "swiper/modules";
7+
import { Swiper as SwiperComponent, SwiperSlide } from "swiper/react";
8+
import "swiper/css";
9+
import "swiper/css/navigation";
10+
import { ChevronLeftIcon, ChevronRightIcon } from "@/components/icons";
11+
import { ProductCard } from "@/components/products/ProductCard";
12+
import { useCarouselProducts } from "@/hooks/useCarouselProducts";
13+
14+
interface ProductCarouselProps {
15+
taxonId?: string;
16+
limit?: number;
17+
basePath: string;
18+
}
19+
20+
const NAV_BUTTON_BASE =
21+
"absolute top-1/2 -translate-y-1/2 z-10 w-10 h-10 flex items-center justify-center cursor-pointer rounded-full bg-white border border-gray-300 text-gray-600 shadow-md hover:bg-gray-100 hover:text-gray-900 transition-colors";
22+
23+
export function ProductCarousel({
24+
taxonId,
25+
limit = 8,
26+
basePath,
27+
}: ProductCarouselProps): ReactElement {
28+
const { products, loading, error } = useCarouselProducts({ taxonId, limit });
29+
const [isBeginning, setIsBeginning] = useState(true);
30+
const [isEnd, setIsEnd] = useState(false);
31+
32+
const prevRef = useRef<HTMLButtonElement>(null);
33+
const nextRef = useRef<HTMLButtonElement>(null);
34+
35+
const handleBeforeInit = useCallback((swiper: Swiper) => {
36+
if (typeof swiper.params.navigation === "object") {
37+
swiper.params.navigation.prevEl = prevRef.current;
38+
swiper.params.navigation.nextEl = nextRef.current;
39+
}
40+
}, []);
41+
42+
const updateNavState = useCallback((swiper: Swiper) => {
43+
setIsBeginning(swiper.isBeginning);
44+
setIsEnd(swiper.isEnd);
45+
}, []);
46+
47+
if (loading) {
48+
return (
49+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
50+
{[...Array(4)].map((_, i) => (
51+
<div key={i} className="animate-pulse">
52+
<div className="aspect-square bg-gray-200 rounded-xl mb-4" />
53+
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
54+
<div className="h-4 bg-gray-200 rounded w-1/4" />
55+
</div>
56+
))}
57+
</div>
58+
);
59+
}
60+
61+
if (error) {
62+
return (
63+
<div className="text-center py-12">
64+
<p className="text-red-500">{error}</p>
65+
</div>
66+
);
67+
}
68+
69+
if (products.length === 0) {
70+
return (
71+
<div className="text-center py-12">
72+
<p className="text-gray-500">No products found.</p>
73+
</div>
74+
);
75+
}
76+
77+
return (
78+
<div className="relative">
79+
<button
80+
ref={prevRef}
81+
type="button"
82+
aria-label="Previous products"
83+
disabled={isBeginning}
84+
className={`${NAV_BUTTON_BASE} -left-5 ${isBeginning ? "opacity-0" : ""}`}
85+
>
86+
<ChevronLeftIcon className="w-5 h-5" />
87+
</button>
88+
<button
89+
ref={nextRef}
90+
type="button"
91+
aria-label="Next products"
92+
disabled={isEnd}
93+
className={`${NAV_BUTTON_BASE} -right-5 ${isEnd ? "opacity-0" : ""}`}
94+
>
95+
<ChevronRightIcon className="w-5 h-5" />
96+
</button>
97+
<SwiperComponent
98+
modules={[Navigation]}
99+
spaceBetween={24}
100+
slidesPerView={1}
101+
navigation={{
102+
prevEl: prevRef.current,
103+
nextEl: nextRef.current,
104+
}}
105+
onBeforeInit={handleBeforeInit}
106+
onSlideChange={updateNavState}
107+
onReachBeginning={updateNavState}
108+
onReachEnd={updateNavState}
109+
onAfterInit={updateNavState}
110+
breakpoints={{
111+
640: { slidesPerView: 2, spaceBetween: 24 },
112+
768: { slidesPerView: 3, spaceBetween: 24 },
113+
1024: { slidesPerView: 4, spaceBetween: 24 },
114+
}}
115+
className="product-carousel"
116+
>
117+
{products.map((product) => (
118+
<SwiperSlide key={product.id} className="p-1">
119+
<ProductCard product={product} basePath={basePath} />
120+
</SwiperSlide>
121+
))}
122+
</SwiperComponent>
123+
</div>
124+
);
125+
}

src/hooks/useCarouselProducts.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { StoreProduct } from "@spree/sdk";
2+
import { useEffect, useState } from "react";
3+
import { useStore } from "@/contexts/StoreContext";
4+
import { getProducts, getTaxonProducts } from "@/lib/data/products";
5+
6+
interface UseCarouselProductsOptions {
7+
taxonId?: string;
8+
limit?: number;
9+
}
10+
11+
interface UseCarouselProductsResult {
12+
products: StoreProduct[];
13+
loading: boolean;
14+
error: string | null;
15+
}
16+
17+
export function useCarouselProducts({
18+
taxonId,
19+
limit = 8,
20+
}: UseCarouselProductsOptions = {}): UseCarouselProductsResult {
21+
const { currency, locale, loading: storeLoading } = useStore();
22+
const [products, setProducts] = useState<StoreProduct[]>([]);
23+
const [loading, setLoading] = useState(true);
24+
const [error, setError] = useState<string | null>(null);
25+
26+
useEffect(() => {
27+
let cancelled = false;
28+
29+
if (!storeLoading) {
30+
const fetchProducts = async () => {
31+
setLoading(true);
32+
setError(null);
33+
try {
34+
const options = { currency, locale };
35+
const params = { per_page: limit };
36+
37+
const response = taxonId
38+
? await getTaxonProducts(taxonId, params, options)
39+
: await getProducts(params, options);
40+
41+
if (!cancelled) {
42+
setProducts(response.data);
43+
}
44+
} catch (err) {
45+
console.error("Failed to fetch carousel products:", err);
46+
if (!cancelled) {
47+
setError("Failed to load products. Please try again later.");
48+
}
49+
} finally {
50+
if (!cancelled) {
51+
setLoading(false);
52+
}
53+
}
54+
};
55+
56+
fetchProducts();
57+
}
58+
59+
return () => {
60+
cancelled = true;
61+
};
62+
}, [currency, locale, storeLoading, taxonId, limit]);
63+
64+
return { products, loading: loading || storeLoading, error };
65+
}

0 commit comments

Comments
 (0)