criado API e primeiros cruds com a LP

This commit is contained in:
Diego Freitas 2026-01-27 16:13:54 -03:00
parent ff34c5ab8a
commit d47468238a
13 changed files with 2560 additions and 52 deletions

19
api/.env Normal file
View File

@ -0,0 +1,19 @@
# =====================
# API
# =====================
PORT=3000
NODE_ENV=development
# =====================
# MYSQL
# =====================
DB_HOST=18.228.91.135
DB_PORT=3306
DB_NAME=lojazoe
DB_USER=root
DB_PASS=maker2018**==
# TRAY
TRAY_API_BASE_URL=https://seu-subdominio.tray.com.br/web_api
TRAY_API_TOKEN=SEU_ACCESS_TOKEN_AQUI
TRAY_STORE_URL=https://seu-subdominio.tray.com.br

48
api/config/connector.js Normal file
View File

@ -0,0 +1,48 @@
// config/connector.js
const mysql = require("mysql2/promise");
let pool = null;
async function initDB() {
if (pool) return pool;
pool = mysql.createPool({
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
timezone: "Z", // UTC (bom pra sistemas distribuídos)
});
// Teste de conexão imediato
try {
const conn = await pool.getConnection();
await conn.ping();
conn.release();
console.log("✅ MySQL conectado com sucesso");
} catch (err) {
console.error("❌ Erro ao conectar no MySQL:", err.message);
process.exit(1); // mata a API se o banco não subir
}
return pool;
}
async function query(sql, params = []) {
if (!pool) {
await initDB();
}
const [rows] = await pool.execute(sql, params);
return rows;
}
module.exports = {
initDB,
query,
};

2181
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
api/package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "api",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.13.3",
"cors": "^2.8.6",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"mysql2": "^3.16.2",
"nodemon": "^3.1.11"
}
}

56
api/routes/courses.js Normal file
View File

@ -0,0 +1,56 @@
const express = require("express");
const router = express.Router();
const { query } = require("../config/connector");
const { listCourses } = require("../services/tray_service");
/**
* GET /courses
* Lista cursos disponíveis para venda (LP)
*/
router.get("/list", async (req, res) => {
try {
const cursos = await query(
`
SELECT
id,
nome,
descricao,
icone,
preco,
ativado
FROM cursos
WHERE ativado = 1
ORDER BY nome ASC
`
);
return res.json({
ok: true,
cursos,
});
} catch (err) {
console.error("Erro ao buscar cursos:", err);
return res.status(500).json({
ok: false,
error: "Erro ao buscar cursos",
});
}
});
router.get("/listTray", async (req, res) => {
try {
const courses = await listCourses();
return res.json({
ok: true,
courses,
});
} catch (err) {
console.error("Erro ao listar cursos na Tray:", err.response?.data || err);
return res.status(500).json({
ok: false,
error: "Erro ao buscar cursos na Tray",
});
}
});
module.exports = router;

51
api/routes/webhooks.js Normal file
View File

@ -0,0 +1,51 @@
const express = require("express");
const router = express.Router();
const { getOrderById } = require("../services/tray_service");
/**
* Webhook de notificações de pedidos da Tray
* URL: POST /webhooks/tray/orders
*/
router.post("/tray/orders", async (req, res) => {
try {
const payload = req.body;
console.log("📩 Webhook Tray recebido:", JSON.stringify(payload, null, 2));
// Aqui depende de como a Tray manda o payload:
// vamos supor que venha algo como:
// { order_id: 123, status: 'paid', ... }
const orderId = payload.order_id || payload.id || null;
if (!orderId) {
console.warn("Webhook Tray sem order_id identificável");
// Mesmo assim respondemos 200 pra não gerar reenvio infinito
return res.status(200).json({ received: true, ignored: true });
}
// (Opcional) Buscar detalhes do pedido na Tray pra ter certeza do status atual
try {
const orderData = await getOrderById(orderId);
console.log("🔍 Detalhes do pedido na Tray:", JSON.stringify(orderData, null, 2));
// Aqui você decide o que fazer:
// - Se status for pago -> mandar e-mail
// - Gravar log em arquivo
// - No futuro: salvar em banco, liberar curso, etc.
} catch (err) {
console.error("Erro ao buscar pedido na Tray:", err.response?.data || err.message);
// Mesmo assim, respondemos 200 pra não ficar em loop de reenvio
}
// Sempre responde 200 pra dizer "recebido"
return res.status(200).json({ received: true });
} catch (err) {
console.error("Erro no webhook /webhooks/tray/orders:", err);
// Em webhook, na maioria dos casos ainda é melhor responder 200
// para não causar reenvios em massa
return res.status(200).json({ received: true, error: "internal error" });
}
});
module.exports = router;

39
api/server.js Normal file
View File

