feat(ui): enhance footer with NutriScan branding and fix database path

- Redesigned Footer.tsx with comprehensive layout including brand section, navigation links, social media icons, and contact info to improve user engagement and brand visibility
- Updated DATABASE_URL in .env to correct Prisma database path for proper configuration and connectivity
- Updated dependencies to support new UI components and ensure compatibility with enhanced features
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-09 00:14:30 -03:00
1 parent 8d6cbeb0bb
commit 9a4e4c762e
51 files changed
+7389 -218

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
/**
* Utilitário para detecção de país do usuário
* Usa detecção de IP server-side via API Route
*/
interface CountryInfo {
code: string;
name: string;
}
// Cache para evitar múltiplas requisições
const CACHE_KEY = "nutriscan_user_country";
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 horas
/**
* Verifica se o cache ainda é válido
*/
function isCacheValid(): boolean {
if (typeof window === "undefined") return false;
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return false;
const { timestamp } = JSON.parse(cached);
return Date.now() - timestamp < CACHE_DURATION;
} catch {
return false;
}
}
/**
* Obtém país do cache
*/
function getCachedCountry(): CountryInfo | null {
if (typeof window === "undefined") return null;
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) return null;
const { data } = JSON.parse(cached);
return data;
} catch {
return null;
}
}
/**
* Salva país no cache
*/
function saveToCache(country: CountryInfo): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(
CACHE_KEY,
JSON.stringify({
data: country,
timestamp: Date.now(),
})
);
} catch (error) {
console.warn("Erro ao salvar cache:", error);
}
}
/**
* Detecta o país do usuário usando detecção de IP server-side
* 1. Verifica cache local primeiro
* 2. Chama API Route /api/location que usa ip-api.com
* 3. Fallback para Brasil em caso de erro
*/
export async function detectUserCountry(
forceRefresh: boolean = false
): Promise<CountryInfo> {
// 1. Verificar cache primeiro (a menos que forceRefresh seja true)
if (!forceRefresh && isCacheValid()) {
const cached = getCachedCountry();
if (cached) {
return cached;
}
}
try {
// 2. Chamar API Route que detecta país por IP
const response = await fetch("/api/location");
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
const countryInfo: CountryInfo = {
code: data.countryCode,
name: data.country,
};
saveToCache(countryInfo);
return countryInfo;
} catch (error) {
console.warn("Country detection failed:", error);
// 3. Fallback: Brasil
const fallback: CountryInfo = { code: "br", name: "Brazil" };
saveToCache(fallback);
return fallback;
}
}
/**
* Limpa o cache de país
*/
export function clearCountryCache(): void {
if (typeof window === "undefined") return;
localStorage.removeItem(CACHE_KEY);
}
/**
* Obtém informações do país atual (síncrono)
* Retorna null se não houver cache
*/
export function getCurrentCountry(): CountryInfo | null {
if (isCacheValid()) {
return getCachedCountry();
}
return null;
}
/**
* Verifica se o usuário está no Brasil
*/
export function isBrazil(): boolean {
const country = getCurrentCountry();
return country?.code === "br" || country?.code === "brazil";
}
/**
* Obtém o código do país para a API Open Food Facts
*/
export function getCountryCodeForAPI(): string {
const country = getCurrentCountry();
return country?.code || "br";
}
+434
View File
@@ -0,0 +1,434 @@
/**
* Wrapper para a API Open Food Facts
* Centraliza todas as chamadas para a API e trata erros
*/
export interface Product {
code: string;
product_name: string;
brands?: string;
categories?: string;
ingredients_text?: string;
nutriments?: {
energy_100g?: number;
fat_100g?: number;
carbohydrates_100g?: number;
proteins_100g?: number;
sugars_100g?: number;
fiber_100g?: number;
sodium_100g?: number;
};
nutrition_grades?: string;
nova_group?: number;
image_url?: string;
image_small_url?: string;
image_front_url?: string;
image_front_small_url?: string;
countries?: string;
countries_tags?: string[];
allergens_tags?: string[];
additives_tags?: string[];
}
export interface SearchResult {
products: Product[];
count: number;
page: number;
page_size: number;
skip: number;
}
export interface ApiError {
message: string;
code?: string;
status?: number;
}
// Configurações da API - agora usando API Routes do Next.js
const API_BASE_URL = ""; // Usar rotas relativas para API Routes
/**
* Faz uma requisição para a API com tratamento de erros
*/
async function makeApiRequest<T>(endpoint: string): Promise<T> {
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.error || `HTTP ${response.status}: ${response.statusText}`
);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Erro na API:", error);
throw new Error(
error instanceof Error
? error.message
: "Erro desconhecido ao consultar a API"
);
}
}
/**
* Busca um produto específico por código de barras
*/
export async function searchByBarcode(
barcode: string
): Promise<Product | null> {
try {
// Limpar código de barras (remover espaços e caracteres não numéricos)
const cleanBarcode = barcode.replace(/\D/g, "");
if (cleanBarcode.length < 8 || cleanBarcode.length > 13) {
throw new Error("Código de barras deve ter entre 8 e 13 dígitos");
}
const data = await makeApiRequest<Product>(`/api/products/${cleanBarcode}`);
return data || null;
} catch (error) {
console.error("Erro ao buscar produto por código:", error);
return null;
}
}
/**
* Busca produtos por nome
*/
export async function searchByName(
query: string,
country?: string,
page: number = 1,
pageSize: number = 20
): Promise<SearchResult> {
try {
if (!query.trim()) {
throw new Error("Termo de busca não pode estar vazio");
}
// Construir parâmetros da URL para API Route
const params = new URLSearchParams({
q: query.trim(),
page: page.toString(),
page_size: pageSize.toString(),
});
// Adicionar filtro de país se especificado
if (country) {
params.append("country", country);
}
const data = await makeApiRequest<SearchResult>(
`/api/search?${params.toString()}`
);
return data;
} catch (error) {
console.error("Erro ao buscar produtos por nome:", error);
throw error;
}
}
/**
* Busca produtos por categoria
*/
export async function searchByCategory(
category: string,
country?: string,
page: number = 1,
pageSize: number = 20
): Promise<SearchResult> {
try {
const params = new URLSearchParams({
categories_tags_en: category,
page_size: pageSize.toString(),
page: page.toString(),
json: "true",
fields: [
"code",
"product_name",
"brands",
"categories",
"image_url",
"image_small_url",
"nutrition_grades",
"nova_group",
].join(","),
});
if (country) {
params.append("countries_tags_en", country);
}
// Chamada direta à API externa (temporária)
const response = await fetch(
`https://world.openfoodfacts.org/cgi/search.pl?${params.toString()}`,
{
headers: {
"User-Agent": "NutriScan/1.0 (https://nutriscan.com.br)",
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Erro ao buscar produtos por categoria:", error);
throw error;
}
}
/**
* Obtém categorias populares
*/
export async function getPopularCategories(
country?: string
): Promise<string[]> {
try {
const params = new URLSearchParams({
json: "true",
fields: "categories_tags",
});
if (country) {
params.append("countries_tags_en", country);
}
// Chamada direta à API externa (temporária)
const response = await fetch(
`https://world.openfoodfacts.org/cgi/search.pl?${params.toString()}`,
{
headers: {
"User-Agent": "NutriScan/1.0 (https://nutriscan.com.br)",
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
return (
data.facets?.categories_tags
?.slice(0, 20)
.map((cat: { name: string; count: number }) => cat.name) || []
);
} catch (error) {
console.error("Erro ao obter categorias:", error);
return [];
}
}
/**
* Valida se um código de barras tem formato válido
*/
export function isValidBarcode(barcode: string): boolean {
const cleanBarcode = barcode.replace(/\D/g, "");
return cleanBarcode.length >= 8 && cleanBarcode.length <= 13;
}
/**
* Detecta o tipo de busca baseado na entrada do usuário
*/
export function detectSearchType(query: string): "barcode" | "name" {
const cleanQuery = query.trim();
// Se contém apenas números e tem 8-13 dígitos, é código de barras
if (/^\d{8,13}$/.test(cleanQuery)) {
return "barcode";
}
// Caso contrário, é busca por nome
return "name";
}
/**
* Formata dados nutricionais para exibição
*/
export function formatNutritionData(product: Product) {
const nutriments = product.nutriments;
if (!nutriments) return null;
// Função auxiliar para converter e formatar valores
const formatValue = (
value: number | undefined,
decimals: number = 1
): string => {
if (value === undefined || value === null) return "N/A";
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return "N/A";
return decimals === 0
? `${Math.round(numValue)}`
: `${numValue.toFixed(decimals)}`;
};
return {
energy: nutriments.energy_100g
? `${formatValue(nutriments.energy_100g, 0)} kcal`
: "N/A",
fat: nutriments.fat_100g ? `${formatValue(nutriments.fat_100g)}g` : "N/A",
carbohydrates: nutriments.carbohydrates_100g
? `${formatValue(nutriments.carbohydrates_100g)}g`
: "N/A",
proteins: nutriments.proteins_100g
? `${formatValue(nutriments.proteins_100g)}g`
: "N/A",
sugars: nutriments.sugars_100g
? `${formatValue(nutriments.sugars_100g)}g`
: "N/A",
fiber: nutriments.fiber_100g
? `${formatValue(nutriments.fiber_100g)}g`
: "N/A",
sodium: nutriments.sodium_100g
? `${formatValue(nutriments.sodium_100g)}g`
: "N/A",
};
}
/**
* Obtém a URL da imagem do produto (prioriza front, depois small)
*/
export function getProductImageUrl(product: Product): string | null {
return (
product.image_front_url ||
product.image_front_small_url ||
product.image_url ||
product.image_small_url ||
null
);
}
/**
* Calcula pontuação de completude de um produto
*/
function calculateCompletenessScore(product: Product): number {
let score = 0;
if (product.product_name) score += 10;
if (product.brands) score += 10;
if (product.image_front_url || product.image_url) score += 15;
if (product.nutriments?.energy_100g) score += 20;
if (product.nutrition_grades) score += 15;
if (product.nova_group) score += 10;
if (product.ingredients_text) score += 10;
if (product.categories) score += 10;
return score;
}
/**
* Ordena produtos com base em critérios especiais
*/
export function sortProducts(
products: Product[],
sortBy: string,
sortOrder: "asc" | "desc" = "asc"
): Product[] {
const sorted = [...products].sort((a, b) => {
let comparison = 0;
switch (sortBy) {
case "with_images":
const aHasImage = !!(a.image_front_url || a.image_url);
const bHasImage = !!(b.image_front_url || b.image_url);
comparison = (bHasImage ? 1 : 0) - (aHasImage ? 1 : 0);
break;
case "with_nutrition":
const aHasNutrition = !!a.nutriments?.energy_100g;
const bHasNutrition = !!b.nutriments?.energy_100g;
comparison = (bHasNutrition ? 1 : 0) - (aHasNutrition ? 1 : 0);
break;
case "completeness":
const aScore = calculateCompletenessScore(a);
const bScore = calculateCompletenessScore(b);
comparison = bScore - aScore;
break;
case "nova_group":
comparison = (a.nova_group || 5) - (b.nova_group || 5);
break;
case "eco_score":
// Para eco_score, usar nutri-score como proxy (A = melhor eco)
const aGrade = a.nutrition_grades?.toLowerCase();
const bGrade = b.nutrition_grades?.toLowerCase();
const gradeOrder = { a: 1, b: 2, c: 3, d: 4, e: 5 };
const aGradeValue = gradeOrder[aGrade as keyof typeof gradeOrder] || 6;
const bGradeValue = gradeOrder[bGrade as keyof typeof gradeOrder] || 6;
comparison = aGradeValue - bGradeValue;
break;
case "relevance":
// Para relevância, usar completude + nutri-score
const aRelevance =
calculateCompletenessScore(a) + (a.nutrition_grades ? 20 : 0);
const bRelevance =
calculateCompletenessScore(b) + (b.nutrition_grades ? 20 : 0);
comparison = bRelevance - aRelevance;
break;
case "nutrition_grade":
const aNutriGrade = a.nutrition_grades?.toLowerCase();
const bNutriGrade = b.nutrition_grades?.toLowerCase();
const gradeOrderForNutri = { a: 1, b: 2, c: 3, d: 4, e: 5 };
const aNutriValue =
gradeOrderForNutri[aNutriGrade as keyof typeof gradeOrderForNutri] ||
6;
const bNutriValue =
gradeOrderForNutri[bNutriGrade as keyof typeof gradeOrderForNutri] ||
6;
comparison = aNutriValue - bNutriValue;
break;
case "name":
comparison = (a.product_name || "").localeCompare(b.product_name || "");
break;
case "brand":
comparison = (a.brands || "").localeCompare(b.brands || "");
break;
case "energy":
const aEnergy = a.nutriments?.energy_100g || 0;
const bEnergy = b.nutriments?.energy_100g || 0;
comparison = aEnergy - bEnergy;
break;
case "fat":
const aFat = a.nutriments?.fat_100g || 0;
const bFat = b.nutriments?.fat_100g || 0;
comparison = aFat - bFat;
break;
case "sugars":
const aSugars = a.nutriments?.sugars_100g || 0;
const bSugars = b.nutriments?.sugars_100g || 0;
comparison = aSugars - bSugars;
break;
default:
comparison = 0;
}
return sortOrder === "asc" ? comparison : -comparison;
});
return sorted;
}
+282
View File
@@ -0,0 +1,282 @@
/**
* Tipos para sistema de filtros de produtos
* Baseado na API do Open Food Facts
*/
export interface ProductFilters {
// Filtros de classificação nutricional
nutritionGrades?: ("a" | "b" | "c" | "d" | "e")[];
novaGroups?: (1 | 2 | 3 | 4)[];
// Filtros de categoria e marca
categories?: string[];
brands?: string[];
// Filtros de ingredientes e aditivos
allergens?: string[];
excludeAllergens?: string[];
additives?: string[];
excludeAdditives?: string[];
noAdditives?: boolean;
// Filtros nutricionais (valores por 100g)
nutritionRanges?: {
energy?: { min?: number; max?: number };
fat?: { min?: number; max?: number };
carbohydrates?: { min?: number; max?: number };
proteins?: { min?: number; max?: number };
sugars?: { min?: number; max?: number };
fiber?: { min?: number; max?: number };
sodium?: { min?: number; max?: number };
};
// Filtros de país
countries?: string[];
// Ordenação
sortBy?:
| "relevance"
| "nutrition_grade"
| "name"
| "brand"
| "energy"
| "fat"
| "sugars";
sortOrder?: "asc" | "desc";
}
export interface FilterOption {
value: string;
label: string;
count?: number;
color?: string;
description?: string;
}
export interface NutritionGrade {
grade: "a" | "b" | "c" | "d" | "e";
label: string;
color: string;
description: string;
}
export interface NovaGroup {
group: 1 | 2 | 3 | 4;
label: string;
color: string;
description: string;
}
export interface SortOption {
value: string;
label: string;
description?: string;
icon?: string;
}
export interface FilterState {
filters: ProductFilters;
isLoading: boolean;
hasActiveFilters: boolean;
totalResults: number;
}
export interface FilterSidebarProps {
filters: ProductFilters;
onFiltersChange: (filters: ProductFilters) => void;
onClearFilters: () => void;
isLoading?: boolean;
totalResults?: number;
}
// Constantes para configuração dos filtros
export const NUTRITION_GRADES: NutritionGrade[] = [
{
grade: "a",
label: "A",
color: "bg-green-500",
description: "Muito bom",
},
{
grade: "b",
label: "B",
color: "bg-green-400",
description: "Bom",
},
{
grade: "c",
label: "C",
color: "bg-yellow-500",
description: "Regular",
},
{
grade: "d",
label: "D",
color: "bg-orange-500",
description: "Ruim",
},
{
grade: "e",
label: "E",
color: "bg-red-500",
description: "Muito ruim",
},
];
export const NOVA_GROUPS: NovaGroup[] = [
{
group: 1,
label: "1",
color: "bg-green-500",
description: "Alimentos não processados ou minimamente processados",
},
{
group: 2,
label: "2",
color: "bg-yellow-500",
description: "Ingredientes culinários processados",
},
{
group: 3,
label: "3",
color: "bg-orange-500",
description: "Alimentos processados",
},
{
group: 4,
label: "4",
color: "bg-red-500",
description: "Alimentos ultraprocessados",
},
];
export const SORT_OPTIONS: SortOption[] = [
{
value: "relevance",
label: "Relevância",
description: "Resultados mais relevantes primeiro",
icon: "TrendingUp",
},
{
value: "nutrition_grade",
label: "Nutri-Score",
description: "Melhor classificação nutricional primeiro",
icon: "Zap",
},
{
value: "name",
label: "Nome (A-Z)",
description: "Ordenar por nome do produto",
icon: "AlphabeticalOrder",
},
{
value: "brand",
label: "Marca (A-Z)",
description: "Ordenar por marca",
icon: "Building2",
},
{
value: "energy",
label: "Calorias",
description: "Menor valor energético primeiro",
icon: "Flame",
},
{
value: "fat",
label: "Gorduras",
description: "Menor teor de gordura primeiro",
icon: "Droplet",
},
{
value: "sugars",
label: "Açúcares",
description: "Menor teor de açúcar primeiro",
icon: "Candy",
},
{
value: "completeness",
label: "Completude",
description: "Produtos com mais informações primeiro",
icon: "FileCheck",
},
{
value: "with_images",
label: "Com Fotos",
description: "Produtos com imagens primeiro",
icon: "Image",
},
{
value: "with_nutrition",
label: "Com Info Nutricional",
description: "Produtos com dados nutricionais completos",
icon: "Activity",
},
{
value: "eco_score",
label: "Eco-Score",
description: "Melhor impacto ambiental primeiro",
icon: "Leaf",
},
{
value: "nova_group",
label: "Nível de Processamento",
description: "Menos processados primeiro",
icon: "Layers",
},
];
// Categorias populares para filtros
export const POPULAR_CATEGORIES = [
"Beverages",
"Dairy",
"Snacks",
"Bakery",
"Meat",
"Fruits",
"Vegetables",
"Cereals",
"Sweets",
"Condiments",
"Frozen foods",
"Canned foods",
];
// Categorias com ícones para interface
export const POPULAR_CATEGORIES_WITH_ICONS = [
{ value: "Beverages", label: "Bebidas", icon: "Coffee" },
{ value: "Dairy", label: "Laticínios", icon: "Milk" },
{ value: "Snacks", label: "Snacks", icon: "Cookie" },
{ value: "Bakery", label: "Padaria", icon: "Croissant" },
{ value: "Meat", label: "Carnes", icon: "Beef" },
{ value: "Fruits", label: "Frutas", icon: "Apple" },
{ value: "Vegetables", label: "Vegetais", icon: "Carrot" },
{ value: "Cereals", label: "Cereais", icon: "Wheat" },
{ value: "Sweets", label: "Doces", icon: "Candy" },
{ value: "Condiments", label: "Condimentos", icon: "Soup" },
{ value: "Frozen foods", label: "Congelados", icon: "Snowflake" },
{ value: "Canned foods", label: "Enlatados", icon: "Package" },
];
// Alérgenos comuns
export const COMMON_ALLERGENS = [
"gluten",
"milk",
"eggs",
"nuts",
"peanuts",
"soy",
"fish",
"shellfish",
"sesame",
];
// Aditivos comuns
export const COMMON_ADDITIVES = [
"preservatives",
"artificial colors",
"artificial flavors",
"sweeteners",
"emulsifiers",
"stabilizers",
"thickeners",
];