Skip to content

Commit b60a9b5

Browse files
committed
wrap up exercise 3
1 parent 1be071b commit b60a9b5

Some content is hidden

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

49 files changed

+3422
-18
lines changed

exercises/03.data-fetching/02.solution.search-with-url/README.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
Well done! You have successfully
44

55
🧝‍♀️ I'm going to help you out for the next exercise, I'll be adding some utilities
6-
so you can easily filter and paginate your data!
6+
so you can easily filter and paginate your data! I'll also extract the product filters
7+
into their own component to keep things nice and tidy!
78

89
You're doing great! 🚀
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DATABASE_URL="file:./prisma/data.db"
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## Filtering, sorting and pagination
2+
3+
Now that you have a working products listing page, it's time to enhance it with filtering, sorting, and pagination capabilities.
4+
In this exercise, you'll implement the following features:
5+
- **Filtering**: Allow users to filter products based on categories, brands, and price ranges.
6+
- **Sorting**: Enable users to sort products by price
7+
- **Pagination**: Implement pagination to manage large sets of products effectively.
8+
9+
We will be using the URL as the source of truth and we will learn how to do all this in a way that supports progressive enhancement.
10+
We will rely on the web platform and HTML forms to manage the state of our filters, sorting, and pagination.
11+
We will also use the `useNavigation` hook to provide feedback to the user when the page is loading new data.
12+
13+
### `useNavigation` hook
14+
15+
The `useNavigation` hook from React Router provides information about the current navigation state of your application.
16+
This hook is particularly useful for providing feedback to users during navigation events, such as when a page is loading new data.
17+
18+
When you call `useNavigation`, it returns an object that contains details about the current navigation state. One of the key properties of this object is `state`,
19+
which can have one of the following values:
20+
- `"idle"`: Indicates that there is no ongoing navigation. The application is in a stable state.
21+
- `"loading"`: Indicates that a navigation event is in progress, and new data is being loaded.
22+
- `"submitting"`: Indicates that a form submission is in progress.
23+
24+
By checking the value of `navigation.state`, you can conditionally render UI elements to inform users about the current state of the application.
25+
For example, you might display a loading spinner when the state is `"loading"` or disable form inputs when the state is `"submitting"`.
26+
27+
This hook can also be used to show optimistic UI by accessing the `formData` property which is filled with the submitted data
28+
during a form submission. This allows you to reflect the changes immediately in the UI before the server responds, enhancing the user experience.
29+
30+
### Exercise Goals
31+
32+
In this exercise, you will enhance the products listing page by implementing filtering, sorting, and pagination features.
33+
You will first use the prepared method for extracting all the search parameters from the URL and then apply those filters to the products listing.
34+
You will pass back the info to the client and then use that info to render true information instead of the hardcoded info we had before.
35+
You will also learn how to use the `useNavigation` hook to provide feedback to users when the page is loading new data.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
@import 'tailwindcss';
2+
3+
@theme {
4+
--font-sans:
5+
'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
6+
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
7+
}
8+
9+
html,
10+
body {
11+
@apply bg-white dark:bg-gray-950;
12+
13+
@media (prefers-color-scheme: dark) {
14+
color-scheme: dark;
15+
}
16+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { ShoppingBag } from 'lucide-react'
2+
import { Link } from 'react-router'
3+
4+
export const Footer = () => {
5+
return (
6+
<footer className="border-t border-stone-200 bg-white dark:border-gray-700 dark:bg-gray-900">
7+
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
8+
<div className="grid grid-cols-1 gap-12 md:grid-cols-4">
9+
<div className="col-span-1 md:col-span-2">
10+
<div className="mb-6 flex items-center space-x-3">
11+
<ShoppingBag className="h-7 w-7 text-amber-600 dark:text-amber-500" />
12+
<span className="text-xl font-light tracking-wide text-gray-900 dark:text-white">
13+
EpicStore
14+
</span>
15+
</div>
16+
<p className="max-w-md leading-relaxed font-light text-gray-600 dark:text-gray-400">
17+
Your premier destination for premium epic products. Discover the
18+
perfect product that combines style, comfort, and quality
19+
craftsmanship.
20+
</p>
21+
</div>
22+
<div>
23+
<h3 className="mb-6 text-sm font-medium tracking-wider text-gray-900 uppercase dark:text-white">
24+
Shop
25+
</h3>
26+
<ul className="space-y-4">
27+
<li>
28+
<Link
29+
to="/products"
30+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
31+
>
32+
All Products
33+
</Link>
34+
</li>
35+
<li>
36+
<Link
37+
to="/products?category=Running"
38+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
39+
>
40+
Running
41+
</Link>
42+
</li>
43+
<li>
44+
<Link
45+
to="/products?category=Casual"
46+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
47+
>
48+
Casual
49+
</Link>
50+
</li>
51+
<li>
52+
<Link
53+
to="/products?category=Formal"
54+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
55+
>
56+
Formal
57+
</Link>
58+
</li>
59+
</ul>
60+
</div>
61+
<div>
62+
<h3 className="mb-6 text-sm font-medium tracking-wider text-gray-900 uppercase dark:text-white">
63+
Company
64+
</h3>
65+
<ul className="space-y-4">
66+
<li>
67+
<Link
68+
to="/about"
69+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
70+
>
71+
About
72+
</Link>
73+
</li>
74+
<li>
75+
<Link
76+
to="/contact"
77+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
78+
>
79+
Contact
80+
</Link>
81+
</li>
82+
<li>
83+
<Link
84+
to="/terms-of-service"
85+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
86+
>
87+
Terms of Service
88+
</Link>
89+
</li>
90+
<li>
91+
<Link
92+
to="/terms-of-use"
93+
className="font-light text-gray-600 transition-colors duration-300 hover:text-amber-600 dark:text-gray-400 dark:hover:text-amber-500"
94+
>
95+
Terms of Use
96+
</Link>
97+
</li>
98+
</ul>
99+
</div>
100+
</div>
101+
<div className="mt-12 border-t border-stone-200 pt-8 dark:border-gray-700">
102+
<p className="text-center font-light text-gray-500 dark:text-gray-400">
103+
© 2025 EpicStore. All rights reserved.
104+
</p>
105+
</div>
106+
</div>
107+
</footer>
108+
)
109+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { ShoppingBag, Search, Menu, X } from 'lucide-react'
2+
import { useState } from 'react'
3+
import { Link, useLocation, Form, useSearchParams } from 'react-router'
4+
5+
export const Header = () => {
6+
const [isMenuOpen, setIsMenuOpen] = useState(false)
7+
const navigation = [
8+
{ name: 'Home', href: '/' },
9+
{ name: 'Products', href: '/products' },
10+
{ name: 'About', href: '/about' },
11+
{ name: 'Contact', href: '/contact' },
12+
]
13+
const [searchParams] = useSearchParams()
14+
const location = useLocation()
15+
const isActive = (href: string) => location.pathname === href
16+
const q = searchParams.get('q') || ''
17+
return (
18+
<nav className="sticky top-0 z-50 border-b border-stone-200 bg-white/95 backdrop-blur-md dark:border-gray-700 dark:bg-gray-900/95">
19+
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
20+
<div className="flex h-20 items-center justify-between">
21+
<div className="flex items-center">
22+
<Link to="/" className="flex items-center space-x-3">
23+
<ShoppingBag className="h-8 w-8 text-amber-600 dark:text-amber-500" />
24+
<span className="text-2xl font-light tracking-wide text-gray-900 dark:text-white">
25+
EpicStore
26+
</span>
27+
</Link>
28+
</div>
29+
30+
<div className="mx-8 hidden max-w-lg flex-1 md:block">
31+
<div className="relative">
32+
<Form method="GET" action="/products" className="relative">
33+
<Search className="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 transform text-gray-400" />
34+
<input
35+
type="text"
36+
defaultValue={q}
37+
name="q"
38+
onKeyDown={(e) => {
39+
if (e.key === 'Enter') {
40+
const form = e.currentTarget.form
41+
if (form) {
42+
form.submit()
43+
}
44+
}
45+
}}
46+
placeholder="Search for products..."
47+
className="w-full rounded-full border border-gray-200 bg-gray-50 py-3 pr-24 pl-10 text-gray-900 focus:border-transparent focus:ring-2 focus:ring-amber-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
48+
/>
49+
<button
50+
type="submit"
51+
className="absolute top-1/2 right-2 flex -translate-y-1/2 items-center space-x-1 rounded-full bg-amber-600 px-4 py-2 text-sm font-medium text-white transition-colors duration-300 hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600"
52+
>
53+
<Search className="h-4 w-4" />
54+
<span>Search</span>
55+
</button>
56+
</Form>
57+
</div>
58+
</div>
59+
60+
<div className="hidden md:block">
61+
<div className="flex items-center space-x-8">
62+
{navigation.map((item) => (
63+
<Link
64+
key={item.name}
65+
to={item.href}
66+
className={`text-sm font-medium tracking-wide transition-colors duration-300 ${
67+
isActive(item.href)
68+
? 'text-amber-600 dark:text-amber-500'
69+
: 'text-gray-700 hover:text-amber-600 dark:text-gray-300 dark:hover:text-amber-500'
70+
}`}
71+
>
72+
{item.name}
73+
</Link>
74+
))}
75+
<div className="flex items-center space-x-4">
76+
{/* <button className="p-2 text-gray-700 dark:text-gray-300 hover:text-amber-600 dark:hover:text-amber-500 transition-colors duration-300">
77+
<Heart className="w-5 h-5" />
78+
</button>
79+
<button className="p-2 text-gray-700 dark:text-gray-300 hover:text-amber-600 dark:hover:text-amber-500 transition-colors duration-300">
80+
<User className="w-5 h-5" />
81+
</button>
82+
<ShoppingCart /> */}
83+
</div>
84+
</div>
85+
</div>
86+
87+
<div className="flex items-center space-x-4 md:hidden">
88+
{/* <ShoppingCart /> */}
89+
<button
90+
onClick={() => setIsMenuOpen(!isMenuOpen)}
91+
className="p-2 text-gray-700 hover:text-amber-600 dark:text-gray-300 dark:hover:text-amber-500"
92+
>
93+
{isMenuOpen ? (
94+
<X className="h-6 w-6" />
95+
) : (
96+
<Menu className="h-6 w-6" />
97+
)}
98+
</button>
99+
</div>
100+
</div>
101+
</div>
102+
103+
{isMenuOpen && (
104+
<div className="md:hidden">
105+
<div className="space-y-4 border-t border-stone-200 bg-white px-4 pt-2 pb-6 dark:border-gray-700 dark:bg-gray-900">
106+
<div className="relative mb-4">
107+
<Search className="absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2 transform text-gray-400" />
108+
<input
109+
type="text"
110+
placeholder="Search for products..."
111+
className="w-full rounded-full border border-gray-200 bg-gray-50 py-3 pr-4 pl-10 text-gray-900 focus:border-transparent focus:ring-2 focus:ring-amber-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
112+
/>
113+
</div>
114+
{navigation.map((item) => (
115+
<Link
116+
key={item.name}
117+
to={item.href}
118+
onClick={() => setIsMenuOpen(false)}
119+
className={`block text-base font-medium tracking-wide transition-colors duration-300 ${
120+
isActive(item.href)
121+
? 'text-amber-600 dark:text-amber-500'
122+
: 'text-gray-700 hover:text-amber-600 dark:text-gray-300 dark:hover:text-amber-500'
123+
}`}
124+
>
125+
{item.name}
126+
</Link>
127+
))}
128+
</div>
129+
</div>
130+
)}
131+
</nav>
132+
)
133+
}
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { PrismaBetterSQLite3 } from '@prisma/adapter-better-sqlite3';
2+
import { PrismaClient } from "./generated/prisma/client";
3+
4+
const adapter = new PrismaBetterSQLite3({
5+
url: process.env.DATABASE_URL,
6+
});
7+
const prisma = new PrismaClient({ adapter });
8+
await prisma.$connect();
9+
export const db = prisma;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { db } from "#app/db.server.js"
2+
3+
export const getAllBrands = async () => {
4+
const brands = await db.brand.findMany({
5+
orderBy: { name: "asc" },
6+
select: { id: true, name: true },
7+
});
8+
return { brands };
9+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { db } from "#app/db.server.js";
2+
3+
export const getAllCategories = async ({ perPage }: { perPage?: number } = {}) => {
4+
const categories = await db.category.findMany({
5+
orderBy: { name: "asc" },
6+
select: { id: true, name: true, imageUrl: true },
7+
take: perPage,
8+
});
9+
return { categories };
10+
}
11+
12+
export type Category = Awaited<ReturnType<typeof getAllCategories>>["categories"][number];

0 commit comments

Comments
 (0)