imagenes tematicas por producto (loremflickr) en vez de fotos random

This commit is contained in:
EdwinRoy 2026-07-31 18:41:17 -06:00
commit 72ef3e3345
22 changed files with 1794 additions and 0 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
node_modules
.next
.git
data
__nm_old
.nm_trash*
*.log
npm-debug.log*

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules
.next
data
__nm_old
.nm_trash*
*.log
npm-debug.log*
.DS_Store

13
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
export default function Template({ children }) {
return <div className="page">{children}</div>;
}

3
next.config.js Normal file
View 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

File diff suppressed because it is too large Load diff

7
package.json Normal file
View 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" }
}