Skip to content

Commit 0456d5b

Browse files
(SP: 1) [Frontend] Fix shop catalog pagination: client-side Load more appends items without navigation + canonicalize ?page= under locale
1 parent 6090694 commit 0456d5b

4 files changed

Lines changed: 211 additions & 50 deletions

File tree

frontend/app/[locale]/shop/products/page.tsx

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { Suspense } from 'react';
22
import { Filter } from 'lucide-react';
33

4-
import { ProductCard } from '@/components/shop/product-card';
54
import { ProductFilters } from '@/components/shop/product-filters';
65
import { ProductSort } from '@/components/shop/product-sort';
7-
import { CatalogLoadMore } from '@/components/shop/catalog-load-more';
8-
// import { Pagination } from '@/components/q&a/Pagination';
6+
import { CatalogProductsClient } from '@/components/shop/catalog-products-client';
97
import { getCatalogProducts } from '@/lib/shop/data';
108
import { catalogQuerySchema } from '@/lib/validation/shop';
119
import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog';
10+
import { redirect } from 'next/navigation';
1211

1312
type RawSearchParams = {
1413
category?: string;
@@ -19,7 +18,6 @@ type RawSearchParams = {
1918
page?: string;
2019
};
2120

22-
2321
interface ProductsPageProps {
2422
searchParams: Promise<RawSearchParams>;
2523
}
@@ -30,13 +28,35 @@ export default async function ProductsPage({
3028
}: ProductsPageProps & { params: Promise<{ locale: string }> }) {
3129
const { locale } = await params;
3230
const resolvedSearchParams = (await searchParams) ?? {};
31+
// canonicalize: infinite-load page should not be shareable as ?page=N
32+
if (resolvedSearchParams.page) {
33+
const qsParams = new URLSearchParams();
34+
35+
for (const [k, v] of Object.entries(resolvedSearchParams)) {
36+
if (!v) continue;
37+
if (k === 'page') continue;
38+
qsParams.set(k, v);
39+
}
40+
41+
const qs = qsParams.toString();
42+
const basePath = `/${locale}/shop/products`;
43+
44+
redirect(qs ? `${basePath}?${qs}` : basePath);
45+
}
3346

3447
const parsedParams = catalogQuerySchema.safeParse(resolvedSearchParams);
3548

36-
const filters = parsedParams.success
49+
const parsed = parsedParams.success
3750
? parsedParams.data
3851
: { page: 1, limit: CATALOG_PAGE_SIZE };
3952

53+
// Для “Load more” UX: починаємо завжди з 1-ї сторінки (URL ?page=... ігноруємо).
54+
const filters = {
55+
...parsed,
56+
page: 1,
57+
limit: parsed.limit ?? CATALOG_PAGE_SIZE,
58+
};
59+
4060
const catalog = await getCatalogProducts(filters, locale);
4161

4262
return (
@@ -74,27 +94,7 @@ export default async function ProductsPage({
7494
</p>
7595
</div>
7696
) : (
77-
<>
78-
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
79-
{catalog.products.map(product => (
80-
<ProductCard key={product.id} product={product} />
81-
))}
82-
</div>
83-
84-
<div className="mt-12 flex justify-center">
85-
<CatalogLoadMore
86-
hasMore={catalog.hasMore}
87-
nextPage={catalog.page + 1}
88-
/>
89-
{/* {!isLoading && totalPages > 1 && (
90-
<Pagination
91-
currentPage={currentPage}
92-
totalPages={totalPages}
93-
onPageChange={handlePageChange}
94-
/>
95-
)} */}
96-
</div>
97-
</>
97+
<CatalogProductsClient locale={locale} initialCatalog={catalog} />
9898
)}
9999
</div>
100100
</div>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
import { getCatalogProducts } from '@/lib/shop/data';
4+
import { catalogQuerySchema } from '@/lib/validation/shop';
5+
import { CATALOG_PAGE_SIZE } from '@/lib/config/catalog';
6+
7+
export const dynamic = 'force-dynamic';
8+
export const revalidate = 0;
9+
10+
type RawSearchParams = {
11+
category?: string;
12+
type?: string;
13+
color?: string;
14+
size?: string;
15+
sort?: string;
16+
page?: string;
17+
limit?: string;
18+
locale?: string;
19+
};
20+
21+
export async function GET(req: NextRequest) {
22+
const url = new URL(req.url);
23+
const raw = Object.fromEntries(url.searchParams.entries()) as RawSearchParams;
24+
25+
const { locale, ...rest } = raw;
26+
const effectiveLocale = locale ?? 'en';
27+
28+
const parsed = catalogQuerySchema.safeParse(rest);
29+
30+
const filters = parsed.success
31+
? parsed.data
32+
: { page: 1, limit: CATALOG_PAGE_SIZE };
33+
34+
const catalog = await getCatalogProducts(filters, effectiveLocale);
35+
36+
return NextResponse.json(catalog, {
37+
headers: { 'Cache-Control': 'no-store' },
38+
});
39+
}
Lines changed: 14 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,25 @@
1-
"use client"
2-
3-
import { useRouter, useSearchParams } from "next/navigation"
4-
import { useTransition } from "react"
1+
'use client';
52

63
interface CatalogLoadMoreProps {
7-
hasMore: boolean
8-
nextPage: number
4+
hasMore: boolean;
5+
isLoading: boolean;
6+
onLoadMore: () => void;
97
}
108

11-
export function CatalogLoadMore({ hasMore, nextPage }: CatalogLoadMoreProps) {
12-
const router = useRouter()
13-
const searchParams = useSearchParams()
14-
const [isPending, startTransition] = useTransition()
15-
16-
if (!hasMore) return null
17-
18-
const handleClick = () => {
19-
startTransition(() => {
20-
const params = new URLSearchParams(searchParams?.toString())
21-
params.set("page", nextPage.toString())
22-
router.push(`/shop/products?${params.toString()}`)
23-
})
24-
}
9+
export function CatalogLoadMore({
10+
hasMore,
11+
isLoading,
12+
onLoadMore,
13+
}: CatalogLoadMoreProps) {
14+
if (!hasMore) return null;
2515

2616
return (
2717
<button
28-
onClick={handleClick}
29-
disabled={isPending}
18+
onClick={onLoadMore}
19+
disabled={isLoading}
3020
className="rounded-md border border-border px-6 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-70"
3121
>
32-
{isPending ? "Loading..." : "Load more"}
22+
{isLoading ? 'Loading...' : 'Load more'}
3323
</button>
34-
)
24+
);
3525
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useSearchParams, type ReadonlyURLSearchParams } from 'next/navigation';
5+
6+
import { ProductCard } from '@/components/shop/product-card';
7+
import { CatalogLoadMore } from '@/components/shop/catalog-load-more';
8+
9+
type Product = React.ComponentProps<typeof ProductCard>['product'] & {
10+
id: string;
11+
};
12+
13+
type CatalogPayload = {
14+
products: Product[];
15+
hasMore: boolean;
16+
page: number;
17+
};
18+
19+
function stripPageParam(sp: ReadonlyURLSearchParams | null): string {
20+
const p = new URLSearchParams(sp?.toString() ?? '');
21+
p.delete('page');
22+
return p.toString();
23+
}
24+
25+
export function CatalogProductsClient({
26+
locale,
27+
initialCatalog,
28+
}: {
29+
locale: string;
30+
initialCatalog: CatalogPayload;
31+
}) {
32+
const searchParams = useSearchParams();
33+
34+
const baseQuery = React.useMemo(
35+
() => stripPageParam(searchParams),
36+
[searchParams]
37+
);
38+
39+
const [products, setProducts] = React.useState<Product[]>(
40+
initialCatalog.products
41+
);
42+
const [page, setPage] = React.useState<number>(initialCatalog.page);
43+
const [hasMore, setHasMore] = React.useState<boolean>(initialCatalog.hasMore);
44+
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
45+
const [error, setError] = React.useState<string | null>(null);
46+
47+
const activeQueryRef = React.useRef<string>(`${baseQuery}|l=${locale}`);
48+
49+
React.useEffect(() => {
50+
activeQueryRef.current = `${baseQuery}|l=${locale}`;
51+
setProducts(initialCatalog.products);
52+
setPage(initialCatalog.page);
53+
setHasMore(initialCatalog.hasMore);
54+
setIsLoadingMore(false);
55+
setError(null);
56+
}, [
57+
baseQuery,
58+
locale,
59+
initialCatalog.products,
60+
initialCatalog.page,
61+
initialCatalog.hasMore,
62+
]);
63+
64+
const onLoadMore = async () => {
65+
if (!hasMore || isLoadingMore) return;
66+
67+
setIsLoadingMore(true);
68+
setError(null);
69+
70+
const nextPage = page + 1;
71+
72+
const query = new URLSearchParams(baseQuery);
73+
query.set('page', String(nextPage));
74+
75+
const requestQueryKey = `${baseQuery}|l=${locale}`;
76+
activeQueryRef.current = requestQueryKey;
77+
query.set('locale', locale);
78+
79+
try {
80+
const res = await fetch(`/api/shop/catalog?${query.toString()}`, {
81+
method: 'GET',
82+
cache: 'no-store',
83+
});
84+
85+
if (!res.ok) {
86+
setError(`Failed to load more (HTTP ${res.status})`);
87+
return;
88+
}
89+
90+
const data = (await res.json()) as CatalogPayload;
91+
92+
// якщо фільтри/сорт змінились під час запиту — ігноруємо відповідь
93+
if (activeQueryRef.current !== requestQueryKey) return;
94+
95+
setProducts(prev => {
96+
const seen = new Set(prev.map(p => p.id));
97+
const appended = data.products.filter(p => !seen.has(p.id));
98+
return [...prev, ...appended];
99+
});
100+
101+
setPage(data.page);
102+
setHasMore(data.hasMore);
103+
} catch {
104+
setError('Failed to load more');
105+
} finally {
106+
if (activeQueryRef.current === requestQueryKey) {
107+
setIsLoadingMore(false);
108+
}
109+
}
110+
};
111+
112+
return (
113+
<>
114+
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
115+
{products.map(p => (
116+
<ProductCard key={p.id} product={p} />
117+
))}
118+
</div>
119+
120+
<div className="mt-12 flex flex-col items-center gap-3">
121+
<CatalogLoadMore
122+
hasMore={hasMore}
123+
isLoading={isLoadingMore}
124+
onLoadMore={onLoadMore}
125+
/>
126+
{error ? (
127+
<p className="text-sm text-muted-foreground">{error}</p>
128+
) : null}
129+
</div>
130+
</>
131+
);
132+
}

0 commit comments

Comments
 (0)