Component Details
Filterable Component 01

FilterableTemplates.tsx
Next.js•Tailwind CSS
1"use client";
2
3import { useMemo, useState } from "react";
4import Image from "next/image";
5import Link from "next/link";
6
7interface Template {
8 id: string;
9 name: string;
10 slug: string;
11 image: string;
12 price: string;
13}
14
15interface TemplateCardProps {
16 template: Template;
17}
18
19function TemplateCard({ template }: TemplateCardProps) {
20 return (
21 <Link href={`/template/${template.slug}`}>
22 <div className="bg-card rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 group relative border border-gray-700 cursor-pointer">
23 {/* Thumbnail */}
24 <div className="relative aspect-video overflow-hidden rounded-t-2xl border-b border-gray-700 group-hover:border-primary transition-colors">
25 <Image
26 src={template.image}
27 alt={template.name}
28 fill
29 className="object-contain object-center"
30 sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
31 />
32 </div>
33
34 {/* Title and Price Container */}
35 <div className="p-4">
36 <div className="flex items-center justify-between">
37 {/* Title with character limit */}
38 <h3
39 className="text-base truncate max-w-[70%]"
40 title={template.name}
41 >
42 {template.name.length > 30
43 ? `${template.name.substring(0, 30)}...`
44 : template.name}
45 </h3>
46
47 {/* Price */}
48 <span className="inline-flex items-center rounded-full bg-blue-600/20 px-2.5 py-0.5 text-xs font-medium text-blue-400">
49 {template.price === "Free" ? "Free" : `$${template.price}`}
50 </span>
51 </div>
52 </div>
53 </div>
54 </Link>
55 );
56}
57
58// Dummy data for templates
59const dummyTemplates: Template[] = [
60 {
61 id: "1",
62 name: "Dashboard Template",
63 slug: "dashboard-template",
64 image: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&auto=format&fit=crop",
65 price: "129"
66 },
67 {
68 id: "2",
69 name: "Ecommerce Template",
70 slug: "ecommerce-template",
71 image: "https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=800&auto=format&fit=crop",
72 price: "89"
73 },
74 {
75 id: "3",
76 name: "Portfolio Template",
77 slug: "portfolio-template",
78 image: "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=800&auto=format&fit=crop",
79 price: "Free"
80 },
81 {
82 id: "4",
83 name: "Blog Template",
84 slug: "blog-template",
85 image: "https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=800&auto=format&fit=crop",
86 price: "75"
87 },
88 {
89 id: "5",
90 name: "Landing Page Template",
91 slug: "landing-page-template",
92 image: "https://images.unsplash.com/photo-1551650975-87deedd944c3?w=800&auto=format&fit=crop",
93 price: "99"
94 },
95 {
96 id: "6",
97 name: "Admin Template",
98 slug: "admin-template",
99 image: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&auto=format&fit=crop",
100 price: "149"
101 },
102 {
103 id: "7",
104 name: "SaaS Template",
105 slug: "saas-template",
106 image: "https://images.unsplash.com/photo-1551650975-87deedd944c3?w=800&auto=format&fit=crop",
107 price: "199"
108 },
109 {
110 id: "8",
111 name: "Mobile App Template",
112 slug: "mobile-app-template",
113 image: "https://images.unsplash.com/photo-1512941937669-90a1b58e7e9c?w=800&auto=format&fit=crop",
114 price: "Free"
115 },
116 {
117 id: "9",
118 name: "Agency Template",
119 slug: "agency-template",
120 image: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=800&auto=format&fit=crop",
121 price: "129"
122 }
123];
124
125export function FilterableTemplates() {
126 const [showCategoryFilters, setShowCategoryFilters] = useState(false);
127 const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
128 const [priceFilter, setPriceFilter] = useState<"all" | "free" | "paid">(
129 "all"
130 );
131
132 // Extract categories from template names
133 const categories = useMemo(() => {
134 const cats = new Map<string, number>();
135 dummyTemplates.forEach((template) => {
136 const categoryMatch = template.name.match(/^(\w+)\s+Template/i);
137 if (categoryMatch && categoryMatch[1]) {
138 const cat = categoryMatch[1].toLowerCase();
139 cats.set(cat, (cats.get(cat) || 0) + 1);
140 }
141 });
142
143 // Convert to array with counts
144 return Array.from(cats.entries()).map(([name, count]) => ({
145 name,
146 count,
147 displayName: name.charAt(0).toUpperCase() + name.slice(1),
148 }));
149 }, []);
150
151 const filteredTemplates = useMemo(() => {
152 let filtered = [...dummyTemplates];
153
154 // Filter by price (free/paid)
155 if (priceFilter === "free") {
156 filtered = filtered.filter((template) => template.price === "Free");
157 } else if (priceFilter === "paid") {
158 filtered = filtered.filter((template) => template.price !== "Free");
159 }
160
161 // Filter by selected categories
162 if (selectedCategories.length > 0) {
163 filtered = filtered.filter((template) => {
164 const categoryMatch = template.name.match(/^(\w+)\s+Template/i);
165 if (categoryMatch && categoryMatch[1]) {
166 return selectedCategories.includes(categoryMatch[1].toLowerCase());
167 }
168 return false;
169 });
170 }
171
172 return filtered;
173 }, [priceFilter, selectedCategories]);
174
175 const toggleCategory = (categoryName: string) => {
176 if (selectedCategories.includes(categoryName)) {
177 setSelectedCategories(
178 selectedCategories.filter((c) => c !== categoryName)
179 );
180 } else {
181 setSelectedCategories([...selectedCategories, categoryName]);
182 }
183 };
184
185 const clearAllFilters = () => {
186 setSelectedCategories([]);
187 setPriceFilter("all");
188 };
189
190 const handleCategoryClick = () => {
191 setShowCategoryFilters(!showCategoryFilters);
192 };
193
194 const togglePriceFilter = (type: "free" | "paid") => {
195 if (priceFilter === type) {
196 setPriceFilter("all");
197 } else {
198 setPriceFilter(type);
199 }
200 };
201
202 const hasActiveFilters =
203 selectedCategories.length > 0 || priceFilter !== "all";
204
205 return (
206 <section>
207 <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
208 <div className="max-w-7xl mx-auto">
209 {/* Header section - responsive layout */}
210 <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
211 <div className="flex items-center justify-between sm:justify-start">
212 <h3 className="text-2xl sm:text-3xl font-semibold text-white">
213 Filter Templates
214 </h3>
215
216 {/* Clear All Filters Button - Mobile only (right side) */}
217 <div className="lg:hidden">
218 {hasActiveFilters && (
219 <button
220 type="button"
221 onClick={clearAllFilters}
222 className="ml-4 px-4 py-1 rounded-lg border border-gray-700 bg-transparent text-white font-medium transition-all duration-300 ease-in-out hover:border-blue-600 whitespace-nowrap text-sm"
223 >
224 Clear all
225 </button>
226 )}
227 </div>
228 </div>
229
230 {/* Filter buttons - responsive container */}
231 <div className="flex flex-col sm:flex-row items-stretch sm:items-center">
232 {/* Clear All Filters Button - Desktop only (left side) */}
233 <div className="hidden lg:block order-first mr-4">
234 {hasActiveFilters && (
235 <button
236 type="button"
237 onClick={clearAllFilters}
238 className="px-5 py-3 rounded-lg border border-gray-700 bg-transparent text-white font-medium transition-all duration-300 ease-in-out hover:border-blue-600 whitespace-nowrap text-base"
239 >
240 Clear all filters
241 </button>
242 )}
243 </div>
244
245 {/* Price Filter Buttons - responsive stacking */}
246 <div className="flex flex-wrap gap-2 sm:gap-3 md:gap-4">
247 <button
248 type="button"
249 onClick={() => togglePriceFilter("free")}
250 className={`px-4 py-2.5 sm:px-5 sm:py-3 rounded-lg border font-medium transition-all duration-300 ease-in-out whitespace-nowrap text-sm sm:text-base ${
251 priceFilter === "free"
252 ? "bg-blue-600 text-white border-blue-600 shadow-md"
253 : "bg-transparent text-white border-gray-700 hover:border-blue-600"
254 }`}
255 >
256 Free
257 <span className="hidden sm:inline"> Templates</span>
258 </button>
259
260 <button
261 type="button"
262 onClick={() => togglePriceFilter("paid")}
263 className={`px-4 py-2.5 sm:px-5 sm:py-3 rounded-lg border font-medium transition-all duration-300 ease-in-out whitespace-nowrap text-sm sm:text-base ${
264 priceFilter === "paid"
265 ? "bg-blue-600 text-white border-blue-600 shadow-md"
266 : "bg-transparent text-white border-gray-700 hover:border-blue-600"
267 }`}
268 >
269 Paid
270 <span className="hidden sm:inline"> Templates</span>
271 </button>
272
273 <button
274 type="button"
275 onClick={handleCategoryClick}
276 className={`px-4 py-2.5 sm:px-5 sm:py-3 rounded-lg border font-medium transition-all duration-300 ease-in-out whitespace-nowrap text-sm sm:text-base ${
277 showCategoryFilters || selectedCategories.length > 0
278 ? "bg-blue-600 text-white border-blue-600 shadow-md"
279 : "bg-transparent text-white border-gray-700 hover:border-blue-600"
280 }`}
281 >
282 <span className="hidden sm:inline">Filter by </span>Category
283 </button>
284 </div>
285 </div>
286 </div>
287
288 {/* Category Filter Container - Smooth Animation */}
289 <div
290 className={`overflow-hidden transition-all duration-500 ease-in-out ${
291 showCategoryFilters
292 ? "max-h-[500px] opacity-100 mt-4"
293 : "max-h-0 opacity-0 mt-0"
294 }`}
295 >
296 <div className="p-4 sm:p-6 bg-transparent border border-gray-700 rounded-lg shadow-sm">
297 <h3 className="text-lg font-semibold text-white mb-4">
298 Select Categories
299 </h3>
300 <div className="flex flex-wrap gap-2 sm:gap-3">
301 {categories.map((category) => (
302 <button
303 key={category.name}
304 type="button"
305 onClick={() => toggleCategory(category.name)}
306 className={`px-3 py-1.5 sm:px-4 sm:py-2 rounded-full text-xs sm:text-sm font-medium transition-all duration-300 ease-in-out transform border hover:border-blue-600 whitespace-nowrap ${
307 selectedCategories.includes(category.name)
308 ? "bg-blue-600 text-white shadow-md border-blue-600"
309 : "bg-transparent text-white border-gray-700"
310 }`}
311 >
312 {category.displayName}
313 <span className="ml-1">({category.count})</span>
314 </button>
315 ))}
316 </div>
317 </div>
318 </div>
319 </div>
320
321 {/* Active Filters Display - Animated */}
322 <div className="mb-4">
323 <div className="flex flex-wrap gap-2 mt-4">
324 {/* Price Filter Badge */}
325 {priceFilter !== "all" && (
326 <span className="inline-flex items-center gap-1 px-3 py-1.5 sm:py-2 bg-blue-600/20 text-primary rounded-full text-xs sm:text-sm whitespace-nowrap">
327 {priceFilter === "free" ? "Free" : "Paid"}
328 <span className="hidden sm:inline"> Templates</span>
329 <button
330 type="button"
331 onClick={() => setPriceFilter("all")}
332 className="hover:text-primary/80 text-lg leading-none"
333 >
334 ×
335 </button>
336 </span>
337 )}
338
339 {/* Category Filter Badges */}
340 {selectedCategories.map((category) => {
341 const cat = categories.find((c) => c.name === category);
342 return (
343 <span
344 key={category}
345 className="inline-flex items-center gap-1 px-3 py-1.5 sm:py-2 bg-blue-600/20 text-primary rounded-full text-xs sm:text-sm whitespace-nowrap"
346 >
347 {cat?.displayName || category}
348 <button
349 type="button"
350 onClick={() => toggleCategory(category)}
351 className="hover:text-primary/80 text-lg leading-none"
352 >
353 ×
354 </button>
355 </span>
356 );
357 })}
358 </div>
359 </div>
360
361 {/* Results Count */}
362 <div className="max-w-7xl mx-auto mb-6 transition-opacity duration-500">
363 <p className="text-gray-500 text-sm sm:text-base">
364 Showing{" "}
365 <span className="font-semibold">{filteredTemplates.length}</span> of{" "}
366 <span className="font-semibold">{dummyTemplates.length}</span> templates
367 {priceFilter !== "all" && ` (${priceFilter})`}
368 </p>
369 </div>
370
371 {/* Templates Grid - Responsive columns */}
372 {filteredTemplates.length > 0 ? (
373 <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 max-w-7xl mx-auto">
374 {filteredTemplates.map((template: Template, index) => (
375 <div
376 key={template.id}
377 className={`transition-all duration-500 ease-in-out`}
378 >
379 <TemplateCard template={template} />
380 </div>
381 ))}
382 </div>
383 ) : (
384 <div className="text-center py-12 sm:py-16 bg-transparent border border-gray-700 rounded-xl max-w-7xl mx-auto transition-all duration-500 ease-in-out">
385 <div className="text-gray-400 mb-4">
386 <svg
387 className="w-12 h-12 sm:w-16 sm:h-16 mx-auto"
388 fill="none"
389 stroke="currentColor"
390 viewBox="0 0 24 24"
391 xmlns="http://www.w3.org/2000/svg"
392 >
393 <path
394 strokeLinecap="round"
395 strokeLinejoin="round"
396 strokeWidth={1.5}
397 d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
398 />
399 </svg>
400 </div>
401 <h3 className="text-lg sm:text-xl font-semibold text-gray-700 mb-2">
402 No templates found
403 </h3>
404 <p className="text-gray-500 mb-6 max-w-md mx-auto px-4 text-sm sm:text-base">
405 Try adjusting your filters to find what you're looking for
406 </p>
407 <button
408 type="button"
409 onClick={clearAllFilters}
410 className="px-5 py-2.5 sm:px-6 sm:py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-300 transform hover:border-blue-600 shadow-md hover:shadow-lg text-sm sm:text-base"
411 >
412 Clear all filters
413 </button>
414 </div>
415 )}
416 </div>
417 </section>
418 );
419}