@ -0,0 +1,39 @@
// server.js
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const { initDB } = require("./config/connector");
const app = express();
// Porta da API (padrão 3000 se não tiver no .env)
const PORT = process.env.PORT || 3000;
// Middlewares básicos
// 👇 CORS aqui
app.use(
cors({
origin: "http://localhost:8080", // origem do teu front
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
})
);
app.use(express.json()); // parseia JSON no body
// Sobe o servidor
async function startServer() {
await initDB(); // garante que o banco está ok antes de subir a API
app.listen(PORT, () => {
console.log(`🚀 Zoe API rodando em http://localhost:${PORT}`);
});
}
app.use("/courses", require("./routes/courses"));
app.use("/webhooks", require("./routes/webhooks"));
startServer();

View File

@ -0,0 +1,75 @@
// services/trayService.js
const axios = require("axios");
const trayApi = axios.create({
baseURL: process.env.TRAY_API_BASE_URL,
headers: {
"Content-Type": "application/json",
},
});
// Se a Tray exigir token por header ou querystring, você ajusta aqui
function getAuthParams() {
return {
access_token: process.env.TRAY_API_TOKEN,
};
}
/**
* Lista cursos (produtos) na Tray.
* Aqui você pode filtrar por categoria, tag, etc, dependendo de como organizar .
*/
async function listCourses() {
// Exemplo genérico: ajuste conforme o endpoint real da Tray
const resp = await trayApi.get("/products", {
params: {
...getAuthParams(),
// filtros opcionais:
// category_id: 123,
// active: 1,
},
});
const products = resp.data?.Products || resp.data?.products || [];
// Mapeia para um modelo mais limpo pro front
const courses = products.map((p) => {
return {
id: p.id,
name: p.name,
description: p.description_small || p.description || "",
price: p.price,
promo_price: p.promotional_price || null,
image_url: p.images?.[0]?.https || null,
// URL pública do produto na loja Tray (exemplo)
trayUrl: `${process.env.TRAY_STORE_URL}/produto/${p.id}/${slugify(
p.name
)}`,
};
});
return courses;
}
async function getOrderById(orderId) {
// Exemplo genérico, ajuste conforme a rota real da Tray
const resp = await trayApi.get(`/orders/${orderId}`, {
params: getAuthParams(),
});
return resp.data;
}
// Função simples pra criar um slug a partir do nome (opcional)
function slugify(str = "") {
return str
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "");
}
module.exports = {
listCourses,
getOrderById
};

6
package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "ZoeBuyCourses",
"lockfileVersion": 2,
"requires": true,
"packages": {}
}

View File

