Skip to content

Commit 97ac0d5

Browse files
PabloJDevmaxzdosreis
authored andcommitted
feat(produto): add homepage with product listing and shared components
1 parent ca0ac22 commit 97ac0d5

9 files changed

Lines changed: 254 additions & 54 deletions

File tree

web/public/products/1.webp

16.2 KB
Loading

web/public/products/2.webp

4.2 KB
Loading

web/public/products/3.webp

12 KB
Loading

web/public/products/4.webp

14.6 KB
Loading

web/src/App.tsx

Lines changed: 17 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,19 @@
1-
import { useCallback, useState } from 'react'
2-
3-
import { apiGet, getApiBaseUrl } from './lib/api'
4-
5-
function App() {
6-
const [result, setResult] = useState<string>('')
7-
const [loading, setLoading] = useState(false)
8-
9-
const tryPing = useCallback(async () => {
10-
setLoading(true)
11-
setResult('')
12-
try {
13-
const data = await apiGet<{ status?: string }>('/test/ping')
14-
setResult(
15-
`OK: ${JSON.stringify(data)}`,
16-
)
17-
} catch (e) {
18-
setResult(e instanceof Error ? e.message : 'Erro desconhecido')
19-
} finally {
20-
setLoading(false)
21-
}
22-
}, [])
23-
24-
return (
25-
<div className="min-h-screen bg-zinc-950 text-zinc-100">
26-
<main className="mx-auto flex max-w-lg flex-col gap-6 px-6 py-16">
27-
<div>
28-
<h1 className="text-2xl font-semibold tracking-tight">
29-
OrderFlow Web
30-
</h1>
31-
<p className="mt-2 text-sm text-zinc-400">
32-
Boilerplate React + TypeScript + Tailwind. Base da API:{' '}
33-
<code className="rounded bg-zinc-800 px-1.5 py-0.5 text-xs">
34-
{getApiBaseUrl()}
35-
</code>
36-
</p>
37-
</div>
38-
<button
39-
type="button"
40-
onClick={tryPing}
41-
disabled={loading}
42-
className="w-fit rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-60"
43-
>
44-
{loading ? 'Testando…' : 'Testar GET /test/ping'}
45-
</button>
46-
{result ? (
47-
<p className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3 text-sm text-zinc-300">
48-
{result}
49-
</p>
50-
) : null}
51-
</main>
52-
</div>
1+
import { BrowserRouter, Routes, Route } from "react-router-dom"
2+
import Navbar from "./components/Navbar"
3+
import HomePage from "./app/HomePage/page"
4+
import CheckoutPage from "./app/CheckoutPage/page"
5+
import EmailConfirmPage from "./app/EmailConfirmPage/page"
6+
7+
export default function App() {
8+
return (
9+
<BrowserRouter>
10+
<Navbar />
11+
<Routes>
12+
<Route path="/" element={<HomePage />} />
13+
<Route path="/checkout" element={<CheckoutPage />} />
14+
<Route path="/email-confirm" element={<EmailConfirmPage />} />
15+
</Routes>
16+
</BrowserRouter>
5317
)
5418
}
55-
56-
export default App
19+

web/src/app/HomePage/page.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { useEffect, useState } from "react";
2+
import ProductCard from "../../components/ProductCard";
3+
import ProductCardSkeleton from "../../components/ProductCardSkeleton";
4+
5+
type Category = {
6+
id: number;
7+
name: string;
8+
};
9+
10+
type Product = {
11+
id: number;
12+
name: string;
13+
category?: Category;
14+
price: number;
15+
};
16+
17+
export default function Home() {
18+
const [products, setProducts] = useState<Product[]>([]);
19+
const [loading, setLoading] = useState(true);
20+
const [error, setError] = useState<string | null>(null);
21+
const [search, setSearch] = useState("");
22+
23+
useEffect(() => {
24+
async function fetchProducts() {
25+
try {
26+
setLoading(true);
27+
28+
await new Promise((resolve) => setTimeout(resolve, 3000));
29+
30+
const res = await fetch("http://localhost:8080/products");
31+
32+
if (!res.ok) throw new Error("Erro ao buscar produtos");
33+
34+
const data: Product[] = await res.json();
35+
setProducts(data);
36+
} catch (err: unknown) {
37+
if (err instanceof Error) {
38+
setError(err.message);
39+
} else {
40+
setError("Erro desconhecido");
41+
}
42+
} finally {
43+
setLoading(false);
44+
}
45+
}
46+
47+
fetchProducts();
48+
}, []);
49+
50+
const filtered = products.filter((p) =>
51+
p.name.toLowerCase().includes(search.toLowerCase()),
52+
);
53+
54+
return (
55+
<div className="p-4">
56+
{loading ? (
57+
<div>
58+
{/* Busca */}
59+
<div className="w-full h-11 bg-gray-200 rounded-xl mb-4 animate-pulse"></div>
60+
61+
{/* Título */}
62+
<div className="h-6 w-28 bg-gray-200 rounded mb-2 animate-pulse"></div>
63+
64+
{/* Quantidade */}
65+
<div className="h-4 w-16 bg-gray-200 rounded mb-4 animate-pulse"></div>
66+
67+
{/* Cards */}
68+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
69+
{Array.from({ length: 4 }).map((_, index) => (
70+
<ProductCardSkeleton key={index} />
71+
))}
72+
</div>
73+
</div>
74+
) : (
75+
<>
76+
{/* Busca */}
77+
<input
78+
type="text"
79+
placeholder="Buscar produtos..."
80+
className="w-full bg-surface border border-border rounded-xl px-3 py-2 mb-4"
81+
value={search}
82+
onChange={(e) => setSearch(e.target.value)}
83+
/>
84+
85+
{/* Título */}
86+
<h2 className="text-lg font-semibold">Produtos</h2>
87+
88+
{/* Quantidade */}
89+
<p className="text-sm text-gray-500 mb-3">
90+
{filtered.length} itens
91+
</p>
92+
93+
{/* ERRO */}
94+
{error && (
95+
<p className="text-red-500">{error}</p>
96+
)}
97+
98+
{/* Nenhum produto */}
99+
{filtered.length === 0 && !error && (
100+
<p>Nenhum produto encontrado.</p>
101+
)}
102+
103+
{/* LISTA */}
104+
{!error && filtered.length > 0 && (
105+
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
106+
{filtered.map((product) => (
107+
<ProductCard
108+
key={product.id}
109+
product={product}
110+
/>
111+
))}
112+
</div>
113+
)}
114+
</>
115+
)}
116+
</div>
117+
);
118+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { useState } from "react";
2+
3+
export default function Navbar() {
4+
// Controla se o menu mobile está aberto ou fechado
5+
const [isOpen, setIsOpen] = useState(false);
6+
7+
return (
8+
<nav className="bg-surface border-b border-border">
9+
<div className="flex items-center justify-between px-2 h-13">
10+
<div className="flex items-center gap-1">
11+
<div className="w-2 h-2 bg-primary rounded-full"></div>
12+
<span>OrderFlow</span>
13+
</div>
14+
15+
{/* Botão hambúrguer (lado direito, só aparece no mobile) */}
16+
<button
17+
onClick={() => setIsOpen(!isOpen)}
18+
className="md:hidden p-2 rounded-md hover:bg-gray-100"
19+
aria-label="Abrir menu"
20+
>
21+
{/* Troca o ícone dependendo do estado */}
22+
{isOpen ? (
23+
// Ícone X (fechar)
24+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
25+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
26+
</svg>
27+
) : (
28+
// Ícone hambúrguer (3 linhas)
29+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
30+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
31+
</svg>
32+
)}
33+
</button>
34+
35+
{/* Links no desktop (some no mobile) */}
36+
<div className="hidden md:flex items-center gap-4">
37+
<a href="#" className="hover:text-primary">Início</a>
38+
<a href="#" className="hover:text-primary">Pedidos</a>
39+
<a href="#" className="hover:text-primary">Configurações</a>
40+
</div>
41+
</div>
42+
43+
{/* Menu mobile — só aparece quando isOpen for true */}
44+
{isOpen && (
45+
<div className="md:hidden flex flex-col px-4 pb-4 gap-3 border-t border-border">
46+
<a href="/" className="hover:text-primary">Produtos</a>
47+
<a href="/checkout" className="hover:text-primary">Checkout</a>
48+
<a href="/email-confirm" className="hover:text-primary">Pedido</a>
49+
</div>
50+
)}
51+
</nav>
52+
);
53+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
type Category = {
2+
id: number;
3+
name: string;
4+
};
5+
6+
type Product = {
7+
id: number;
8+
name: string;
9+
category?: Category;
10+
price: number;
11+
};
12+
13+
type Props = {
14+
product: Product;
15+
};
16+
17+
export default function ProductCard({ product }: Props) {
18+
return (
19+
<div className="bg-surface rounded-lg p-3 shadow-sm border border-border">
20+
{/* Imagem */}
21+
<img
22+
src={`/products/${product.id}.webp`}
23+
alt={product.name}
24+
className="h-20 w-full object-contain rounded mb-2"
25+
/>
26+
27+
{/* Categoria */}
28+
{product.category && (
29+
<span className="text-xs bg-blue-100 text-blue-600 px-2 py-1 rounded">
30+
{product.category.name}
31+
</span>
32+
)}
33+
34+
{/* Nome */}
35+
<h3 className="text-sm mt-2">{product.name}</h3>
36+
37+
{/* Preço */}
38+
<p className="text-sm font-semibold">R$ {product.price.toFixed(2)}</p>
39+
40+
{/* Botão */}
41+
<button className="w-full mt-2 bg-black text-white text-sm py-1 rounded">
42+
Adicionar
43+
</button>
44+
</div>
45+
);
46+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export default function ProductCardSkeleton() {
2+
return (
3+
<div className="bg-surface rounded-lg p-3 shadow-sm border border-border animate-pulse">
4+
{/* Imagem */}
5+
<div className="h-20 bg-gray-200 rounded mb-3"></div>
6+
7+
{/* Categoria */}
8+
<div className="h-4 w-16 bg-gray-200 rounded mb-2"></div>
9+
10+
{/* Nome */}
11+
<div className="h-4 w-28 bg-gray-200 rounded mb-2"></div>
12+
13+
{/* Preço */}
14+
<div className="h-4 w-20 bg-gray-200 rounded mb-3"></div>
15+
16+
{/* Botão */}
17+
<div className="h-8 w-full bg-gray-300 rounded"></div>
18+
</div>
19+
);
20+
}

0 commit comments

Comments
 (0)