Skip to content

Commit 127376b

Browse files
committed
added problem 4
1 parent b60a9b5 commit 127376b

File tree

93 files changed

+3365
-0
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

93 files changed

+3365
-0
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
## Filtering, sorting and pagination
2+
3+
Well done! You have successfully added filtering, sorting, and pagination to your product listing page.
4+
This allows your users to easily find and navigate through products based on their preferences.
5+
6+
Now with that out of the way, you might've noticed that the pagination works, but it doesn't load more products!
7+
Let's fix that in the next exercise by using fetchers and infinite scrolling.
8+
9+
🧝‍♀️ I'm going to help you out for the next exercise, I'll create an intersection observer hook to let us listen
10+
to when the user scrolls to the bottom of the page so we can load more products automatically! The rest is up to you!
11+
12+
You're doing great! 🚀
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Heart, Star } from 'lucide-react'
2+
import { Link } from 'react-router'
3+
import { type ProductCardInfo } from '#app/domain/products.server.js'
4+
5+
export function ProductCard({ product }: { product: ProductCardInfo }) {
6+
return (
7+
<div
8+
key={product.id}
9+
className="group overflow-hidden rounded-lg bg-white transition-all duration-300 hover:scale-105 hover:transform hover:shadow-xl dark:bg-gray-800"
10+
>
11+
<div className="relative overflow-hidden">
12+
<Link to={`/products/${product.id}`}>
13+
<img
14+
src={product.imageUrl}
15+
alt={product.name}
16+
className="h-64 w-full object-cover transition-transform duration-500 group-hover:scale-110"
17+
/>
18+
</Link>
19+
<button className="absolute top-4 right-4 rounded-full bg-white p-2 shadow-lg transition-colors duration-200 hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800">
20+
<Heart className="h-4 w-4 text-gray-600 dark:text-gray-400" />
21+
</button>
22+
<div className="absolute top-4 left-4 rounded-full bg-white px-3 py-1 dark:bg-gray-900">
23+
<div className="flex items-center space-x-1">
24+
<Star className="h-4 w-4 fill-current text-amber-500" />
25+
<span className="text-sm font-medium text-gray-900 dark:text-white">
26+
{product.reviewScore.toFixed(1)}
27+
</span>
28+
</div>
29+
</div>
30+
</div>
31+
<div className="p-6">
32+
<div className="mb-2 text-sm font-medium text-amber-600 dark:text-amber-500">
33+
{product.brand.name}
34+
</div>
35+
<Link to={`/products/${product.id}`}>
36+
<h3 className="mb-2 text-lg font-medium text-gray-900 transition-colors duration-300 group-hover:text-amber-600 dark:text-white dark:group-hover:text-amber-500">
37+
{product.name}
38+
</h3>
39+
</Link>
40+
<p className="mb-4 line-clamp-2 text-sm text-gray-600 dark:text-gray-300">
41+
{product.description}
42+
</p>
43+
<div className="flex items-center justify-between">
44+
<span className="text-xl font-bold text-gray-900 dark:text-white">
45+
${product.price}
46+
</span>
47+
<span className="text-sm text-gray-500 dark:text-gray-400">
48+
{product._count.reviews} reviews
49+
</span>
50+
</div>
51+
</div>
52+
</div>
53+
)
54+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { db } from "#app/db.server.js";
2+
import { type ProductWhereInput, type ProductSelect, type ProductOrderByWithRelationInput } from "#app/generated/prisma/models.ts";
3+
4+
interface ProductFilters {
5+
category?: string[];
6+
brand?: string[];
7+
priceMin?: number;
8+
priceMax?: number;
9+
sortBy?: 'name' | 'price-low' | 'price-high' | 'rating';
10+
page?: number;
11+
limit?: number;
12+
search?: string;
13+
}
14+
15+
// Define select object without strict typing for _count support
16+
const productShortInfoSelect = {
17+
id: true,
18+
name: true,
19+
description: true,
20+
imageUrl: true,
21+
price: true,
22+
brand: {
23+
select: {
24+
id: true,
25+
name: true
26+
},
27+
},
28+
reviewScore: true,
29+
_count: {
30+
select: {
31+
reviews: true,
32+
},
33+
},
34+
} as const satisfies ProductSelect
35+
36+
37+
export const extractProductFiltersFromSearchParams = (
38+
searchParams: URLSearchParams,
39+
): ProductFilters => {
40+
const search = searchParams.get('q') || undefined
41+
const category = searchParams.getAll('category') || []
42+
const brand = searchParams.getAll('brand') || []
43+
const priceMin = searchParams.get('priceMin')
44+
? parseFloat(searchParams.get('priceMin')!)
45+
: undefined
46+
const priceMax = searchParams.get('priceMax')
47+
? parseFloat(searchParams.get('priceMax')!)
48+
: undefined
49+
const sortBy = (searchParams.get('sortBy') || 'name') as
50+
| 'name'
51+
| 'price-low'
52+
| 'price-high'
53+
| 'rating'
54+
const page = parseInt(searchParams.get('page') || '1')
55+
const limit = parseInt(searchParams.get('perPage') || '9')
56+
return { search, category, brand, priceMin, priceMax, sortBy, page, limit }
57+
}
58+
59+
function createProductOrderBy(sortBy: ProductFilters['sortBy']): ProductOrderByWithRelationInput | undefined {
60+
switch (sortBy) {
61+
case 'name':
62+
return { name: 'asc' };
63+
case 'price-low':
64+
return { price: 'asc' };
65+
case 'price-high':
66+
return { price: 'desc' };
67+
case 'rating':
68+
return { reviewScore: 'desc' };
69+
default:
70+
return { name: 'asc' };
71+
}
72+
}
73+
74+
75+
function createProductWhereClause(filters?: ProductFilters): ProductWhereInput {
76+
return {
77+
OR: filters?.search ? [
78+
{ name: filters.search ? { contains: filters.search, } : undefined },
79+
{ description: filters.search ? { contains: filters.search, } : undefined },
80+
] : undefined,
81+
category: filters?.category && filters.category.length > 0 ? { name: { in: filters.category } } : undefined,
82+
brand: filters?.brand && filters.brand.length > 0 ? { name: { in: filters.brand } } : undefined,
83+
price: {
84+
gte: filters?.priceMin ?? 0,
85+
lte: filters?.priceMax ?? 300,
86+
},
87+
}
88+
}
89+
90+
91+
export async function getProducts(filters?: ProductFilters) {
92+
const page = filters?.page ?? 1;
93+
const limit = filters?.limit ?? 4;
94+
const skip = (page - 1) * limit;
95+
const whereClause = createProductWhereClause(filters);
96+
97+
const orderByClause = createProductOrderBy(filters?.sortBy);
98+
99+
// Get products with pagination
100+
const products = await db.product.findMany({
101+
where: whereClause,
102+
orderBy: orderByClause,
103+
select: productShortInfoSelect,
104+
skip,
105+
take: limit,
106+
});
107+
108+
// Get total count for pagination metadata
109+
const totalCount = await db.product.count({
110+
where: whereClause,
111+
});
112+
113+
const hasMore = skip + products.length < totalCount;
114+
115+
116+
return {
117+
products,
118+
pagination: {
119+
page,
120+
limit,
121+
totalCount,
122+
hasMore,
123+
totalPages: Math.ceil(totalCount / limit),
124+
}
125+
};
126+
}
127+
128+
export async function getProductById(id: string) {
129+
const product = await db.product.findUnique({
130+
where: { id },
131+
select: {
132+
id: true,
133+
name: true,
134+
description: true,
135+
imageUrl: true,
136+
price: true,
137+
reviewScore: true,
138+
category: {
139+
select: {
140+
id: true,
141+
name: true
142+
},
143+
},
144+
brand: {
145+
select: {
146+
id: true,
147+
name: true
148+
},
149+
},
150+
reviews: {
151+
select: {
152+
id: true,
153+
rating: true,
154+
comment: true,
155+
}
156+
},
157+
variations: {
158+
select: {
159+
160+
color: true,
161+
size: true,
162+
id: true,
163+
quantity: true,
164+
165+
}
166+
}
167+
}
168+
});
169+
return product;
170+
}
171+
172+
export async function getRelatedProducts(productId: string, categoryId: string | undefined, brandId: string | undefined) {
173+
const products = await db.product.findMany({
174+
where: {
175+
id: { not: productId },
176+
OR: [
177+
{ categoryId: categoryId },
178+
{ brandId: brandId }
179+
]
180+
},
181+
select: productShortInfoSelect,
182+
take: 4
183+
});
184+
return products;
185+
}
186+
187+
export type ProductCardInfo = Awaited<ReturnType<typeof getProducts>>['products'][number];

0 commit comments

Comments
 (0)