@ -1,5 +1,6 @@
import { Smartphone, Users, BookOpen, Gift } from "lucide-react"; import { useState } from "react";
import ExtraProductCard from "./ExtraProductCard"; import ExtraProductCard from "./ExtraProductCard";
import { iconMap } from "@/utils/iconMap";
interface ExtraProduct { interface ExtraProduct {
id: string; id: string;
@ -10,47 +11,21 @@ interface ExtraProduct {
iconBgClass: string; iconBgClass: string;
} }
export interface Extra {
id: string;
name: string;
description: string;
price: number;
icon: string;
}
interface ExtrasSectionProps { interface ExtrasSectionProps {
extras: Extra[];
selectedExtras: string[]; selectedExtras: string[];
onToggleExtra: (id: string) => void; onToggleExtra: (id: string) => void;
} }
const extraProducts: ExtraProduct[] = [ const ExtrasSection = ({ extras, selectedExtras, onToggleExtra }: ExtrasSectionProps) => {
{
id: "device",
title: "Novo Dispositivo ZOE",
description: "Adicione a ZOE em mais um tablet ou celular da família.",
price: 29.9,
icon: <Smartphone className="w-7 h-7 text-zoe-blue" />,
iconBgClass: "bg-zoe-blue/20",
},
{
id: "license",
title: "Licença Adicional",
description: "Para outro filho ou familiar. Mesmo prazo do plano escolhido.",
price: 19.9,
icon: <Users className="w-7 h-7 text-zoe-pink" />,
iconBgClass: "bg-zoe-pink/20",
},
{
id: "book1",
title: "Livro de Atividades Vol. 1",
description: "Atividades educativas impressas para fazer com a família.",
price: 34.9,
icon: <BookOpen className="w-7 h-7 text-zoe-mint" />,
iconBgClass: "bg-zoe-mint/20",
},
{
id: "book2",
title: "Livro de Atividades Vol. 2",
description: "Mais atividades para diversão e aprendizado offline.",
price: 34.9,
icon: <Gift className="w-7 h-7 text-zoe-orange" />,
iconBgClass: "bg-zoe-orange/20",
},
];
const ExtrasSection = ({ selectedExtras, onToggleExtra }: ExtrasSectionProps) => {
return ( return (
<section className="zoe-section bg-secondary/30"> <section className="zoe-section bg-secondary/30">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
@ -66,11 +41,16 @@ const ExtrasSection = ({ selectedExtras, onToggleExtra }: ExtrasSectionProps) =>
{/* Products grid */} {/* Products grid */}
<div className="grid sm:grid-cols-2 gap-6 max-w-4xl mx-auto"> <div className="grid sm:grid-cols-2 gap-6 max-w-4xl mx-auto">
{extraProducts.map((product) => ( {extras.map((extra) => (
<ExtraProductCard <ExtraProductCard
key={product.id} key={extra.id}
{...product} id={extra.id}
isSelected={selectedExtras.includes(product.id)} title={extra.name}
description={extra.description}
price={extra.price}
icon={iconMap[extra.icon] ?? iconMap["gift"]}
iconBgClass="bg-secondary/20"
isSelected={selectedExtras.includes(extra.id)}
onToggle={onToggleExtra} onToggle={onToggleExtra}
/> />
))} ))}
@ -80,4 +60,6 @@ const ExtrasSection = ({ selectedExtras, onToggleExtra }: ExtrasSectionProps) =>
); );
}; };
export default ExtrasSection; export default ExtrasSection;

View File

@ -1,4 +1,4 @@
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import Header from "@/components/Header"; import Header from "@/components/Header";
import HeroSection from "@/components/HeroSection"; import HeroSection from "@/components/HeroSection";
import GallerySection from "@/components/GallerySection"; import GallerySection from "@/components/GallerySection";
@ -9,6 +9,7 @@ import CartSummary from "@/components/CartSummary";
import TrustSection from "@/components/TrustSection"; import TrustSection from "@/components/TrustSection";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import { toast } from "@/hooks/use-toast"; import { toast } from "@/hooks/use-toast";
import type { Extra } from '@/components/ExtrasSection'
interface CartItem { interface CartItem {
id: string; id: string;
@ -21,18 +22,12 @@ const planDetails: Record<string, { name: string; price: number }> = {
"12months": { name: "Plano Anual (12 meses)", price: 119.9 }, "12months": { name: "Plano Anual (12 meses)", price: 119.9 },
}; };
const extraDetails: Record<string, { name: string; price: number }> = {
device: { name: "Novo Dispositivo ZOE", price: 29.9 },
license: { name: "Licença Adicional", price: 19.9 },
book1: { name: "Livro de Atividades Vol. 1", price: 34.9 },
book2: { name: "Livro de Atividades Vol. 2", price: 34.9 },
};
const Index = () => { const Index = () => {
const [selectedPlanId, setSelectedPlanId] = useState<string | undefined>(); const [selectedPlanId, setSelectedPlanId] = useState<string | undefined>();
const [selectedExtras, setSelectedExtras] = useState<string[]>([]); const [selectedExtras, setSelectedExtras] = useState<string[]>([]);
const [isCartOpen, setIsCartOpen] = useState(false); const [isCartOpen, setIsCartOpen] = useState(false);
const plansSectionRef = useRef<HTMLElement>(null); const plansSectionRef = useRef<HTMLElement>(null);
const [extras, setExtras] = useState<Extra[]>([]);
const scrollToPlans = useCallback(() => { const scrollToPlans = useCallback(() => {
plansSectionRef.current?.scrollIntoView({ behavior: "smooth" }); plansSectionRef.current?.scrollIntoView({ behavior: "smooth" });
@ -66,10 +61,12 @@ const Index = () => {
} }
selectedExtras.forEach((extraId) => { selectedExtras.forEach((extraId) => {
if (extraDetails[extraId]) { const extra = extras.find((e) => e.id === extraId);
if (extra) {
items.push({ items.push({
id: extraId, id: extra.id,
...extraDetails[extraId], name: extra.name,
price: extra.price,
}); });
} }
}); });
@ -95,6 +92,28 @@ const Index = () => {
setSelectedExtras([]); setSelectedExtras([]);
}, []); }, []);
useEffect(() => {
async function loadExtras() {
const api_endpoint = import.meta.env.VITE_API_URL;
const res = await fetch(`${api_endpoint}/courses/list`);
const data = await res.json();
if (!data.ok) return;
const mapped = data.cursos.map((c: any) => ({
id: String(c.id),
name: c.nome,
description: c.descricao,
price: c.preco,
icon: c.icone,
}));
setExtras(mapped);
}
loadExtras();
}, []);
const cartItems = getCartItems(); const cartItems = getCartItems();
const cartItemCount = cartItems.length; const cartItemCount = cartItems.length;
@ -117,6 +136,7 @@ const Index = () => {
/> />
<ExtrasSection <ExtrasSection
extras={extras}
selectedExtras={selectedExtras} selectedExtras={selectedExtras}
onToggleExtra={handleToggleExtra} onToggleExtra={handleToggleExtra}
/> />

View File

@ -0,0 +1,10 @@
import { Smartphone, Users, BookOpen, Gift } from "lucide-react";
import React from "react";
export const iconMap: Record<string, React.ReactNode> = {
smartphone: <Smartphone className="w-7 h-7 text-zoe-blue" />,
users: <Users className="w-7 h-7 text-zoe-pink" />,
book: <BookOpen className="w-7 h-7 text-zoe-mint" />,
gift: <Gift className="w-7 h-7 text-zoe-orange" />,
};