first commit

This commit is contained in:
2026-02-10 17:51:21 +01:00
parent 079aa1da58
commit db00c3ec89
25 changed files with 1536 additions and 99 deletions

265
package-lock.json generated
View File

@@ -8,9 +8,16 @@
"name": "surtilatino-frontend-page",
"version": "0.1.0",
"dependencies": {
"@supabase/supabase-js": "^2.95.3",
"@tanstack/react-query": "^5.90.20",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1234,6 +1241,86 @@
"dev": true,
"license": "MIT"
},
"node_modules/@supabase/auth-js": {
"version": "2.95.3",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.95.3.tgz",
"integrity": "sha512-vD2YoS8E2iKIX0F7EwXTmqhUpaNsmbU6X2R0/NdFcs02oEfnHyNP/3M716f3wVJ2E5XHGiTFXki6lRckhJ0Thg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.95.3",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.95.3.tgz",
"integrity": "sha512-uTuOAKzs9R/IovW1krO0ZbUHSJnsnyJElTXIRhjJTqymIVGcHzkAYnBCJqd7468Fs/Foz1BQ7Dv6DCl05lr7ig==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.95.3",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.95.3.tgz",
"integrity": "sha512-LTrRBqU1gOovxRm1vRXPItSMPBmEFqrfTqdPTRtzOILV4jPSueFz6pES5hpb4LRlkFwCPRmv3nQJ5N625V2Xrg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.95.3",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.95.3.tgz",
"integrity": "sha512-D7EAtfU3w6BEUxDACjowWNJo/ZRo7sDIuhuOGKHIm9FHieGeoJV5R6GKTLtga/5l/6fDr2u+WcW/m8I9SYmaIw==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.95.3",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.95.3.tgz",
"integrity": "sha512-4GxkJiXI3HHWjxpC3sDx1BVrV87O0hfX+wvJdqGv67KeCu+g44SPnII8y0LL/Wr677jB7tpjAxKdtVWf+xhc9A==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.95.3",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.95.3.tgz",
"integrity": "sha512-Fukw1cUTQ6xdLiHDJhKKPu6svEPaCEDvThqCne3OaQyZvuq2qjhJAd91kJu3PXLG18aooCgYBaB6qQz35hhABg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.95.3",
"@supabase/functions-js": "2.95.3",
"@supabase/postgrest-js": "2.95.3",
"@supabase/realtime-js": "2.95.3",
"@supabase/storage-js": "2.95.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1514,6 +1601,32 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.20",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1550,17 +1663,22 @@
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
"integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz",
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1576,6 +1694,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.55.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz",
@@ -2587,6 +2714,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2640,7 +2776,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -3596,6 +3732,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/framer-motion": {
"version": "12.34.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.0.tgz",
"integrity": "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.34.0",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3901,6 +4064,15 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4844,6 +5016,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "0.563.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz",
"integrity": "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4911,6 +5092,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/motion-dom": {
"version": "12.34.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.0.tgz",
"integrity": "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6029,6 +6225,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
@@ -6308,7 +6514,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -6502,6 +6707,27 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -6544,6 +6770,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@@ -9,9 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@supabase/supabase-js": "^2.95.3",
"@tanstack/react-query": "^5.90.20",
"clsx": "^2.1.1",
"framer-motion": "^12.34.0",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -1,26 +1,56 @@
/* src/app/globals.css */
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme {
/* Paleta Surtilatino */
--color-accent: #5D9C59;
--color-dark: #1a1a1a;
--color-light: #f8f8f8;
--color-light-hover: #f0f0f0;
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
/* Bordes Boutique */
--radius-huge: 2rem;
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
/* Animaciones */
--animate-reveal: reveal 0.6s cubic-bezier(0.22, 1, 0.36, 1);
--animate-fade-up: fade-up 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards;
@keyframes reveal {
from { transform: scale(1); }
to { transform: scale(1.08); }
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
--animate-marquee: marquee 25s linear infinite;
@keyframes marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
/* Clases utilitarias personalizadas */
@layer utilities {
.text-balance {
text-wrap: balance;
}
/* Ocultar scrollbar pero permitir scroll */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
@layer base {
body {
@apply bg-white text-dark antialiased selection:bg-accent selection:text-white;
}
}

View File

@@ -1,20 +1,22 @@
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Outfit } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/layout/Navbar";
import CartDrawer from "@/components/layout/CartDrawer";
import Footer from "@/components/layout/Footer";
// (Lo crearemos en el siguiente paso)
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
// Configuración de la fuente Outfit
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-sans", // Vincula con Tailwind
weight: ["200", "300", "400", "500", "600"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Surtilatino | Mercado Boutique",
description: "Sabor auténtico, origen natural.",
};
export default function RootLayout({
@@ -23,11 +25,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="es">
<body className={`${outfit.variable} font-sans`}>
<Navbar />
<CartDrawer />
<main>{children}</main>
<Footer></Footer>
</body>
</html>
);

View File

@@ -1,65 +1,84 @@
import Image from "next/image";
import { supabase, getProductImageUrl } from "@/lib/supabase";
import Hero from "@/components/sections/Hero";
import ProductCard from "@/components/ui/ProductCard";
import Marquee from "@/components/ui/Marquee";
import { Product } from "@/types";
// Esta función se ejecuta en el SERVIDOR cada vez que alguien entra
async function getProducts() {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('is_active', true) // Solo productos activos
.order('created_at', { ascending: false }) // Los más nuevos primero
.limit(6); // Traemos solo 6 para la home
if (error) {
console.error("Error cargando productos:", error);
return [];
}
return data as Product[];
}
export default async function Home() {
// 1. Obtenemos los datos reales
const products = await getProducts();
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="bg-white">
<Hero />
<Marquee />
<section id="shop" className="py-32 px-6 md:px-12 max-w-[1400px] mx-auto">
<div className="flex flex-col md:flex-row justify-between items-end mb-16 gap-4">
<h2 className="text-3xl md:text-4xl font-light leading-tight">
Favoritos <br /> de la semana.
</h2>
<div className="text-right">
<p className="text-xs text-gray-400 mb-2 uppercase tracking-widest">
{products.length} productos disponibles
</p>
<a href="/shop" className="text-sm border-b border-gray-300 pb-1 hover:border-dark hover:text-dark transition text-gray-500">
Ver catálogo completo
</a>
</div>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{/* Grid de Productos */}
{products.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
{products.map((product, index) => (
<ProductCard
key={product.id}
id={product.id}
title={product.name}
category={product.category || "General"}
price={product.price}
imageUrl={getProductImageUrl(product.image_url)}
index={index + 1}
/>
))}
</div>
) : (
/* Estado Vacío (Empty State) Minimalista */
<div className="text-center py-20 bg-light rounded-[2rem]">
<p className="text-gray-400 font-light text-lg">Aún no hemos subido productos.</p>
<p className="text-sm text-gray-300 mt-2">Vuelve pronto para ver nuestras novedades.</p>
</div>
)}
</section>
{/* Frase Editorial */}
<section className="py-24 px-6 bg-[#f8f8f8] mb-20">
<div className="max-w-3xl mx-auto text-center">
<h3 className="text-2xl md:text-3xl font-light leading-relaxed mb-6">
"No vendemos solo ingredientes. Vendemos la <span className="text-gray-400 line-through decoration-1 opacity-50">nostalgia</span> alegría de cocinar como en casa."
</h3>
<p className="text-xs text-gray-500 uppercase tracking-widest font-semibold">Familia Mercado, desde 2010</p>
</div>
</main>
</section>
</div>
);
}

View File

@@ -0,0 +1,149 @@
// src/app/product/[id]/page.tsx
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArrowLeft, Truck, ShieldCheck } from "lucide-react";
import { supabase, getProductImageUrl } from "@/lib/supabase";
import { Product } from "@/types";
import ProductCard from "@/components/ui/ProductCard";
import ProductActions from "@/components/product/ProductActions";
// 1. Función para obtener el producto actual
async function getProduct(id: string) {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('id', id)
.single();
if (error || !data) return null;
return data as Product;
}
// 2. Función para obtener productos relacionados
async function getRelatedProducts(category: string, currentId: string) {
const { data } = await supabase
.from('products')
.select('*')
.eq('category', category)
.neq('id', currentId)
.eq('is_active', true)
.limit(3);
return (data as Product[]) || [];
}
// 3. DEFINICIÓN DEL COMPONENTE (Aquí estaba el error de tipos)
// En lugar de una interfaz separada, tipamos inline para evitar conflictos con Next.js 15
export default async function ProductPage(props: {
params: Promise<{ id: string }>
}) {
// Await obligatorio en Next.js 15 antes de acceder a params
const params = await props.params;
const { id } = params;
const product = await getProduct(id);
if (!product) {
notFound();
}
const relatedProducts = await getRelatedProducts(product.category, product.id);
const imageUrl = getProductImageUrl(product.image_url);
return (
<div className="bg-white min-h-screen pb-32">
{/* Navegación Breadcrumb */}
<div className="pt-32 px-6 md:px-12 max-w-[1400px] mx-auto mb-8">
<Link href="/" className="inline-flex items-center gap-2 text-sm text-gray-400 hover:text-dark transition-colors">
<ArrowLeft className="w-4 h-4" />
Volver al catálogo
</Link>
</div>
<main className="px-6 md:px-12 max-w-[1400px] mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-24">
{/* COLUMNA IZQUIERDA: Imagen Heroica */}
<div className="relative">
<div className="bg-[#f8f8f8] rounded-[2rem] aspect-[4/5] flex items-center justify-center overflow-hidden sticky top-32">
<img
src={imageUrl}
alt={product.name}
className="w-3/4 h-3/4 object-contain drop-shadow-2xl transition-transform duration-700 hover:scale-105"
/>
{/* Badge de Stock */}
{product.stock < 5 && product.stock > 0 && (
<span className="absolute top-8 left-8 bg-red-50 text-red-500 px-4 py-1 rounded-full text-xs font-semibold tracking-wide uppercase">
Últimas {product.stock} unidades
</span>
)}
</div>
</div>
{/* COLUMNA DERECHA: Detalles */}
<div className="flex flex-col justify-center lg:py-12">
<span className="text-accent text-sm tracking-[0.2em] uppercase font-medium mb-4 block">
{product.category}
</span>
<h1 className="text-5xl md:text-6xl font-light text-dark mb-6 leading-[1.1] tracking-tight">
{product.name}
</h1>
<div className="flex items-center gap-6 mb-8 border-b border-gray-100 pb-8">
<span className="text-3xl font-medium text-dark">
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(product.price)}
</span>
{product.stock > 0 ? (
<span className="text-green-600 text-sm flex items-center gap-1">
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
En Stock
</span>
) : (
<span className="text-gray-400 text-sm">Agotado</span>
)}
</div>
<div className="prose prose-neutral max-w-none text-gray-500 font-light leading-relaxed mb-12">
<p>{product.description || "Sin descripción disponible para este producto."}</p>
</div>
{/* Componente Interactivo */}
<ProductActions product={product} />
<div className="grid grid-cols-2 gap-4 text-xs text-gray-400 uppercase tracking-wider font-medium">
<div className="flex items-center gap-3">
<Truck className="w-5 h-5 text-dark" />
<span>Envío en 24/48h</span>
</div>
<div className="flex items-center gap-3">
<ShieldCheck className="w-5 h-5 text-dark" />
<span>Calidad Garantizada</span>
</div>
</div>
</div>
</div>
{/* Relacionados */}
{relatedProducts.length > 0 && (
<section className="mt-32 border-t border-gray-100 pt-20">
<h2 className="text-2xl font-light mb-12">También te podría gustar</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 gap-y-16">
{relatedProducts.map((prod, idx) => (
<ProductCard
key={prod.id}
id={prod.id}
title={prod.name}
category={prod.category}
price={prod.price}
imageUrl={getProductImageUrl(prod.image_url)}
index={idx + 1}
/>
))}
</div>
</section>
)}
</main>
</div>
);
}

52
src/app/shop/page.tsx Normal file
View File

@@ -0,0 +1,52 @@
// src/app/shop/page.tsx
import { supabase } from "@/lib/supabase";
import { Product, Category } from "@/types";
import CatalogBrowser from "@/components/shop/CatalogBrowser";
export const revalidate = 0; // Datos siempre frescos
// 1. Tu función para traer categorías
async function getCategories() {
const { data, error } = await supabase
.from('categories')
.select('*')
.order('name', { ascending: true });
if (error) {
console.error('Error cargando categorías:', error.message);
return [];
}
return data as Category[];
}
// 2. Función para traer productos
async function getAllProducts() {
const { data, error } = await supabase
.from('products')
.select('*')
.eq('is_active', true)
.order('created_at', { ascending: false });
if (error) {
console.error("Error fetching shop products:", error);
return [];
}
return data as Product[];
}
export default async function ShopPage() {
// 3. Ejecutamos ambas peticiones a la vez (Parallel Data Fetching)
const [products, categories] = await Promise.all([
getAllProducts(),
getCategories()
]);
return (
<div className="bg-white min-h-screen">
{/* Pasamos ambas listas al componente cliente */}
<CatalogBrowser initialProducts={products} categories={categories} />
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useCartStore } from "@/store/cart-store";
import { X, Minus, Plus, Trash2, ArrowRight } from "lucide-react";
import { useEffect, useState } from "react";
import Image from "next/image";
import { getProductImageUrl } from "@/lib/supabase";
import { cn } from "@/lib/utils";
export default function CartDrawer() {
const {
items,
isOpen,
closeCart,
removeItem,
updateQuantity,
getTotalPrice
} = useCartStore();
const [mounted, setMounted] = useState(false);
// Evitar problemas de hidratación (SSR vs Client)
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<>
{/* Overlay Oscuro (Backdrop) */}
<div
className={cn(
"fixed inset-0 bg-black/20 backdrop-blur-sm z-[60] transition-opacity duration-300",
isOpen ? "opacity-100 visible" : "opacity-0 invisible pointer-events-none"
)}
onClick={closeCart}
/>
{/* Panel Deslizante */}
<div
className={cn(
"fixed top-0 right-0 h-full w-full md:w-[450px] bg-white z-[70] shadow-2xl transform transition-transform duration-500 cubic-bezier(0.32, 0.72, 0, 1)",
// Borde redondeado a la izquierda estilo boutique
"md:rounded-l-[2.5rem]",
isOpen ? "translate-x-0" : "translate-x-full"
)}
>
<div className="flex flex-col h-full p-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-light tracking-tight">
Tu Pedido <span className="text-accent text-sm font-medium ml-2">({items.length})</span>
</h2>
<button onClick={closeCart} className="p-2 hover:bg-gray-100 rounded-full transition">
<X className="w-6 h-6 text-gray-400" />
</button>
</div>
{/* Lista de Items */}
<div className="flex-1 overflow-y-auto pr-2 -mr-2 no-scrollbar">
{items.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center opacity-50">
<span className="text-6xl mb-4">🥑</span>
<p className="font-light">Tu cesta está vacía.</p>
<button onClick={closeCart} className="mt-4 text-sm underline hover:text-accent">
Volver a la tienda
</button>
</div>
) : (
<div className="space-y-8">
{items.map((item) => (
<div key={item.id} className="flex gap-4">
{/* Imagen Miniatura */}
<div className="w-20 h-24 bg-[#f8f8f8] rounded-xl flex items-center justify-center shrink-0">
<img
src={getProductImageUrl(item.image_url)}
alt={item.name}
className="w-16 h-16 object-contain"
/>
</div>
{/* Info */}
<div className="flex-1 flex flex-col justify-between">
<div className="flex justify-between items-start">
<div>
<h4 className="font-medium text-dark line-clamp-1">{item.name}</h4>
<p className="text-xs text-gray-400">{item.category}</p>
</div>
<button onClick={() => removeItem(item.id)} className="text-gray-300 hover:text-red-400 transition">
<Trash2 className="w-4 h-4" />
</button>
</div>
<div className="flex justify-between items-end">
{/* Control Cantidad Mini */}
<div className="flex items-center gap-3 bg-gray-50 rounded-full px-2 py-1">
<button onClick={() => updateQuantity(item.id, item.quantity - 1)} className="p-1 hover:text-accent">
<Minus className="w-3 h-3" />
</button>
<span className="text-xs font-medium w-3 text-center">{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)} className="p-1 hover:text-accent">
<Plus className="w-3 h-3" />
</button>
</div>
<span className="font-medium text-dark">
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(item.price * item.quantity)}
</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Footer / Checkout */}
{items.length > 0 && (
<div className="mt-8 pt-6 border-t border-gray-100">
<div className="flex justify-between items-center mb-6 text-lg font-medium">
<span>Subtotal</span>
<span>
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(getTotalPrice())}
</span>
</div>
<button className="w-full bg-dark text-white py-4 rounded-full font-medium flex justify-center items-center gap-2 hover:bg-accent hover:shadow-lg hover:shadow-green-100 transition-all duration-300 group">
Tramitar Pedido
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
<p className="text-center text-[10px] text-gray-400 mt-4 uppercase tracking-widest">
Envío calculado en el siguiente paso
</p>
</div>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,120 @@
import Link from "next/link";
import { Instagram, Facebook, ArrowRight, MapPin, Phone, Clock } from "lucide-react";
export default function Footer() {
return (
<footer className="bg-[#f8f8f8] pt-24 pb-12 px-6 md:px-12 border-t border-gray-100 mt-auto">
<div className="max-w-[1400px] mx-auto">
{/* SECCIÓN SUPERIOR: Newsletter y Promesa */}
<div className="flex flex-col lg:flex-row justify-between items-start lg:items-end mb-24 gap-12">
<div className="max-w-xl">
<h2 className="text-4xl md:text-5xl font-light leading-[1.1] mb-6 tracking-tight">
Recibe lo mejor de Latinoamérica en tu mesa.
</h2>
<p className="text-gray-500 font-light mb-8 max-w-md">
Suscríbete para recibir ofertas exclusivas, novedades de temporada y recetas auténticas.
</p>
<form className="flex w-full max-w-md items-center border-b border-gray-300 py-2 focus-within:border-dark transition-colors">
<input
type="email"
placeholder="Tu correo electrónico"
className="appearance-none bg-transparent border-none w-full text-dark mr-3 py-1 px-2 leading-tight focus:outline-none placeholder:text-gray-400 font-light"
/>
<button
type="button"
className="flex-shrink-0 text-sm border-none text-dark hover:text-accent transition-colors"
>
<ArrowRight className="w-5 h-5" />
</button>
</form>
</div>
<div className="flex gap-4">
{/* Redes Sociales Minimalistas */}
<a href="#" className="w-12 h-12 rounded-full border border-gray-200 flex items-center justify-center hover:bg-dark hover:text-white hover:border-dark transition-all duration-300">
<Instagram className="w-5 h-5" />
</a>
<a href="#" className="w-12 h-12 rounded-full border border-gray-200 flex items-center justify-center hover:bg-dark hover:text-white hover:border-dark transition-all duration-300">
<Facebook className="w-5 h-5" />
</a>
</div>
</div>
{/* SECCIÓN MEDIA: Enlaces y Contacto Real */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-12 border-t border-gray-200 pt-16 mb-16">
{/* Columna 1: Marca */}
<div className="flex flex-col gap-4">
<Link href="/" className="text-2xl tracking-tighter font-semibold">
SurtiLatino<span className="text-accent">.</span>
</Link>
<p className="text-xs text-gray-400 uppercase tracking-widest font-medium mt-2">
Barcelona Since 2024
</p>
</div>
{/* Columna 2: Shop */}
<div className="flex flex-col gap-4">
<h4 className="font-medium mb-2">Tienda</h4>
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Novedades</Link>
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Despensa</Link>
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Frutas & Verduras</Link>
<Link href="/shop" className="text-gray-500 hover:text-dark transition-colors font-light">Bebidas</Link>
</div>
{/* Columna 3: Información Real */}
<div className="flex flex-col gap-4">
<h4 className="font-medium mb-2">Visítanos</h4>
<div className="flex items-start gap-3 text-gray-500 font-light">
<MapPin className="w-5 h-5 shrink-0 mt-0.5" />
<a
href="https://maps.google.com/?q=Carrer+del+Doctor+Martí+Julià,+53,+08903+L'Hospitalet+de+Llobregat"
target="_blank"
rel="noopener noreferrer"
className="hover:text-dark hover:underline decoration-1 underline-offset-4 transition"
>
Carrer del Doctor Martí Julià, 53,<br />
08903 L'Hospitalet de Llobregat,<br />
Barcelona
</a>
</div>
<div className="flex items-center gap-3 text-gray-500 font-light">
<Phone className="w-4 h-4 shrink-0" />
<a href="tel:+34611947465" className="hover:text-dark transition">611 94 74 65</a>
</div>
<div className="flex items-start gap-3 text-gray-500 font-light">
<Clock className="w-4 h-4 shrink-0 mt-1" />
<div>
<p>Mar - Dom: 10:00 - 21:00</p>
<p className="text-xs text-gray-400 mt-1">Lunes cerrado</p>
</div>
</div>
</div>
{/* Columna 4: Legal */}
<div className="flex flex-col gap-4">
<h4 className="font-medium mb-2">Ayuda</h4>
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Envíos y Devoluciones</Link>
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Aviso Legal</Link>
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Política de Privacidad</Link>
<Link href="#" className="text-gray-500 hover:text-dark transition-colors font-light">Contacto</Link>
</div>
</div>
{/* BOTTOM BAR: Copyright */}
<div className="flex flex-col md:flex-row justify-between items-center text-xs text-gray-400 pt-8 border-t border-gray-200">
<p>© {new Date().getFullYear()} SurtiLatino. Todos los derechos reservados.</p>
<div className="flex gap-6 mt-4 md:mt-0 uppercase tracking-widest font-medium">
<span>Hecho con en Barcelona</span>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,123 @@
// src/components/layout/Navbar.tsx
"use client";
import Link from "next/link";
import { Search, ShoppingBag, Menu, X } from "lucide-react"; // Añadimos 'X' para cerrar
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { useCartStore } from "@/store/cart-store";
export default function Navbar() {
const [scrolled, setScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); // Estado para menú móvil
const { openCart, items } = useCartStore();
const itemCount = items.reduce((acc, item) => acc + item.quantity, 0);
// Efecto: Detectar scroll para el fondo glassmorphism
useEffect(() => {
const handleScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
// Efecto: Bloquear el scroll del body cuando el menú móvil está abierto
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
}, [mobileMenuOpen]);
// Lista de enlaces para reutilizar
const navLinks = ["Shop", "Novedades", "Origen"];
return (
<>
<nav
className={cn(
"fixed top-0 w-full z-50 px-6 md:px-12 py-6 transition-all duration-300 flex justify-between items-center",
scrolled || mobileMenuOpen ? "bg-white/90 backdrop-blur-md py-4 shadow-sm" : "bg-transparent"
)}
>
{/* Logo */}
<Link
href="/"
className="text-2xl tracking-tighter font-semibold z-50 relative"
onClick={() => setMobileMenuOpen(false)}
>
SurtiLatino<span className="text-accent">.</span>
</Link>
{/* MENÚ DESKTOP (Oculto en móvil) */}
<div className="hidden md:flex space-x-12 text-sm font-medium text-gray-500">
{navLinks.map((item) => (
<Link
key={item}
href={`/${item.toLowerCase()}`}
className="hover:text-dark transition-colors duration-200"
>
{item}
</Link>
))}
</div>
{/* ACCIONES (Siempre visibles) */}
<div className="flex items-center gap-4 md:gap-6 text-dark z-50">
<button className="hover:text-accent transition-colors">
<Search strokeWidth={1.5} className="w-5 h-5" />
</button>
<button
onClick={openCart}
className="hover:text-accent transition-colors relative group"
>
<ShoppingBag strokeWidth={1.5} className="w-5 h-5" />
{itemCount > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-accent text-white text-[10px] flex items-center justify-center rounded-full font-bold animate-in zoom-in duration-300 shadow-sm border border-white">
{itemCount}
</span>
)}
</button>
{/* BOTÓN HAMBURGUESA / CERRAR (Solo móvil) */}
<button
className="md:hidden p-1"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<X strokeWidth={1.5} className="w-6 h-6 animate-in spin-in-90 duration-300" />
) : (
<Menu strokeWidth={1.5} className="w-6 h-6 animate-in fade-in duration-300" />
)}
</button>
</div>
</nav>
{/* MENÚ MÓVIL (Overlay Pantalla Completa) */}
<div
className={cn(
"fixed inset-0 bg-white z-40 flex flex-col justify-center items-center gap-8 transition-transform duration-500 cubic-bezier(0.32, 0.72, 0, 1) md:hidden",
mobileMenuOpen ? "translate-y-0" : "-translate-y-full"
)}
>
{navLinks.map((item) => (
<Link
key={item}
href={`/${item.toLowerCase()}`}
onClick={() => setMobileMenuOpen(false)}
className="text-4xl font-light tracking-tight hover:text-accent transition-colors duration-300"
>
{item}
</Link>
))}
<div className="mt-8 flex gap-6">
<Link href="#" className="text-sm font-medium uppercase tracking-widest text-gray-400">Instagram</Link>
<Link href="#" className="text-sm font-medium uppercase tracking-widest text-gray-400">Contacto</Link>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { Minus, Plus, ShoppingBag } from "lucide-react";
import { useCartStore } from "@/store/cart-store";
import { Product } from "@/types";
interface ProductActionsProps {
product: Product;
}
export default function ProductActions({ product }: ProductActionsProps) {
const [quantity, setQuantity] = useState(1);
const addItem = useCartStore((state) => state.addItem);
const handleIncrement = () => setQuantity((prev) => prev + 1);
const handleDecrement = () => setQuantity((prev) => (prev > 1 ? prev - 1 : 1));
const handleAddToCart = () => {
addItem(product, quantity);
// El store ya se encarga de abrir el drawer automáticamente
};
return (
<div className="flex flex-col sm:flex-row gap-4 mb-12">
{/* Selector de Cantidad */}
<div className="flex items-center border border-gray-200 rounded-full px-4 h-14 w-fit">
<button
onClick={handleDecrement}
disabled={quantity <= 1 || product.stock === 0}
className="p-2 hover:text-accent disabled:opacity-30 transition"
>
<Minus className="w-4 h-4" />
</button>
<span className="w-12 text-center font-medium select-none">
{quantity}
</span>
<button
onClick={handleIncrement}
disabled={quantity >= product.stock} // Evita seleccionar más del stock real
className="p-2 hover:text-accent disabled:opacity-30 transition"
>
<Plus className="w-4 h-4" />
</button>
</div>
{/* Botón de Añadir */}
<button
onClick={handleAddToCart}
disabled={product.stock === 0}
className="flex-1 bg-dark text-white h-14 rounded-full font-medium hover:bg-accent hover:scale-[1.02] active:scale-95 transition-all duration-300 flex items-center justify-center gap-3 shadow-lg shadow-gray-200 disabled:bg-gray-300 disabled:cursor-not-allowed disabled:shadow-none disabled:scale-100"
>
<ShoppingBag className="w-5 h-5" />
{product.stock > 0 ? "Añadir al Pedido" : "Agotado"}
</button>
</div>
);
}

View File

View File

@@ -0,0 +1,80 @@
// src/components/sections/Hero.tsx
"use client";
import { motion } from "framer-motion";
import { PlayCircle, ArrowRight } from "lucide-react";
import Image from "next/image";
export default function Hero() {
return (
<section className="min-h-screen flex flex-col justify-center items-center text-center px-6 pt-32 pb-20 overflow-hidden relative">
{/* Etiqueta Superior */}
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-accent text-xs md:text-sm tracking-[0.2em] uppercase mb-6 font-medium"
>
Esenciales Latinos
</motion.p>
{/* Titular Principal */}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="text-5xl md:text-7xl lg:text-8xl font-light tracking-tight leading-[1.1] mb-10 max-w-5xl mx-auto"
>
Sabor auténtico, <br className="hidden md:block" />
<span className="font-semibold relative inline-block">
origen natural.
{/* Subrayado orgánico decorativo opcional */}
<svg className="absolute w-full h-3 -bottom-1 left-0 text-accent/30" viewBox="0 0 100 10" preserveAspectRatio="none">
<path d="M0 5 Q 50 10 100 5" stroke="currentColor" strokeWidth="2" fill="none" />
</svg>
</span>
</motion.h1>
{/* Botones de Acción */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
className="flex flex-col md:flex-row gap-4 items-center mb-20 z-10"
>
<button className="bg-dark text-white px-8 py-4 rounded-full text-sm font-medium hover:bg-accent transition-colors duration-300 flex items-center gap-2 group">
Ver Colección
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</button>
<button className="flex items-center gap-2 px-8 py-4 rounded-full text-sm font-medium border border-gray-200 hover:border-dark transition-colors duration-300 bg-white">
<PlayCircle className="w-5 h-5 text-gray-400" />
Nuestra Historia
</button>
</motion.div>
{/* Imagen Principal con Decoración */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.5, ease: [0.22, 1, 0.36, 1] }}
className="relative w-full max-w-4xl"
>
{/* Círculo de fondo decorativo */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] bg-gradient-to-b from-light to-white rounded-full -z-10 blur-3xl opacity-60"></div>
{/* Imagen (Usamos un placeholder de alta calidad por ahora) */}
<div className="relative aspect-[16/9] w-full">
{/* Nota: En producción usarás el componente Image de Next.js.
Para este ejemplo rápido uso img estándar con una URL externa */}
<img
src="https://images.unsplash.com/photo-1610832958506-aa56368176cf?q=80&w=2670&auto=format&fit=crop"
alt="Bodegón de frutas frescas"
className="w-full h-full object-contain drop-shadow-2xl hover:scale-[1.02] transition-transform duration-700 ease-out"
/>
</div>
</motion.div>
</section>
);
}

View File

View File

@@ -0,0 +1,150 @@
"use client";
import { useState, useMemo } from "react";
import { Product, Category } from "@/types";
import ProductCard from "@/components/ui/ProductCard";
import { getProductImageUrl } from "@/lib/supabase";
import { cn } from "@/lib/utils";
interface CatalogBrowserProps {
initialProducts: Product[];
categories: Category[];
}
export default function CatalogBrowser({ initialProducts, categories }: CatalogBrowserProps) {
// AHORA: activeCategory guarda el ID ('all' o el UUID de la categoría)
const [activeCategory, setActiveCategory] = useState("all");
const [sortOrder, setSortOrder] = useState("Destacados");
// 1. Preparamos el menú (añadimos la opción "Todo" con ID 'all')
const menuCategories = useMemo(() => {
return [
{ id: 'all', name: 'Todo' },
...categories
];
}, [categories]);
// 2. Lógica de Filtrado y Ordenación
const filteredProducts = useMemo(() => {
let result = [...initialProducts];
// FILTRADO POR ID (Mucho más seguro)
if (activeCategory !== "all") {
result = result.filter((p) => p.category_id === activeCategory);
}
// Ordenar
if (sortOrder === "Precio: Menor a Mayor") {
result.sort((a, b) => a.price - b.price);
} else if (sortOrder === "Precio: Mayor a Menor") {
result.sort((a, b) => b.price - a.price);
} else if (sortOrder === "Novedades") {
result.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
}
return result;
}, [initialProducts, activeCategory, sortOrder]);
// Helper para obtener el nombre de la categoría de un producto
const getCategoryName = (catId: string) => {
const category = categories.find((c) => c.id === catId);
return category ? category.name : "General";
};
return (
<>
<header className="pt-40 pb-12 px-6 md:px-12 flex flex-col md:flex-row md:items-end justify-between gap-6 max-w-[1600px] mx-auto animate-fade-in-up">
<div>
<p className="text-gray-400 text-xs tracking-widest uppercase mb-2">Colección {new Date().getFullYear()}</p>
<h1 className="text-5xl md:text-7xl font-light tracking-tight text-dark">
La Despensa.
</h1>
</div>
<p className="text-gray-500 text-sm max-w-xs leading-relaxed text-right hidden md:block">
Explora nuestra selección de productos auténticos, importados directamente para tu cocina.
</p>
</header>
{/* FILTROS STICKY */}
<div className="sticky top-[80px] z-40 bg-white/95 backdrop-blur-md border-y border-gray-100 py-4 px-6 md:px-12 mb-12 transition-all">
<div className="max-w-[1600px] mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div className="w-full md:w-auto overflow-x-auto no-scrollbar">
<div className="flex gap-2 md:gap-4 text-sm whitespace-nowrap">
{menuCategories.map((cat) => (
<button
key={cat.id}
onClick={() => setActiveCategory(cat.id)} // Guardamos el ID
className={cn(
"px-6 py-2 rounded-full transition-all duration-300 border",
activeCategory === cat.id
? "bg-dark text-white border-dark font-medium"
: "bg-transparent text-gray-500 border-gray-200 hover:border-dark hover:text-dark"
)}
>
{cat.name}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>Ver:</span>
<select
value={sortOrder}
onChange={(e) => setSortOrder(e.target.value)}
className="bg-transparent text-dark font-medium outline-none cursor-pointer border-none focus:ring-0 py-0 pl-2"
>
<option value="Destacados">Destacados</option>
<option value="Novedades">Novedades</option>
<option value="Precio: Menor a Mayor">Precio: Menor a Mayor</option>
<option value="Precio: Mayor a Menor">Precio: Mayor a Menor</option>
</select>
</div>
</div>
</div>
<section className="px-6 md:px-12 pb-24 max-w-[1600px] mx-auto min-h-[50vh]">
{filteredProducts.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-x-6 gap-y-12">
{filteredProducts.map((product, index) => (
<ProductCard
key={product.id}
id={product.id}
title={product.name}
// Buscamos el nombre real usando el ID
category={getCategoryName(product.category_id)}
price={product.price}
imageUrl={getProductImageUrl(product.image_url)}
index={index + 1}
/>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<p className="text-lg font-light">No encontramos productos en esta categoría.</p>
<button
onClick={() => setActiveCategory("all")}
className="mt-4 text-dark border-b border-dark pb-0.5 hover:text-accent hover:border-accent transition"
>
Ver todos los productos
</button>
</div>
)}
<div className="mt-24 flex flex-col items-center justify-center gap-4 border-t border-gray-100 pt-12">
<p className="text-gray-400 text-sm">¿Buscas algo específico?</p>
<a
href="https://wa.me/34611947465"
target="_blank"
rel="noopener noreferrer"
className="bg-dark text-white px-8 py-3 rounded-full text-sm hover:bg-accent transition duration-300 flex items-center gap-2 shadow-lg shadow-gray-200 hover:shadow-green-100 transform hover:-translate-y-1"
>
<span className="font-bold text-lg">WA</span>
Consultar disponibilidad en tienda
</a>
</div>
</section>
</>
);
}

View File

View File

@@ -0,0 +1,17 @@
// src/components/ui/Marquee.tsx
export default function Marquee() {
return (
<div className="w-full py-6 border-y border-gray-100 bg-white overflow-hidden">
<div className="relative flex overflow-x-hidden group">
<div className="animate-marquee whitespace-nowrap flex gap-16 items-center">
{/* Repetimos el contenido suficientes veces para cubrir pantallas grandes */}
{[...Array(10)].map((_, i) => (
<span key={i} className="text-xl md:text-2xl font-light text-gray-300 uppercase tracking-widest select-none">
Orgánico <span className="text-accent"></span> Fresco <span className="text-accent"></span> Importado <span className="text-accent"></span> Local
</span>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,59 @@
// src/components/ui/ProductCard.tsx
import Image from "next/image";
import Link from "next/link";
import { Plus } from "lucide-react";
interface ProductCardProps {
id: string;
title: string;
category: string;
price: number;
imageUrl: string;
index?: number; // Para escalonar animaciones si queremos
}
export default function ProductCard({ id, title, category, price, imageUrl, index = 1 }: ProductCardProps) {
return (
<Link href={`/product/${id}`} className="group block cursor-pointer">
{/* Contenedor de Imagen (Gris pálido, bordes muy redondos) */}
<div className="bg-light rounded-[2rem] aspect-[3/4] flex items-center justify-center mb-5 relative overflow-hidden transition-colors duration-300 group-hover:bg-[#f0f0f0]">
{/* Número de índice estilo editorial */}
<span className="absolute top-6 left-6 text-xs font-medium text-gray-300 tracking-widest z-20">
{index.toString().padStart(2, '0')}
</span>
{/* Botón flotante "Quick Add" (aparece en hover) */}
<button className="absolute bottom-6 right-6 w-10 h-10 bg-white rounded-full flex items-center justify-center shadow-sm translate-y-20 opacity-0 group-hover:translate-y-0 group-hover:opacity-100 transition-all duration-300 z-20 hover:bg-accent hover:text-white">
<Plus className="w-5 h-5" />
</button>
{/* Imagen del Producto */}
<div className="relative w-3/4 h-3/4 transition-transform duration-700 ease-[cubic-bezier(0.25,1,0.5,1)] group-hover:scale-110">
<img
src={imageUrl}
alt={title}
className="w-full h-full object-contain drop-shadow-md group-hover:drop-shadow-xl transition-all duration-500"
/>
</div>
</div>
{/* Información del Producto */}
<div className="flex justify-between items-start px-2">
<div>
<h3 className="text-lg font-medium text-dark leading-tight group-hover:text-accent transition-colors">
{title}
</h3>
<p className="text-xs text-gray-400 uppercase tracking-wider mt-1 font-medium">
{category}
</p>
</div>
<div className="text-right">
<span className="text-dark font-medium block">
{new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(price)}
</span>
</div>
</div>
</Link>
);
}

View File

28
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,28 @@
import { createClient } from '@supabase/supabase-js';
// 1. Usamos tus variables exactas
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
// Aceptamos la clave nueva (sb_publishable) O la antigua (anon) por si acaso
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
// 2. Verificación de seguridad
if (!supabaseUrl || !supabaseKey) {
throw new Error(
'Faltan las variables de entorno. Asegúrate de que tu archivo .env.local tenga NEXT_PUBLIC_SUPABASE_URL y NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY'
);
}
// 3. Crear cliente (Supabase detecta automáticamente si es sb_publishable o anon)
export const supabase = createClient(supabaseUrl, supabaseKey);
// Helper de imágenes
export const getProductImageUrl = (imagePath: string | null) => {
if (!imagePath) return 'https://placehold.co/400x600/f8f8f8/e0e0e0?text=Sin+Imagen';
if (imagePath.startsWith('http')) return imagePath;
const { data } = supabase.storage
.from('products')
.getPublicUrl(imagePath);
return data.publicUrl;
};

7
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
// src/lib/utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

77
src/store/cart-store.ts Normal file
View File

@@ -0,0 +1,77 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { Product, CartItem } from '@/types';
interface CartState {
items: CartItem[];
isOpen: boolean;
addItem: (product: Product, quantity?: number) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
toggleCart: () => void;
openCart: () => void;
closeCart: () => void;
// Getters
getTotalPrice: () => number;
getTotalItems: () => number;
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
isOpen: false,
addItem: (product, quantity = 1) => {
const currentItems = get().items;
const existingItem = currentItems.find((item) => item.id === product.id);
if (existingItem) {
// Si ya existe, sumamos la cantidad
const updatedItems = currentItems.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
);
set({ items: updatedItems, isOpen: true }); // Abrimos el carrito al añadir
} else {
// Si es nuevo, lo añadimos
set({ items: [...currentItems, { ...product, quantity }], isOpen: true });
}
},
removeItem: (id) => {
set({ items: get().items.filter((item) => item.id !== id) });
},
updateQuantity: (id, quantity) => {
if (quantity < 1) return;
set({
items: get().items.map((item) =>
item.id === id ? { ...item, quantity } : item
),
});
},
clearCart: () => set({ items: [] }),
// Control de UI del Drawer
toggleCart: () => set({ isOpen: !get().isOpen }),
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
getTotalPrice: () => {
return get().items.reduce((total, item) => total + item.price * item.quantity, 0);
},
getTotalItems: () => {
return get().items.reduce((total, item) => total + item.quantity, 0);
},
}),
{
name: 'surtilatino-cart', // Nombre en LocalStorage
storage: createJSONStorage(() => localStorage),
}
)
);

View File

25
src/types/index.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface Product {
id: string;
created_at: string;
name: string;
description: string | null;
price: number;
stock: number;
category_id: string;
image_url: string | null; // El path dentro del bucket
is_active: boolean;
stripe_product_id?: string;
stripe_price_id?: string;
}
export interface Category {
id: string;
name: string; // Ej: "Frutas & Verduras"
slug: string; // Ej: "frutas-verduras" (útil para URLs)
description?: string;
}
export interface CartItem extends Product {
quantity: number;
}

33
structure.sh Normal file
View File

@@ -0,0 +1,33 @@
# 1. Crear estructura de directorios dentro de src
mkdir -p src/components/layout # Navbar, Footer
mkdir -p src/components/ui # Botones, Tarjetas, Elementos base
mkdir -p src/components/sections # Hero, Grids de productos, Banners
mkdir -p src/lib # Cliente Supabase, utilidades (clsx)
mkdir -p src/store # Estado global (Zustand)
mkdir -p src/types # Definiciones de TypeScript (DB, Productos)
mkdir -p src/hooks # Hooks personalizados (si fueran necesarios)
# 2. Crear archivos para la Lógica y Configuración
touch src/lib/supabase.ts # Inicialización del cliente Supabase
touch src/lib/utils.ts # Helper para clases css (tailwind-merge)
touch src/store/cart-store.ts # Store de carrito (Zustand)
touch src/types/database.types.ts # Tipos generados de Supabase
touch src/types/index.ts # Tipos globales de la app
# 3. Crear archivos de Componentes (Vacíos por ahora)
# Layout
touch src/components/layout/Navbar.tsx
touch src/components/layout/Footer.tsx
# Secciones Principales (Home)
touch src/components/sections/Hero.tsx
touch src/components/sections/FeaturedGrid.tsx
touch src/components/sections/InfoSection.tsx
# Componentes de UI (Estilo Boutique)
touch src/components/ui/ProductCard.tsx
touch src/components/ui/Button.tsx
touch src/components/ui/SectionHeader.tsx
# 4. Mensaje de confirmación
echo "✅ Estructura 'Surtilatino Minimal' generada correctamente."