feat(api): implement product caching mechanism
- Add NODE_ENV=production to .env for environment configuration - Enhance product API route with JSON cache manager for local storage and retrieval - Introduce cache-only mode and detailed logging for better performance and offline support - Update UI to display cache status and storage statistics
This commit is contained in:
1 parent
bddf19e5c8
commit
c173fec668
19 files changed
+9240
-175
No files matched your search
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
|
||||
export function CacheOnlySwitch() {
|
||||
const [cacheOnly, setCacheOnly] = useState(false);
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
// Carregar preferência do localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("cache_only_mode");
|
||||
if (saved) {
|
||||
setCacheOnly(saved === "true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Salvar preferência
|
||||
const handleToggle = (checked: boolean) => {
|
||||
setCacheOnly(checked);
|
||||
localStorage.setItem("cache_only_mode", checked.toString());
|
||||
};
|
||||
|
||||
// Só exibir em produção
|
||||
if (!isProd) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
{cacheOnly ? (
|
||||
<Database className="w-4 h-4 text-blue-600" />
|
||||
) : (
|
||||
<Globe className="w-4 h-4 text-green-600" />
|
||||
)}
|
||||
|
||||
<Label htmlFor="cache-only" className="cursor-pointer">
|
||||
{cacheOnly ? "Modo Cache Local" : "Modo Online"}
|
||||
</Label>
|
||||
|
||||
<Switch
|
||||
id="cache-only"
|
||||
checked={cacheOnly}
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Filter, X, AlertTriangle, Zap } from "lucide-react";
|
||||
import {
|
||||
Filter,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Zap,
|
||||
Grid,
|
||||
List,
|
||||
RefreshCw,
|
||||
ArrowUpDown,
|
||||
LayoutGrid,
|
||||
Tag,
|
||||
Beaker,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -17,6 +30,8 @@ import {
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SortDropdown } from "@/app/components/SortDropdown";
|
||||
import { FilterBadges } from "@/app/components/FilterBadge";
|
||||
import {
|
||||
ProductFilters,
|
||||
NUTRITION_GRADES,
|
||||
@@ -32,6 +47,14 @@ interface FilterSidebarProps {
|
||||
isLoading?: boolean;
|
||||
totalResults?: number;
|
||||
className?: string;
|
||||
sortBy?: string;
|
||||
onSortChange?: (sortBy: string) => void;
|
||||
viewMode?: "grid" | "list";
|
||||
onViewModeChange?: (mode: "grid" | "list") => void;
|
||||
onRefresh?: () => void;
|
||||
onClearCache?: () => void;
|
||||
isCached?: boolean;
|
||||
onRemoveFilter?: (key: string, value?: string) => void;
|
||||
}
|
||||
|
||||
export function FilterSidebar({
|
||||
@@ -41,6 +64,14 @@ export function FilterSidebar({
|
||||
isLoading = false,
|
||||
totalResults = 0,
|
||||
className = "",
|
||||
sortBy = "relevance",
|
||||
onSortChange,
|
||||
viewMode = "grid",
|
||||
onViewModeChange,
|
||||
onRefresh,
|
||||
onClearCache,
|
||||
isCached = false,
|
||||
onRemoveFilter,
|
||||
}: FilterSidebarProps) {
|
||||
const [searchBrands, setSearchBrands] = useState("");
|
||||
const [searchCategories, setSearchCategories] = useState("");
|
||||
@@ -117,11 +148,116 @@ export function FilterSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controles de Visualização e Cache */}
|
||||
<div className="space-y-4">
|
||||
{/* Modo de Visualização */}
|
||||
{onViewModeChange && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Modo de visualização</div>
|
||||
<div className="flex items-center border border-gray-100 dark:border-gray-800 rounded-lg">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange("grid")}
|
||||
className="rounded-r-none flex-1"
|
||||
>
|
||||
<Grid className="w-4 h-4 mr-2" />
|
||||
Grade
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange("list")}
|
||||
className="rounded-l-none flex-1"
|
||||
>
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
Lista
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controles de Cache */}
|
||||
{(onRefresh || onClearCache) && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Controles</div>
|
||||
<div className="flex gap-2">
|
||||
{onRefresh && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
title="Atualizar busca"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${
|
||||
isLoading ? "animate-spin" : ""
|
||||
}`}
|
||||
/>
|
||||
Atualizar
|
||||
</Button>
|
||||
)}
|
||||
{onClearCache && isCached && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClearCache}
|
||||
className="flex-1"
|
||||
title="Limpar cache e refazer busca"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Limpar Cache
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filtros Ativos */}
|
||||
{onRemoveFilter && hasActiveFilters && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Filtros ativos</div>
|
||||
<FilterBadges
|
||||
filters={filters}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onClearAll={onClearFilters}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={["nutrition", "categories"]}
|
||||
defaultValue={["sorting"]}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* Ordenação */}
|
||||
<AccordionItem
|
||||
value="sorting"
|
||||
className="border border-gray-100 dark:border-gray-800 rounded-lg"
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="h-4 w-4 text-indigo-500" />
|
||||
<span className="font-medium">Ordenação</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4">
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium mb-3">Ordenar por</div>
|
||||
{onSortChange && (
|
||||
<SortDropdown
|
||||
value={sortBy}
|
||||
onValueChange={onSortChange}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
{/* Nutri-Score */}
|
||||
<AccordionItem
|
||||
value="nutrition"
|
||||
@@ -219,7 +355,7 @@ export function FilterSidebar({
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded bg-blue-500" />
|
||||
<LayoutGrid className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium">Categorias</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
@@ -286,7 +422,7 @@ export function FilterSidebar({
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded bg-purple-500" />
|
||||
<Tag className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium">Marcas</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
@@ -360,7 +496,7 @@ export function FilterSidebar({
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded bg-orange-500" />
|
||||
<Beaker className="h-4 w-4 text-orange-500" />
|
||||
<span className="font-medium">Aditivos</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
@@ -393,7 +529,7 @@ export function FilterSidebar({
|
||||
>
|
||||
<AccordionTrigger className="px-4 py-3 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 rounded bg-green-500" />
|
||||
<BarChart3 className="h-4 w-4 text-green-500" />
|
||||
<span className="font-medium">Valores Nutricionais</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface SearchBarProps {
|
||||
initialQuery?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
initialQuery = "",
|
||||
className = "",
|
||||
placeholder = "Buscar produtos...",
|
||||
}: SearchBarProps) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
router.push(`/resultados?q=${encodeURIComponent(query.trim())}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="pl-10 pr-4 h-10"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim()}
|
||||
className="h-10 px-4"
|
||||
>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Buscar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,12 +48,14 @@ interface SortDropdownProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
className?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
export function SortDropdown({
|
||||
value,
|
||||
onValueChange,
|
||||
className = "",
|
||||
align = "start",
|
||||
}: SortDropdownProps) {
|
||||
const selectedOption =
|
||||
SORT_OPTIONS.find((option) => option.value === value) || SORT_OPTIONS[0];
|
||||
@@ -73,43 +75,48 @@ export function SortDropdown({
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-72 border-gray-100 dark:border-gray-800"
|
||||
align={align}
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
avoidCollisions={false}
|
||||
className="w-64 max-w-[calc(100vw-2rem)] border-gray-100 dark:border-gray-800 z-50 p-0"
|
||||
>
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const IconComponent = SORT_ICONS[option.value];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => onValueChange(option.value)}
|
||||
className={`flex items-start gap-3 p-3 ${
|
||||
value === option.value ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Ícone */}
|
||||
<div className="mt-0.5">
|
||||
{IconComponent && (
|
||||
<IconComponent className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conteúdo */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 text-primary" />
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{SORT_OPTIONS.map((option) => {
|
||||
const IconComponent = SORT_ICONS[option.value];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
onClick={() => onValueChange(option.value)}
|
||||
className={`flex items-start gap-3 p-3 ${
|
||||
value === option.value ? "bg-accent" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Ícone */}
|
||||
<div className="mt-0.5">
|
||||
{IconComponent && (
|
||||
<IconComponent className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{option.description && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Conteúdo */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
{value === option.value && (
|
||||
<Check className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
{option.description && (
|
||||
<span className="text-xs text-muted-foreground mt-1 block">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user