Component Details

Filterable Component 01

Filterable Component 01

FilterableTemplates.tsx

Next.jsTailwind 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&apos;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}