mirror of
https://github.com/domfelipe/chef-zero.git
synced 2026-08-07 04:16:45 +00:00
1076 lines
42 KiB
HTML
1076 lines
42 KiB
HTML
<!doctype html>
|
|
<html lang="pt-BR">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta
|
|
name="description"
|
|
content="ChefZero ajuda a encontrar receitas com ingredientes que ja existem em casa, usando busca local e uma inspiracao diaria via API publica."
|
|
/>
|
|
<title>ChefZero | Cozinha sem desperdicio</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700;800&family=Geist+Mono:wght@500;600&display=swap"
|
|
rel="stylesheet"
|
|
/>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
theme: {
|
|
extend: {
|
|
fontFamily: {
|
|
sans: ['Geist', 'ui-sans-serif', 'system-ui'],
|
|
mono: ['Geist Mono', 'ui-monospace', 'SFMono-Regular']
|
|
},
|
|
colors: {
|
|
paper: '#f6f1e7',
|
|
ink: '#1f211d',
|
|
olive: '#596b45',
|
|
clay: '#b96141',
|
|
butter: '#d6a84d'
|
|
}
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--paper: #f6f1e7;
|
|
--paper-deep: #ece3d2;
|
|
--ink: #1f211d;
|
|
--muted: #6d7066;
|
|
--line: rgba(31, 33, 29, 0.14);
|
|
--olive: #596b45;
|
|
--clay: #b96141;
|
|
--butter: #d6a84d;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html {
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
body {
|
|
min-height: 100dvh;
|
|
margin: 0;
|
|
background:
|
|
linear-gradient(90deg, rgba(31, 33, 29, 0.04) 1px, transparent 1px) 0 0 / 48px 48px,
|
|
linear-gradient(rgba(31, 33, 29, 0.04) 1px, transparent 1px) 0 0 / 48px 48px,
|
|
radial-gradient(circle at 85% 10%, rgba(214, 168, 77, 0.2), transparent 30%),
|
|
var(--paper);
|
|
color: var(--ink);
|
|
font-family: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
|
sans-serif;
|
|
}
|
|
|
|
body::before {
|
|
content: '';
|
|
position: fixed;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
opacity: 0.25;
|
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='0.36'/%3E%3C/svg%3E");
|
|
mix-blend-mode: multiply;
|
|
}
|
|
|
|
button,
|
|
input {
|
|
font: inherit;
|
|
}
|
|
|
|
button {
|
|
transition: transform 180ms ease, border-color 180ms ease, background 180ms ease,
|
|
color 180ms ease, opacity 180ms ease;
|
|
}
|
|
|
|
button:active {
|
|
transform: translateY(1px) scale(0.99);
|
|
}
|
|
|
|
.surface {
|
|
min-width: 0;
|
|
border: 1px solid var(--line);
|
|
background: rgba(255, 252, 245, 0.76);
|
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55),
|
|
0 24px 70px rgba(80, 67, 42, 0.12);
|
|
backdrop-filter: blur(18px);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.soft-panel {
|
|
min-width: 0;
|
|
border: 1px solid rgba(31, 33, 29, 0.12);
|
|
background: rgba(255, 252, 245, 0.58);
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.recipe-card {
|
|
animation: rise 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
animation-delay: calc(var(--index, 0) * 70ms);
|
|
}
|
|
|
|
@keyframes rise {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(14px);
|
|
}
|
|
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.skeleton {
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: rgba(31, 33, 29, 0.08);
|
|
}
|
|
|
|
.skeleton::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
transform: translateX(-100%);
|
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.55), transparent);
|
|
animation: shimmer 1.4s infinite;
|
|
}
|
|
|
|
@keyframes shimmer {
|
|
to {
|
|
transform: translateX(100%);
|
|
}
|
|
}
|
|
|
|
.status-dot {
|
|
width: 0.55rem;
|
|
height: 0.55rem;
|
|
border-radius: 999px;
|
|
background: var(--butter);
|
|
box-shadow: 0 0 0 0 rgba(214, 168, 77, 0.4);
|
|
animation: breathe 2.2s ease-in-out infinite;
|
|
}
|
|
|
|
.status-dot[data-state='online'] {
|
|
background: var(--olive);
|
|
}
|
|
|
|
.status-dot[data-state='offline'] {
|
|
background: var(--clay);
|
|
}
|
|
|
|
@keyframes breathe {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(89, 107, 69, 0.24);
|
|
}
|
|
|
|
50% {
|
|
box-shadow: 0 0 0 8px rgba(89, 107, 69, 0);
|
|
}
|
|
}
|
|
|
|
.modal {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 180ms ease;
|
|
}
|
|
|
|
.modal[data-open='true'] {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.modal[data-open='true'] .modal-panel {
|
|
transform: translateY(0) scale(1);
|
|
}
|
|
|
|
.modal-panel {
|
|
transform: translateY(14px) scale(0.98);
|
|
transition: transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0.001ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
scroll-behavior: auto !important;
|
|
transition-duration: 0.001ms !important;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="relative mx-auto flex min-h-[100dvh] w-full max-w-[1500px] flex-col px-4 py-4 sm:px-6 lg:px-8">
|
|
<header class="surface mb-4 flex items-center justify-between gap-4 px-4 py-3">
|
|
<a class="flex min-w-0 items-center gap-3" href="./" aria-label="ChefZero">
|
|
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-[8px] bg-ink text-paper">
|
|
<i data-lucide="chef-hat" class="h-5 w-5"></i>
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block truncate text-lg font-bold tracking-tight">ChefZero</span>
|
|
<span class="block truncate text-xs font-medium uppercase tracking-[0.16em] text-stone-500"
|
|
>cozinha sem desperdicio</span
|
|
>
|
|
</span>
|
|
</a>
|
|
|
|
<div class="hidden items-center gap-3 text-sm text-stone-600 md:flex">
|
|
<span class="flex items-center gap-2 rounded-[8px] border border-black/10 bg-white/40 px-3 py-2">
|
|
<i data-lucide="database" class="h-4 w-4"></i>
|
|
Base local
|
|
</span>
|
|
<span id="apiPill" class="flex items-center gap-2 rounded-[8px] border border-black/10 bg-white/40 px-3 py-2">
|
|
<span id="apiDot" class="status-dot" data-state="loading"></span>
|
|
<span id="apiStatus">Conectando API</span>
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="grid flex-1 grid-cols-[minmax(0,1fr)] gap-4 lg:grid-cols-[minmax(0,1.7fr)_minmax(360px,0.8fr)]">
|
|
<section class="surface flex min-h-[calc(100dvh-112px)] min-w-0 flex-col overflow-hidden">
|
|
<div class="grid gap-6 border-b border-black/10 p-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(300px,0.9fr)] lg:p-7">
|
|
<div class="flex flex-col justify-between gap-5">
|
|
<div>
|
|
<p class="mb-3 text-sm font-semibold uppercase tracking-[0.18em] text-clay">
|
|
Motor local com API keyless
|
|
</p>
|
|
<h1 class="max-w-3xl text-4xl font-black leading-[0.96] tracking-tight text-ink md:text-6xl">
|
|
Cozinhe com o que ja existe na sua cozinha.
|
|
</h1>
|
|
</div>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-3">
|
|
<div class="soft-panel px-4 py-3">
|
|
<span id="recipeCount" class="font-mono text-2xl font-semibold">0</span>
|
|
<p class="mt-1 text-sm text-stone-600">receitas ranqueadas</p>
|
|
</div>
|
|
<div class="soft-panel px-4 py-3">
|
|
<span id="savingScore" class="font-mono text-2xl font-semibold">R$ 0</span>
|
|
<p class="mt-1 text-sm text-stone-600">potencial poupado</p>
|
|
</div>
|
|
<div class="soft-panel px-4 py-3">
|
|
<span id="matchAverage" class="font-mono text-2xl font-semibold">0%</span>
|
|
<p class="mt-1 text-sm text-stone-600">encaixe medio</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="ingredientForm" class="soft-panel flex flex-col gap-4 p-4" autocomplete="off">
|
|
<div class="space-y-2">
|
|
<label class="text-sm font-semibold text-ink" for="ingredientInput">Ingredientes em casa</label>
|
|
<div class="flex gap-2">
|
|
<input
|
|
id="ingredientInput"
|
|
class="min-w-0 flex-1 rounded-[8px] border border-black/15 bg-white/70 px-3 py-3 text-base outline-none transition focus:border-olive focus:ring-4 focus:ring-olive/15"
|
|
placeholder="arroz, ovos, tomate"
|
|
type="text"
|
|
/>
|
|
<button
|
|
class="grid h-[50px] w-[50px] shrink-0 place-items-center rounded-[8px] bg-ink text-paper hover:bg-olive"
|
|
type="submit"
|
|
aria-label="Adicionar ingrediente"
|
|
>
|
|
<i data-lucide="plus" class="h-5 w-5"></i>
|
|
</button>
|
|
</div>
|
|
<p id="inputError" class="min-h-5 text-sm font-medium text-clay"></p>
|
|
</div>
|
|
|
|
<div>
|
|
<div id="selectedIngredients" class="flex min-h-[42px] flex-wrap gap-2"></div>
|
|
</div>
|
|
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
data-filter="rapido"
|
|
class="filter-btn rounded-[8px] border border-black/10 bg-white/55 px-3 py-2 text-sm font-semibold hover:border-olive"
|
|
>
|
|
rapido
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-filter="barato"
|
|
class="filter-btn rounded-[8px] border border-black/10 bg-white/55 px-3 py-2 text-sm font-semibold hover:border-olive"
|
|
>
|
|
barato
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-filter="vegetariano"
|
|
class="filter-btn rounded-[8px] border border-black/10 bg-white/55 px-3 py-2 text-sm font-semibold hover:border-olive"
|
|
>
|
|
vegetariano
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-filter="uma-panela"
|
|
class="filter-btn rounded-[8px] border border-black/10 bg-white/55 px-3 py-2 text-sm font-semibold hover:border-olive"
|
|
>
|
|
uma panela
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="grid gap-4 border-b border-black/10 px-5 py-4 md:grid-cols-[auto_1fr_auto] lg:px-7">
|
|
<div class="flex items-center gap-2 text-sm font-semibold text-stone-700">
|
|
<i data-lucide="sparkles" class="h-4 w-4 text-clay"></i>
|
|
Sugestoes rapidas
|
|
</div>
|
|
<div id="suggestions" class="flex flex-wrap gap-2"></div>
|
|
<button
|
|
id="resetBtn"
|
|
type="button"
|
|
class="rounded-[8px] border border-black/10 bg-white/45 px-3 py-2 text-sm font-semibold text-stone-700 hover:border-clay hover:text-clay"
|
|
>
|
|
limpar
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 overflow-auto p-5 lg:p-7">
|
|
<div id="emptyState" class="hidden min-h-[320px] place-items-center text-center">
|
|
<div class="max-w-md">
|
|
<div class="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-[8px] bg-ink text-paper">
|
|
<i data-lucide="search" class="h-6 w-6"></i>
|
|
</div>
|
|
<h2 class="text-2xl font-bold tracking-tight">Nenhuma receita encontrada</h2>
|
|
<p class="mt-2 text-stone-600">
|
|
Ajuste os ingredientes ou remova filtros para ampliar as combinacoes disponiveis.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div id="recipeGrid" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<aside class="grid min-w-0 gap-4 lg:grid-rows-[auto_1fr_auto]">
|
|
<section class="surface overflow-hidden">
|
|
<div class="flex min-w-0 items-center justify-between gap-3 border-b border-black/10 px-4 py-4 sm:px-5">
|
|
<div class="min-w-0">
|
|
<h2 class="text-lg font-bold tracking-tight">Inspiracao do Dia</h2>
|
|
<p class="truncate text-sm text-stone-600">TheMealDB, sem chave de acesso</p>
|
|
</div>
|
|
<button
|
|
id="refreshApiBtn"
|
|
class="grid h-10 w-10 shrink-0 place-items-center rounded-[8px] border border-black/10 bg-white/50 hover:border-olive"
|
|
type="button"
|
|
aria-label="Atualizar inspiracao"
|
|
>
|
|
<i data-lucide="refresh-cw" class="h-4 w-4"></i>
|
|
</button>
|
|
</div>
|
|
<div id="apiCard" class="p-5">
|
|
<div class="skeleton mb-4 aspect-[4/3] rounded-[8px]"></div>
|
|
<div class="skeleton mb-2 h-5 w-2/3 rounded-[8px]"></div>
|
|
<div class="skeleton h-4 w-full rounded-[8px]"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="surface p-5">
|
|
<div class="mb-4 flex items-center justify-between gap-3">
|
|
<h2 class="text-lg font-bold tracking-tight">Plano de cozinha</h2>
|
|
<i data-lucide="list-checks" class="h-5 w-5 text-olive"></i>
|
|
</div>
|
|
<div id="kitchenPlan" class="space-y-3"></div>
|
|
</section>
|
|
|
|
<section class="surface p-5">
|
|
<div class="flex items-start gap-3">
|
|
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-[8px] bg-olive text-white">
|
|
<i data-lucide="shield-check" class="h-5 w-5"></i>
|
|
</div>
|
|
<div>
|
|
<h2 class="text-base font-bold">Arquitetura sem secrets</h2>
|
|
<p class="mt-1 text-sm leading-6 text-stone-600">
|
|
A busca principal usa dados locais auditaveis. A API publica apenas enriquece a experiencia,
|
|
entao a pagina continua funcionando mesmo se o servico externo cair.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
</main>
|
|
</div>
|
|
|
|
<div id="recipeModal" class="modal fixed inset-0 z-40 grid place-items-end bg-ink/45 p-3 md:place-items-center" data-open="false">
|
|
<article
|
|
class="modal-panel max-h-[92dvh] w-full max-w-3xl overflow-hidden rounded-[8px] bg-[#fffaf0] shadow-2xl"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="modalTitle"
|
|
>
|
|
<div class="flex items-center justify-between gap-3 border-b border-black/10 px-5 py-4">
|
|
<h2 id="modalTitle" class="text-xl font-bold tracking-tight"></h2>
|
|
<button
|
|
id="closeModalBtn"
|
|
class="grid h-10 w-10 shrink-0 place-items-center rounded-[8px] border border-black/10 bg-white/60 hover:border-clay"
|
|
type="button"
|
|
aria-label="Fechar detalhes"
|
|
>
|
|
<i data-lucide="x" class="h-5 w-5"></i>
|
|
</button>
|
|
</div>
|
|
<div id="modalBody" class="max-h-[calc(92dvh-73px)] overflow-auto p-5"></div>
|
|
</article>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
<script>
|
|
const recipes = [
|
|
{
|
|
id: 'arroz-cremoso-forno',
|
|
name: 'Arroz cremoso de forno',
|
|
image: 'https://www.themealdb.com/images/media/meals/wvpsxx1468256321.jpg',
|
|
time: 24,
|
|
cost: 14.8,
|
|
servings: 3,
|
|
difficulty: 'facil',
|
|
tags: ['barato', 'uma-panela', 'almoco'],
|
|
ingredients: ['arroz', 'ovos', 'tomate', 'queijo', 'cebola'],
|
|
steps: [
|
|
'Refogue cebola e tomate ate formar um molho curto.',
|
|
'Misture arroz cozido, ovos batidos e queijo ralado.',
|
|
'Leve ao forno alto ate a superficie ficar firme e dourada.'
|
|
]
|
|
},
|
|
{
|
|
id: 'omelete-verde',
|
|
name: 'Omelete verde de frigideira',
|
|
image: 'https://www.themealdb.com/images/media/meals/1548772327.jpg',
|
|
time: 12,
|
|
cost: 9.4,
|
|
servings: 2,
|
|
difficulty: 'facil',
|
|
tags: ['rapido', 'barato', 'vegetariano'],
|
|
ingredients: ['ovos', 'espinafre', 'cebola', 'tomate', 'queijo'],
|
|
steps: [
|
|
'Bata os ovos com sal e pimenta.',
|
|
'Salteie cebola, tomate e espinafre por dois minutos.',
|
|
'Junte os ovos e cozinhe em fogo baixo ate firmar.'
|
|
]
|
|
},
|
|
{
|
|
id: 'macarrao-alho-couve',
|
|
name: 'Macarrao alho e couve',
|
|
image: 'https://www.themealdb.com/images/media/meals/ustsqw1468250014.jpg',
|
|
time: 18,
|
|
cost: 12.2,
|
|
servings: 2,
|
|
difficulty: 'facil',
|
|
tags: ['rapido', 'barato', 'vegetariano'],
|
|
ingredients: ['macarrao', 'alho', 'couve', 'azeite', 'limão'],
|
|
steps: [
|
|
'Cozinhe o macarrao e reserve meia concha da agua.',
|
|
'Doure alho no azeite e entre com a couve fatiada.',
|
|
'Misture a massa, ajuste com agua do cozimento e finalize com limao.'
|
|
]
|
|
},
|
|
{
|
|
id: 'sopa-abobora-lentilha',
|
|
name: 'Sopa de abobora e lentilha',
|
|
image: 'https://www.themealdb.com/images/media/meals/stpuws1511191310.jpg',
|
|
time: 32,
|
|
cost: 16.9,
|
|
servings: 4,
|
|
difficulty: 'media',
|
|
tags: ['barato', 'vegetariano', 'uma-panela'],
|
|
ingredients: ['abóbora', 'lentilha', 'cebola', 'alho', 'cenoura'],
|
|
steps: [
|
|
'Refogue cebola, alho e cenoura em uma panela funda.',
|
|
'Adicione abobora em cubos, lentilha e agua ate cobrir.',
|
|
'Cozinhe ate amaciar e bata parcialmente para dar corpo.'
|
|
]
|
|
},
|
|
{
|
|
id: 'frango-panela-limao',
|
|
name: 'Frango de panela com limao',
|
|
image: 'https://www.themealdb.com/images/media/meals/1520084413.jpg',
|
|
time: 28,
|
|
cost: 22.6,
|
|
servings: 3,
|
|
difficulty: 'media',
|
|
tags: ['uma-panela', 'almoco'],
|
|
ingredients: ['frango', 'limão', 'alho', 'arroz', 'cebola'],
|
|
steps: [
|
|
'Tempere o frango com limao, alho e sal.',
|
|
'Doure na panela, junte cebola e pingue agua aos poucos.',
|
|
'Sirva com arroz e o caldo reduzido por cima.'
|
|
]
|
|
},
|
|
{
|
|
id: 'salada-grao-bico',
|
|
name: 'Salada quente de grao-de-bico',
|
|
image: 'https://www.themealdb.com/images/media/meals/ysxwuq1487323065.jpg',
|
|
time: 20,
|
|
cost: 17.5,
|
|
servings: 2,
|
|
difficulty: 'facil',
|
|
tags: ['rapido', 'vegetariano'],
|
|
ingredients: ['grão-de-bico', 'tomate', 'cebola', 'cenoura', 'azeite'],
|
|
steps: [
|
|
'Aqueça o grao-de-bico cozido em frigideira larga.',
|
|
'Entre com tomate, cebola e cenoura em cubos pequenos.',
|
|
'Finalize com azeite, sal e ervas.'
|
|
]
|
|
},
|
|
{
|
|
id: 'panqueca-banana-aveia',
|
|
name: 'Panqueca de banana e aveia',
|
|
image: 'https://www.themealdb.com/images/media/meals/rwuyqx1511383174.jpg',
|
|
time: 10,
|
|
cost: 8.7,
|
|
servings: 2,
|
|
difficulty: 'facil',
|
|
tags: ['rapido', 'barato', 'vegetariano'],
|
|
ingredients: ['banana', 'aveia', 'ovos', 'leite', 'canela'],
|
|
steps: [
|
|
'Amasse banana e misture com ovos, aveia e leite.',
|
|
'Despeje porcoes pequenas em frigideira untada.',
|
|
'Doure os dois lados e finalize com canela.'
|
|
]
|
|
},
|
|
{
|
|
id: 'risoto-tomate-feijao',
|
|
name: 'Risoto rustico de tomate e feijao',
|
|
image: 'https://www.themealdb.com/images/media/meals/1549542877.jpg',
|
|
time: 26,
|
|
cost: 13.6,
|
|
servings: 3,
|
|
difficulty: 'media',
|
|
tags: ['barato', 'uma-panela'],
|
|
ingredients: ['arroz', 'feijão', 'tomate', 'cebola', 'queijo'],
|
|
steps: [
|
|
'Refogue cebola e tomate ate quase desmanchar.',
|
|
'Junte arroz cozido, feijao com caldo e misture em fogo medio.',
|
|
'Finalize com queijo e deixe descansar por tres minutos.'
|
|
]
|
|
},
|
|
{
|
|
id: 'tapioca-recheada',
|
|
name: 'Tapioca de queijo e tomate',
|
|
image: 'https://www.themealdb.com/images/media/meals/58oia61564916529.jpg',
|
|
time: 9,
|
|
cost: 7.9,
|
|
servings: 1,
|
|
difficulty: 'facil',
|
|
tags: ['rapido', 'barato', 'vegetariano'],
|
|
ingredients: ['tapioca', 'queijo', 'tomate', 'orégano'],
|
|
steps: [
|
|
'Espalhe a goma de tapioca em frigideira quente.',
|
|
'Vire quando firmar e adicione queijo, tomate e oregano.',
|
|
'Dobre ao meio e aqueça ate o queijo derreter.'
|
|
]
|
|
},
|
|
{
|
|
id: 'bolinho-arroz-airfryer',
|
|
name: 'Bolinho de arroz sem fritura',
|
|
image: 'https://www.themealdb.com/images/media/meals/1529443236.jpg',
|
|
time: 22,
|
|
cost: 11.1,
|
|
servings: 3,
|
|
difficulty: 'facil',
|
|
tags: ['barato', 'vegetariano'],
|
|
ingredients: ['arroz', 'ovos', 'queijo', 'cenoura', 'farinha'],
|
|
steps: [
|
|
'Misture arroz, ovos, queijo, cenoura ralada e farinha.',
|
|
'Modele bolinhos compactos com as maos umidas.',
|
|
'Asse em forno alto ou airfryer ate dourar.'
|
|
]
|
|
},
|
|
{
|
|
id: 'mexido-feijao-couve',
|
|
name: 'Mexido de feijao com couve',
|
|
image: 'https://www.themealdb.com/images/media/meals/wtsvxx1511296896.jpg',
|
|
time: 16,
|
|
cost: 10.5,
|
|
servings: 2,
|
|
difficulty: 'facil',
|
|
tags: ['rapido', 'barato', 'uma-panela'],
|
|
ingredients: ['feijão', 'arroz', 'couve', 'alho', 'ovos'],
|
|
steps: [
|
|
'Doure alho e junte couve fatiada.',
|
|
'Adicione arroz e feijao, mexendo ate aquecer.',
|
|
'Abra espaço na panela, cozinhe os ovos e misture.'
|
|
]
|
|
},
|
|
{
|
|
id: 'creme-milho-frango',
|
|
name: 'Creme de milho com frango',
|
|
image: 'https://www.themealdb.com/images/media/meals/qrqywr1503066605.jpg',
|
|
time: 25,
|
|
cost: 20.3,
|
|
servings: 3,
|
|
difficulty: 'media',
|
|
tags: ['uma-panela', 'almoco'],
|
|
ingredients: ['milho', 'frango', 'leite', 'cebola', 'arroz'],
|
|
steps: [
|
|
'Bata milho com leite ate formar um creme.',
|
|
'Refogue cebola e frango desfiado em uma panela.',
|
|
'Junte o creme, cozinhe ate engrossar e sirva com arroz.'
|
|
]
|
|
}
|
|
];
|
|
|
|
const suggestions = [
|
|
'arroz',
|
|
'ovos',
|
|
'tomate',
|
|
'cebola',
|
|
'alho',
|
|
'queijo',
|
|
'feijão',
|
|
'couve',
|
|
'frango',
|
|
'banana',
|
|
'aveia',
|
|
'lentilha'
|
|
];
|
|
|
|
const state = {
|
|
selected: ['arroz', 'ovos', 'tomate'],
|
|
activeFilter: null,
|
|
currentResults: [],
|
|
inspiration: null
|
|
};
|
|
|
|
const el = {
|
|
form: document.getElementById('ingredientForm'),
|
|
input: document.getElementById('ingredientInput'),
|
|
inputError: document.getElementById('inputError'),
|
|
selected: document.getElementById('selectedIngredients'),
|
|
suggestions: document.getElementById('suggestions'),
|
|
grid: document.getElementById('recipeGrid'),
|
|
empty: document.getElementById('emptyState'),
|
|
recipeCount: document.getElementById('recipeCount'),
|
|
savingScore: document.getElementById('savingScore'),
|
|
matchAverage: document.getElementById('matchAverage'),
|
|
kitchenPlan: document.getElementById('kitchenPlan'),
|
|
apiCard: document.getElementById('apiCard'),
|
|
apiStatus: document.getElementById('apiStatus'),
|
|
apiDot: document.getElementById('apiDot'),
|
|
refreshApiBtn: document.getElementById('refreshApiBtn'),
|
|
resetBtn: document.getElementById('resetBtn'),
|
|
modal: document.getElementById('recipeModal'),
|
|
modalTitle: document.getElementById('modalTitle'),
|
|
modalBody: document.getElementById('modalBody'),
|
|
closeModalBtn: document.getElementById('closeModalBtn')
|
|
};
|
|
|
|
function normalize(value) {
|
|
return value
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function currency(value) {
|
|
return value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
}
|
|
|
|
function ingredientTokens(value) {
|
|
return value
|
|
.split(/[,;\n]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function addIngredient(raw) {
|
|
const name = raw.trim().replace(/\s+/g, ' ');
|
|
if (name.length < 2) {
|
|
el.inputError.textContent = 'Use pelo menos duas letras.';
|
|
return;
|
|
}
|
|
|
|
if (state.selected.some((item) => normalize(item) === normalize(name))) {
|
|
el.inputError.textContent = 'Ingrediente ja esta na lista.';
|
|
return;
|
|
}
|
|
|
|
state.selected.push(name);
|
|
el.inputError.textContent = '';
|
|
render();
|
|
}
|
|
|
|
function removeIngredient(name) {
|
|
state.selected = state.selected.filter((item) => normalize(item) !== normalize(name));
|
|
render();
|
|
}
|
|
|
|
function scoreRecipe(recipe) {
|
|
const selected = state.selected.map(normalize);
|
|
const recipeIngredients = recipe.ingredients.map(normalize);
|
|
const matches = recipeIngredients.filter((item) => selected.includes(item));
|
|
const missing = recipe.ingredients.filter((item) => !selected.includes(normalize(item)));
|
|
const matchRate = recipe.ingredients.length ? matches.length / recipe.ingredients.length : 0;
|
|
const filterBonus = state.activeFilter && recipe.tags.includes(state.activeFilter) ? 0.28 : 0;
|
|
const quickBonus = recipe.time <= 18 ? 0.08 : 0;
|
|
const cheapBonus = recipe.cost <= 13 ? 0.08 : 0;
|
|
const score = matchRate + filterBonus + quickBonus + cheapBonus;
|
|
return { ...recipe, matches, missing, matchRate, score };
|
|
}
|
|
|
|
function getResults() {
|
|
const ranked = recipes
|
|
.map(scoreRecipe)
|
|
.filter((recipe) => {
|
|
if (state.activeFilter && !recipe.tags.includes(state.activeFilter)) return false;
|
|
if (state.selected.length === 0) return true;
|
|
return recipe.matches.length > 0;
|
|
})
|
|
.sort((a, b) => b.score - a.score || a.cost - b.cost || a.time - b.time);
|
|
|
|
return ranked;
|
|
}
|
|
|
|
function renderSelected() {
|
|
el.selected.innerHTML = '';
|
|
state.selected.forEach((item) => {
|
|
const chip = document.createElement('button');
|
|
chip.type = 'button';
|
|
chip.className =
|
|
'flex items-center gap-2 rounded-[8px] border border-olive/25 bg-olive/10 px-3 py-2 text-sm font-semibold text-olive hover:bg-olive hover:text-white';
|
|
chip.setAttribute('aria-label', `Remover ${item}`);
|
|
|
|
const label = document.createElement('span');
|
|
label.textContent = item;
|
|
chip.append(label);
|
|
|
|
const icon = document.createElement('i');
|
|
icon.setAttribute('data-lucide', 'x');
|
|
icon.className = 'h-3.5 w-3.5';
|
|
chip.append(icon);
|
|
|
|
chip.addEventListener('click', () => removeIngredient(item));
|
|
el.selected.append(chip);
|
|
});
|
|
}
|
|
|
|
function renderSuggestions() {
|
|
el.suggestions.innerHTML = '';
|
|
suggestions.forEach((item) => {
|
|
const selected = state.selected.some((ingredient) => normalize(ingredient) === normalize(item));
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.disabled = selected;
|
|
btn.className =
|
|
'rounded-[8px] border border-black/10 bg-white/45 px-3 py-2 text-sm font-semibold text-stone-700 hover:border-olive hover:text-olive disabled:cursor-not-allowed disabled:opacity-40';
|
|
btn.textContent = item;
|
|
btn.addEventListener('click', () => addIngredient(item));
|
|
el.suggestions.append(btn);
|
|
});
|
|
}
|
|
|
|
function renderFilters() {
|
|
document.querySelectorAll('.filter-btn').forEach((button) => {
|
|
const active = button.dataset.filter === state.activeFilter;
|
|
button.classList.toggle('bg-ink', active);
|
|
button.classList.toggle('text-paper', active);
|
|
button.classList.toggle('border-ink', active);
|
|
});
|
|
}
|
|
|
|
function renderStats(results) {
|
|
const visible = results.length;
|
|
const average =
|
|
visible === 0
|
|
? 0
|
|
: Math.round((results.reduce((sum, recipe) => sum + recipe.matchRate, 0) / visible) * 100);
|
|
const savings = results.slice(0, 3).reduce((sum, recipe) => sum + Math.max(0, 28 - recipe.cost), 0);
|
|
|
|
el.recipeCount.textContent = String(visible);
|
|
el.savingScore.textContent = currency(savings);
|
|
el.matchAverage.textContent = `${average}%`;
|
|
}
|
|
|
|
function renderGrid(results) {
|
|
el.grid.innerHTML = '';
|
|
el.empty.classList.toggle('hidden', results.length > 0);
|
|
el.empty.classList.toggle('grid', results.length === 0);
|
|
|
|
results.forEach((recipe, index) => {
|
|
const card = document.createElement('article');
|
|
card.className = 'recipe-card soft-panel overflow-hidden bg-white/55';
|
|
card.style.setProperty('--index', index);
|
|
|
|
const missing = recipe.missing.slice(0, 3).join(', ') || 'nada essencial';
|
|
const matchPercent = Math.round(recipe.matchRate * 100);
|
|
|
|
card.innerHTML = `
|
|
<div class="relative aspect-[4/3] overflow-hidden">
|
|
<img class="h-full w-full object-cover transition duration-500 hover:scale-[1.04]" src="${recipe.image}" alt="${recipe.name}" loading="lazy" />
|
|
<span class="absolute left-3 top-3 rounded-[8px] bg-[#fffaf0]/90 px-3 py-1 font-mono text-sm font-semibold text-ink">${matchPercent}%</span>
|
|
</div>
|
|
<div class="space-y-4 p-4">
|
|
<div>
|
|
<h3 class="line-clamp-2 text-xl font-bold leading-tight tracking-tight">${recipe.name}</h3>
|
|
<p class="mt-2 text-sm text-stone-600">faltam: ${missing}</p>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-2 text-sm">
|
|
<span class="rounded-[8px] bg-paper px-2 py-2 text-center font-mono">${recipe.time} min</span>
|
|
<span class="rounded-[8px] bg-paper px-2 py-2 text-center font-mono">${currency(recipe.cost)}</span>
|
|
<span class="rounded-[8px] bg-paper px-2 py-2 text-center font-mono">${recipe.servings} porcoes</span>
|
|
</div>
|
|
<button class="detail-btn flex w-full items-center justify-center gap-2 rounded-[8px] bg-ink px-4 py-3 font-semibold text-paper hover:bg-olive" type="button">
|
|
ver preparo
|
|
<i data-lucide="arrow-up-right" class="h-4 w-4"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
card.querySelector('.detail-btn').addEventListener('click', () => openRecipe(recipe));
|
|
el.grid.append(card);
|
|
});
|
|
}
|
|
|
|
function renderPlan(results) {
|
|
const top = results[0];
|
|
const second = results[1];
|
|
const available = state.selected.length;
|
|
const rows = [
|
|
{
|
|
icon: 'package-check',
|
|
label: 'Ingredientes selecionados',
|
|
value: `${available} itens`
|
|
},
|
|
{
|
|
icon: 'timer',
|
|
label: 'Receita mais rapida',
|
|
value: top ? `${top.name} em ${top.time} min` : 'aguardando'
|
|
},
|
|
{
|
|
icon: 'wallet',
|
|
label: 'Menor custo entre destaques',
|
|
value: second ? currency(Math.min(top.cost, second.cost)) : top ? currency(top.cost) : 'aguardando'
|
|
}
|
|
];
|
|
|
|
el.kitchenPlan.innerHTML = '';
|
|
rows.forEach((row) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'flex items-start gap-3 rounded-[8px] border border-black/10 bg-white/45 p-3';
|
|
item.innerHTML = `
|
|
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-[8px] bg-paper text-olive">
|
|
<i data-lucide="${row.icon}" class="h-4 w-4"></i>
|
|
</span>
|
|
<span class="min-w-0">
|
|
<span class="block text-sm text-stone-600">${row.label}</span>
|
|
<span class="block truncate font-semibold text-ink">${row.value}</span>
|
|
</span>
|
|
`;
|
|
el.kitchenPlan.append(item);
|
|
});
|
|
}
|
|
|
|
function openRecipe(recipe) {
|
|
el.modalTitle.textContent = recipe.name;
|
|
const has = recipe.ingredients.filter((ingredient) =>
|
|
state.selected.map(normalize).includes(normalize(ingredient))
|
|
);
|
|
const missing = recipe.ingredients.filter(
|
|
(ingredient) => !state.selected.map(normalize).includes(normalize(ingredient))
|
|
);
|
|
|
|
el.modalBody.innerHTML = `
|
|
<div class="grid gap-5 md:grid-cols-[0.85fr_1.15fr]">
|
|
<img class="aspect-[4/3] h-full w-full rounded-[8px] object-cover" src="${recipe.image}" alt="${recipe.name}" />
|
|
<div class="space-y-5">
|
|
<div class="grid grid-cols-3 gap-2">
|
|
<div class="rounded-[8px] border border-black/10 bg-paper p-3 text-center">
|
|
<span class="block font-mono text-xl font-semibold">${recipe.time}</span>
|
|
<span class="text-xs text-stone-600">minutos</span>
|
|
</div>
|
|
<div class="rounded-[8px] border border-black/10 bg-paper p-3 text-center">
|
|
<span class="block font-mono text-xl font-semibold">${currency(recipe.cost)}</span>
|
|
<span class="text-xs text-stone-600">estimado</span>
|
|
</div>
|
|
<div class="rounded-[8px] border border-black/10 bg-paper p-3 text-center">
|
|
<span class="block font-mono text-xl font-semibold">${recipe.servings}</span>
|
|
<span class="text-xs text-stone-600">porcoes</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 class="mb-2 font-bold">Ingredientes que voce ja tem</h3>
|
|
<div class="flex flex-wrap gap-2">${has
|
|
.map((item) => `<span class="rounded-[8px] bg-olive/10 px-3 py-2 text-sm font-semibold text-olive">${item}</span>`)
|
|
.join('')}</div>
|
|
</div>
|
|
<div>
|
|
<h3 class="mb-2 font-bold">Completar se possivel</h3>
|
|
<div class="flex flex-wrap gap-2">${missing
|
|
.map((item) => `<span class="rounded-[8px] bg-clay/10 px-3 py-2 text-sm font-semibold text-clay">${item}</span>`)
|
|
.join('')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-6">
|
|
<h3 class="mb-3 font-bold">Preparo</h3>
|
|
<ol class="space-y-3">${recipe.steps
|
|
.map(
|
|
(step, index) =>
|
|
`<li class="flex gap-3 rounded-[8px] border border-black/10 bg-white/55 p-3"><span class="font-mono font-semibold text-clay">${String(
|
|
index + 1
|
|
).padStart(2, '0')}</span><span>${step}</span></li>`
|
|
)
|
|
.join('')}</ol>
|
|
</div>
|
|
`;
|
|
|
|
el.modal.dataset.open = 'true';
|
|
document.body.style.overflow = 'hidden';
|
|
if (window.lucide) window.lucide.createIcons();
|
|
}
|
|
|
|
function closeRecipe() {
|
|
el.modal.dataset.open = 'false';
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
async function loadInspiration() {
|
|
el.apiDot.dataset.state = 'loading';
|
|
el.apiStatus.textContent = 'Conectando API';
|
|
el.apiCard.innerHTML = `
|
|
<div class="skeleton mb-4 aspect-[4/3] rounded-[8px]"></div>
|
|
<div class="skeleton mb-2 h-5 w-2/3 rounded-[8px]"></div>
|
|
<div class="skeleton h-4 w-full rounded-[8px]"></div>
|
|
`;
|
|
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(() => controller.abort(), 8000);
|
|
|
|
try {
|
|
const response = await fetch('https://www.themealdb.com/api/json/v1/1/random.php', {
|
|
signal: controller.signal
|
|
});
|
|
if (!response.ok) throw new Error('API indisponivel');
|
|
const payload = await response.json();
|
|
const meal = payload.meals?.[0];
|
|
if (!meal) throw new Error('Resposta vazia');
|
|
|
|
state.inspiration = meal;
|
|
el.apiDot.dataset.state = 'online';
|
|
el.apiStatus.textContent = 'API conectada';
|
|
renderApiMeal(meal);
|
|
} catch (error) {
|
|
el.apiDot.dataset.state = 'offline';
|
|
el.apiStatus.textContent = 'API em fallback';
|
|
renderApiFallback();
|
|
} finally {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function renderApiMeal(meal) {
|
|
el.apiCard.innerHTML = '';
|
|
const article = document.createElement('article');
|
|
article.className = 'space-y-4';
|
|
|
|
const image = document.createElement('img');
|
|
image.className = 'aspect-[4/3] w-full rounded-[8px] object-cover';
|
|
image.src = meal.strMealThumb;
|
|
image.alt = meal.strMeal;
|
|
image.loading = 'lazy';
|
|
article.append(image);
|
|
|
|
const content = document.createElement('div');
|
|
const category = [meal.strCategory, meal.strArea].filter(Boolean).join(' / ');
|
|
content.innerHTML = `
|
|
<p class="mb-2 text-sm font-semibold uppercase tracking-[0.16em] text-clay">${category}</p>
|
|
<h3 class="text-2xl font-bold leading-tight tracking-tight"></h3>
|
|
<p class="mt-2 line-clamp-2 text-sm leading-6 text-stone-600"></p>
|
|
<a class="mt-4 inline-flex items-center gap-2 rounded-[8px] bg-ink px-4 py-3 text-sm font-semibold text-paper hover:bg-olive" target="_blank" rel="noreferrer">
|
|
abrir receita
|
|
<i data-lucide="external-link" class="h-4 w-4"></i>
|
|
</a>
|
|
`;
|
|
content.querySelector('h3').textContent = meal.strMeal;
|
|
content.querySelector('p.line-clamp-2').textContent =
|
|
meal.strInstructions || 'Receita internacional carregada diretamente da API publica.';
|
|
const link = content.querySelector('a');
|
|
link.href = meal.strSource || meal.strYoutube || 'https://www.themealdb.com/';
|
|
article.append(content);
|
|
el.apiCard.append(article);
|
|
if (window.lucide) window.lucide.createIcons();
|
|
}
|
|
|
|
function renderApiFallback() {
|
|
el.apiCard.innerHTML = `
|
|
<div class="space-y-4">
|
|
<img class="aspect-[4/3] w-full rounded-[8px] object-cover" src="https://picsum.photos/seed/chef-zero-fallback/900/650" alt="Mesa de cozinha preparada" loading="lazy" />
|
|
<div>
|
|
<p class="mb-2 text-sm font-semibold uppercase tracking-[0.16em] text-clay">fallback local</p>
|
|
<h3 class="text-2xl font-bold leading-tight tracking-tight">Cozinha continua ativa</h3>
|
|
<p class="mt-2 text-sm leading-6 text-stone-600">A API externa nao respondeu agora. A busca por ingredientes segue operando com a base local.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function render() {
|
|
const results = getResults();
|
|
state.currentResults = results;
|
|
renderSelected();
|
|
renderSuggestions();
|
|
renderFilters();
|
|
renderStats(results);
|
|
renderGrid(results);
|
|
renderPlan(results);
|
|
if (window.lucide) window.lucide.createIcons();
|
|
}
|
|
|
|
el.form.addEventListener('submit', (event) => {
|
|
event.preventDefault();
|
|
const tokens = ingredientTokens(el.input.value);
|
|
if (tokens.length === 0) {
|
|
el.inputError.textContent = 'Informe um ingrediente para adicionar.';
|
|
return;
|
|
}
|
|
tokens.forEach(addIngredient);
|
|
el.input.value = '';
|
|
});
|
|
|
|
document.querySelectorAll('.filter-btn').forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
state.activeFilter = state.activeFilter === button.dataset.filter ? null : button.dataset.filter;
|
|
render();
|
|
});
|
|
});
|
|
|
|
el.resetBtn.addEventListener('click', () => {
|
|
state.selected = [];
|
|
state.activeFilter = null;
|
|
el.inputError.textContent = '';
|
|
render();
|
|
});
|
|
|
|
el.refreshApiBtn.addEventListener('click', loadInspiration);
|
|
el.closeModalBtn.addEventListener('click', closeRecipe);
|
|
el.modal.addEventListener('click', (event) => {
|
|
if (event.target === el.modal) closeRecipe();
|
|
});
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') closeRecipe();
|
|
});
|
|
|
|
render();
|
|
loadInspiration();
|
|
</script>
|
|
</body>
|
|
</html>
|