imagenes tematicas por producto (loremflickr) en vez de fotos random
This commit is contained in:
commit
72ef3e3345
22 changed files with 1794 additions and 0 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
data
|
||||
__nm_old
|
||||
.nm_trash*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
.next
|
||||
data
|
||||
__nm_old
|
||||
.nm_trash*
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
# better-sqlite3 es un módulo nativo: en Alpine (musl) hay que compilarlo.
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
CMD ["npm", "start"]
|
||||
21
app/api/login/route.js
Normal file
21
app/api/login/route.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import db from "../../lib/db";
|
||||
import { verifyPassword, signSession } from "../../lib/auth";
|
||||
|
||||
export async function POST(req) {
|
||||
let body;
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Solicitud inválida" }, { status: 400 }); }
|
||||
|
||||
const email = (body.email || "").trim().toLowerCase();
|
||||
const password = body.password || "";
|
||||
|
||||
const user = db.prepare("SELECT * FROM users WHERE email = ?").get(email);
|
||||
if (!user || !verifyPassword(password, user.password))
|
||||
return NextResponse.json({ error: "Email o contraseña incorrectos" }, { status: 401 });
|
||||
|
||||
const res = NextResponse.json({ ok: true, user: { id: user.id, name: user.name, email: user.email } });
|
||||
res.cookies.set("session", signSession(user.id), {
|
||||
httpOnly: true, sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
return res;
|
||||
}
|
||||
7
app/api/logout/route.js
Normal file
7
app/api/logout/route.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.cookies.set("session", "", { httpOnly: true, path: "/", maxAge: 0 });
|
||||
return res;
|
||||
}
|
||||
12
app/api/me/route.js
Normal file
12
app/api/me/route.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import db from "../../lib/db";
|
||||
import { readSession } from "../../lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
const store = await cookies();
|
||||
const id = readSession(store.get("session")?.value);
|
||||
if (!id) return NextResponse.json({ user: null });
|
||||
const user = db.prepare("SELECT id, name, email FROM users WHERE id = ?").get(id);
|
||||
return NextResponse.json({ user: user || null });
|
||||
}
|
||||
28
app/api/register/route.js
Normal file
28
app/api/register/route.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import db from "../../lib/db";
|
||||
import { hashPassword, signSession } from "../../lib/auth";
|
||||
|
||||
export async function POST(req) {
|
||||
let body;
|
||||
try { body = await req.json(); } catch { return NextResponse.json({ error: "Solicitud inválida" }, { status: 400 }); }
|
||||
|
||||
const name = (body.name || "").trim();
|
||||
const email = (body.email || "").trim().toLowerCase();
|
||||
const password = body.password || "";
|
||||
|
||||
if (!email || !password) return NextResponse.json({ error: "Email y contraseña son obligatorios" }, { status: 400 });
|
||||
if (password.length < 6) return NextResponse.json({ error: "La contraseña debe tener al menos 6 caracteres" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const info = db.prepare("INSERT INTO users (name, email, password) VALUES (?, ?, ?)")
|
||||
.run(name, email, hashPassword(password));
|
||||
const res = NextResponse.json({ ok: true, user: { id: Number(info.lastInsertRowid), name, email } });
|
||||
res.cookies.set("session", signSession(info.lastInsertRowid), {
|
||||
httpOnly: true, sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
return res;
|
||||
} catch (e) {
|
||||
if (String(e).includes("UNIQUE")) return NextResponse.json({ error: "Ese email ya está registrado" }, { status: 409 });
|
||||
return NextResponse.json({ error: "No se pudo crear la cuenta" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
41
app/components/AuthNav.js
Normal file
41
app/components/AuthNav.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function AuthNav() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/me")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setUser(d.user); setReady(true); })
|
||||
.catch(() => setReady(true));
|
||||
}, []);
|
||||
|
||||
async function logout() {
|
||||
await fetch("/api/logout", { method: "POST" });
|
||||
setUser(null);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="auth-nav">
|
||||
<span className="auth-hi">Hola, {user.name || user.email}</span>
|
||||
<button className="theme-toggle" onClick={logout}>Salir</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-nav">
|
||||
<Link href="/login" className="auth-link">Ingresar</Link>
|
||||
<Link href="/register" className="btn-mini">Crear cuenta</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
app/components/ThemeToggle.js
Normal file
25
app/components/ThemeToggle.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useState("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("theme") || "dark";
|
||||
setTheme(saved);
|
||||
document.documentElement.setAttribute("data-theme", saved);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next = theme === "dark" ? "light" : "dark";
|
||||
setTheme(next);
|
||||
document.documentElement.setAttribute("data-theme", next);
|
||||
localStorage.setItem("theme", next);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="theme-toggle" onClick={toggle} aria-label="Cambiar tema" suppressHydrationWarning>
|
||||
{theme === "dark" ? "☀ Claro" : "🌙 Oscuro"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
76
app/globals.css
Normal file
76
app/globals.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
:root{--bg:#0a0b12;--card:#151827;--line:#232741;--text:#eef0f7;--muted:#9aa0b5;--accent:#8b5cf6;--accent2:#ec4899;--header-bg:rgba(10,11,18,.72)}
|
||||
[data-theme="light"]{--bg:#f6f7fb;--card:#ffffff;--line:#e6e8f0;--text:#12141f;--muted:#5a6172;--accent:#7c3aed;--accent2:#db2777;--header-bg:rgba(255,255,255,.78)}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{scroll-behavior:smooth}
|
||||
body{background:var(--bg);color:var(--text);font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;line-height:1.5;-webkit-font-smoothing:antialiased;transition:background .35s ease,color .35s ease}
|
||||
a{color:inherit;text-decoration:none}
|
||||
.container{max-width:1120px;margin:0 auto;padding:0 24px}
|
||||
.header{position:sticky;top:0;z-index:50;display:flex;align-items:center;justify-content:space-between;padding:16px 24px;background:var(--header-bg);backdrop-filter:blur(12px);border-bottom:1px solid var(--line);transition:background .35s ease,border-color .35s ease}
|
||||
.logo{font-weight:800;letter-spacing:.14em;font-size:18px;background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.header nav{display:flex;gap:22px}
|
||||
.header nav a{color:var(--muted);font-size:14px;transition:color .2s}
|
||||
.header nav a:hover{color:var(--text)}
|
||||
.cart{background:var(--card);border:1px solid var(--line);color:var(--text);border-radius:999px;padding:8px 14px;font-size:14px;cursor:pointer;display:flex;gap:6px;align-items:center}
|
||||
.cart span{background:var(--accent);border-radius:999px;padding:0 7px;font-size:12px}
|
||||
.theme-toggle{background:var(--card);border:1px solid var(--line);color:var(--text);border-radius:999px;padding:8px 14px;font-size:13px;cursor:pointer;transition:background .2s,border-color .2s,transform .15s}
|
||||
.theme-toggle:hover{border-color:var(--accent);transform:translateY(-1px)}
|
||||
.auth-nav{display:flex;align-items:center;gap:12px}
|
||||
.auth-hi{color:var(--muted);font-size:14px}
|
||||
.auth-link{color:var(--muted);font-size:14px;transition:color .2s}
|
||||
.auth-link:hover{color:var(--text)}
|
||||
.btn-mini{background:linear-gradient(90deg,var(--accent),var(--accent2));color:#fff;border-radius:999px;padding:8px 14px;font-size:13px;font-weight:600;transition:transform .15s,box-shadow .2s}
|
||||
.btn-mini:hover{transform:translateY(-1px);box-shadow:0 8px 20px -8px var(--accent)}
|
||||
.auth{min-height:70vh;display:flex;align-items:center;justify-content:center;padding:60px 0}
|
||||
.auth-card{width:100%;max-width:400px;background:var(--card);border:1px solid var(--line);border-radius:20px;padding:34px 30px}
|
||||
.auth-card h1{font-size:26px;font-weight:800;letter-spacing:-.01em}
|
||||
.auth-sub{color:var(--muted);font-size:14px;margin:6px 0 24px}
|
||||
.auth-form{display:grid;gap:16px}
|
||||
.auth-form label{display:grid;gap:7px;font-size:13px;font-weight:600;color:var(--muted)}
|
||||
.auth-form input{background:var(--bg);border:1px solid var(--line);color:var(--text);border-radius:11px;padding:12px 14px;font-size:15px;transition:border-color .2s}
|
||||
.auth-form input:focus{outline:none;border-color:var(--accent)}
|
||||
.auth-form .btn{margin-top:6px;width:100%}
|
||||
.auth-error{color:#f87171;font-size:13px;background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.25);padding:9px 12px;border-radius:10px}
|
||||
.auth-alt{text-align:center;color:var(--muted);font-size:14px;margin-top:20px}
|
||||
.auth-alt a{color:var(--accent2);font-weight:600}
|
||||
@media(max-width:720px){.auth-nav .auth-hi{display:none}}
|
||||
.hero{padding:88px 0 56px;text-align:center;max-width:760px;margin:0 auto}
|
||||
.eyebrow{color:var(--accent2);font-weight:600;letter-spacing:.08em;text-transform:uppercase;font-size:13px;margin-bottom:14px}
|
||||
.hero h1{font-size:clamp(38px,6vw,68px);line-height:1.04;font-weight:800;letter-spacing:-.02em}
|
||||
.hero h1 span{background:linear-gradient(90deg,var(--accent),var(--accent2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.hero .sub{color:var(--muted);font-size:18px;margin:20px auto 30px;max-width:560px}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;cursor:pointer;border-radius:12px;padding:13px 22px;font-size:15px;font-weight:600;transition:transform .15s,box-shadow .2s}
|
||||
.btn-primary{background:linear-gradient(90deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 8px 24px -8px var(--accent)}
|
||||
.btn-primary:hover{transform:translateY(-2px);box-shadow:0 14px 30px -8px var(--accent)}
|
||||
.btn-ghost{background:transparent;border:1px solid var(--line);color:var(--text)}
|
||||
.btn-ghost:hover{background:var(--card)}
|
||||
.grid-section{padding:26px 0 70px}
|
||||
.section-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:22px}
|
||||
.section-head h2{font-size:26px;font-weight:700}
|
||||
.muted{color:var(--muted);font-size:14px}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:20px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;overflow:hidden;transition:transform .2s,border-color .2s,box-shadow .3s;display:block}
|
||||
.card:hover{transform:translateY(-6px);border-color:var(--accent);box-shadow:0 20px 40px -20px rgba(139,92,246,.6)}
|
||||
.thumb{aspect-ratio:4/3;position:relative;overflow:hidden;background:var(--line)}
|
||||
.thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:transform .4s ease}
|
||||
.card:hover .thumb img{transform:scale(1.05)}
|
||||
.card-body{padding:16px}
|
||||
.chip{display:inline-block;font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--accent2);background:rgba(236,72,153,.1);border:1px solid rgba(236,72,153,.25);padding:3px 9px;border-radius:999px;margin-bottom:10px}
|
||||
.card-body h3{font-size:16px;font-weight:600;margin-bottom:6px}
|
||||
.price{color:var(--text);font-weight:700;font-size:18px}
|
||||
.price.big{font-size:30px;margin:6px 0 16px}
|
||||
.detail{padding:36px 0 80px}
|
||||
.back{color:var(--muted);font-size:14px;display:inline-block;margin-bottom:22px;transition:color .2s}
|
||||
.back:hover{color:var(--text)}
|
||||
.detail-grid{display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:start}
|
||||
.detail-thumb{aspect-ratio:1/1;border-radius:24px;overflow:hidden;position:relative;background:var(--line);border:1px solid var(--line)}
|
||||
.detail-thumb img{width:100%;height:100%;object-fit:cover;display:block}
|
||||
.detail-info h1{font-size:34px;font-weight:800;letter-spacing:-.01em;margin:12px 0}
|
||||
.desc{color:var(--muted);font-size:16px;margin-bottom:24px}
|
||||
.actions{display:flex;gap:12px;margin-bottom:26px;flex-wrap:wrap}
|
||||
.perks{list-style:none;display:grid;gap:10px}
|
||||
.perks li{color:var(--muted);font-size:14px;padding-left:24px;position:relative}
|
||||
.perks li::before{content:"\2713";position:absolute;left:0;color:var(--accent);font-weight:700}
|
||||
.footer{border-top:1px solid var(--line);color:var(--muted);text-align:center;padding:28px;font-size:13px;margin-top:40px}
|
||||
.page{animation:enter .45s cubic-bezier(.22,1,.36,1) both}
|
||||
@keyframes enter{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
|
||||
@media(max-width:720px){.detail-grid{grid-template-columns:1fr}.header nav{display:none}}
|
||||
24
app/layout.js
Normal file
24
app/layout.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import "./globals.css";
|
||||
import Link from "next/link";
|
||||
import ThemeToggle from "./components/ThemeToggle";
|
||||
import AuthNav from "./components/AuthNav";
|
||||
export const metadata = { title: "Nova - Store", description: "Tienda de tecnologia moderna" };
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="es">
|
||||
<body>
|
||||
<header className="header">
|
||||
<Link href="/" className="logo">NOVA</Link>
|
||||
<nav><Link href="/">Inicio</Link><Link href="/#productos">Productos</Link></nav>
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
||||
<AuthNav />
|
||||
<ThemeToggle />
|
||||
<button className="cart">Carrito <span>0</span></button>
|
||||
</div>
|
||||
</header>
|
||||
<main className="container">{children}</main>
|
||||
<footer className="footer">2026 Nova Store - Hecho con Foundry</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
40
app/lib/auth.js
Normal file
40
app/lib/auth.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import crypto from "node:crypto";
|
||||
|
||||
const SECRET = process.env.AUTH_SECRET || "nova-store-dev-secret-change-me";
|
||||
|
||||
// --- Contraseñas: scrypt (nativo de Node, sin dependencias) ---
|
||||
export function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = crypto.scryptSync(password, salt, 64).toString("hex");
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password, stored) {
|
||||
const [salt, hash] = String(stored).split(":");
|
||||
if (!salt || !hash) return false;
|
||||
const test = crypto.scryptSync(password, salt, 64).toString("hex");
|
||||
const a = Buffer.from(hash, "hex");
|
||||
const b = Buffer.from(test, "hex");
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
// --- Sesión: cookie firmada con HMAC (sin dependencias) ---
|
||||
export function signSession(userId) {
|
||||
const value = String(userId);
|
||||
const sig = crypto.createHmac("sha256", SECRET).update(value).digest("hex");
|
||||
return `${value}.${sig}`;
|
||||
}
|
||||
|
||||
export function readSession(cookieValue) {
|
||||
if (!cookieValue) return null;
|
||||
const i = cookieValue.lastIndexOf(".");
|
||||
if (i < 0) return null;
|
||||
const value = cookieValue.slice(0, i);
|
||||
const sig = cookieValue.slice(i + 1);
|
||||
const expected = crypto.createHmac("sha256", SECRET).update(value).digest("hex");
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
const id = Number(value);
|
||||
return Number.isFinite(id) ? id : null;
|
||||
}
|
||||
27
app/lib/db.js
Normal file
27
app/lib/db.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Singleton: evita abrir múltiples conexiones durante el hot-reload de next dev.
|
||||
function createDb() {
|
||||
const dataDir = path.join(process.cwd(), "data");
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const db = new Database(path.join(dataDir, "nova.db"));
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
const g = globalThis;
|
||||
const db = g.__novaDb ?? (g.__novaDb = createDb());
|
||||
|
||||
export default db;
|
||||
15
app/lib/products.js
Normal file
15
app/lib/products.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Imagenes tematicas via LoremFlickr: busca por palabra clave.
|
||||
// El parametro lock fija la foto para que sea siempre la misma.
|
||||
const img = (keywords, lock, w = 800, h = 600) =>
|
||||
`https://loremflickr.com/${w}/${h}/${keywords}?lock=${lock}`;
|
||||
|
||||
export const products = [
|
||||
{ id: "aurora-headphones", name: "Aurora Headphones", price: 189, category: "Audio", img: img("headphones,music", 11), desc: "Auriculares inalambricos con cancelacion de ruido activa y 40h de bateria." },
|
||||
{ id: "nova-watch", name: "Nova Smartwatch", price: 249, category: "Wearables", img: img("smartwatch,watch", 22), desc: "Reloj inteligente con GPS, monitor cardiaco y pantalla AMOLED. Resistente al agua." },
|
||||
{ id: "lumen-lamp", name: "Lumen Desk Lamp", price: 79, category: "Hogar", img: img("desk,lamp", 33), desc: "Lampara de escritorio con temperatura de color ajustable y carga inalambrica." },
|
||||
{ id: "pulse-speaker", name: "Pulse Speaker", price: 129, category: "Audio", img: img("speaker,bluetooth", 44), desc: "Parlante portatil resistente al agua con sonido envolvente 360 y 20h de reproduccion." },
|
||||
{ id: "zen-keyboard", name: "Zen Keyboard", price: 159, category: "Setup", img: img("keyboard,computer", 55), desc: "Teclado mecanico inalambrico con switches silenciosos y retroiluminacion RGB." },
|
||||
{ id: "orbit-mouse", name: "Orbit Mouse", price: 69, category: "Setup", img: img("mouse,computer", 66), desc: "Mouse ergonomico de precision con scroll magnetico y bateria recargable." }
|
||||
];
|
||||
|
||||
export const getProduct = (id) => products.find((p) => p.id === id);
|
||||
49
app/login/page.js
Normal file
49
app/login/page.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError(""); setLoading(true);
|
||||
try {
|
||||
const r = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) { setError(data.error || "Error al iniciar sesión"); return; }
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch { setError("Error de conexión"); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth">
|
||||
<div className="auth-card">
|
||||
<h1>Iniciar sesión</h1>
|
||||
<p className="auth-sub">Bienvenido de nuevo a Nova Store.</p>
|
||||
<form onSubmit={onSubmit} className="auth-form">
|
||||
<label>Email
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="tu@email.com" required />
|
||||
</label>
|
||||
<label>Contraseña
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" required />
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<button className="btn btn-primary" disabled={loading}>{loading ? "Entrando…" : "Entrar"}</button>
|
||||
</form>
|
||||
<p className="auth-alt">¿No tenés cuenta? <Link href="/register">Registrate</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
app/page.js
Normal file
31
app/page.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Link from "next/link";
|
||||
import { products } from "./lib/products";
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<section className="hero">
|
||||
<p className="eyebrow">Nueva coleccion - 2026</p>
|
||||
<h1>Tecnologia que se <span>siente</span>.</h1>
|
||||
<p className="sub">Diseno minimalista, materiales premium y una experiencia que enamora.</p>
|
||||
<Link href="/#productos" className="btn btn-primary">Ver productos</Link>
|
||||
</section>
|
||||
<section id="productos" className="grid-section">
|
||||
<div className="section-head"><h2>Destacados</h2><span className="muted">{products.length} productos</span></div>
|
||||
<div className="grid">
|
||||
{products.map((p) => (
|
||||
<Link key={p.id} href={`/product/${p.id}`} className="card">
|
||||
<div className="thumb">
|
||||
<img src={p.img} alt={p.name} loading="lazy" />
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<span className="chip">{p.category}</span>
|
||||
<h3>{p.name}</h3>
|
||||
<p className="price">${p.price}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
app/product/[id]/page.js
Normal file
28
app/product/[id]/page.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getProduct, products } from "../../lib/products";
|
||||
export function generateStaticParams() { return products.map((p) => ({ id: p.id })); }
|
||||
export default async function ProductPage({ params }) {
|
||||
const { id } = await params;
|
||||
const p = getProduct(id);
|
||||
if (!p) return notFound();
|
||||
return (
|
||||
<section className="detail">
|
||||
<Link href="/" className="back">Volver</Link>
|
||||
<div className="detail-grid">
|
||||
<div className="detail-thumb"><img src={p.img} alt={p.name} /></div>
|
||||
<div className="detail-info">
|
||||
<span className="chip">{p.category}</span>
|
||||
<h1>{p.name}</h1>
|
||||
<p className="price big">${p.price}</p>
|
||||
<p className="desc">{p.desc}</p>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary">Agregar al carrito</button>
|
||||
<button className="btn btn-ghost">Guardar</button>
|
||||
</div>
|
||||
<ul className="perks"><li>Envio gratis en 24-48h</li><li>Garantia de 2 anos</li><li>Devolucion sin costo 30 dias</li></ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
53
app/register/page.js
Normal file
53
app/register/page.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
setError(""); setLoading(true);
|
||||
try {
|
||||
const r = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) { setError(data.error || "Error al registrarse"); return; }
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
} catch { setError("Error de conexión"); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth">
|
||||
<div className="auth-card">
|
||||
<h1>Crear cuenta</h1>
|
||||
<p className="auth-sub">Unite a Nova Store en segundos.</p>
|
||||
<form onSubmit={onSubmit} className="auth-form">
|
||||
<label>Nombre
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Tu nombre" required />
|
||||
</label>
|
||||
<label>Email
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="tu@email.com" required />
|
||||
</label>
|
||||
<label>Contraseña
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mínimo 6 caracteres" required />
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
<button className="btn btn-primary" disabled={loading}>{loading ? "Creando…" : "Crear cuenta"}</button>
|
||||
</form>
|
||||
<p className="auth-alt">¿Ya tenés cuenta? <Link href="/login">Iniciá sesión</Link></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
app/template.js
Normal file
3
app/template.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export default function Template({ children }) {
|
||||
return <div className="page">{children}</div>;
|
||||
}
|
||||
3
next.config.js
Normal file
3
next.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = { output: "standalone", serverExternalPackages: ["better-sqlite3"] };
|
||||
module.exports = nextConfig;
|
||||
1275
package-lock.json
generated
Normal file
1275
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
7
package.json
Normal file
7
package.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "nova-store",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
|
||||
"dependencies": { "next": "^15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "better-sqlite3": "^11.8.1" }
|
||||
}
|
||||
Loading…
Reference in a new issue