Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
33a44f2b31 Auto commit: 2026-03-02T18:13:27.414Z 2026-03-02 18:13:27 +00:00
11 changed files with 671 additions and 157 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -0,0 +1,44 @@
module.exports = {
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
const [publicRole] = await queryInterface.sequelize.query(
"SELECT id FROM roles WHERE name = 'Public' LIMIT 1"
);
if (!publicRole || publicRole.length === 0) {
console.error("Public role not found");
return;
}
const publicRoleId = publicRole[0].id;
const [permissions] = await queryInterface.sequelize.query(
"SELECT id, name FROM permissions WHERE name IN ('READ_PRODUCTS', 'READ_CATEGORIES', 'CREATE_ORDERS', 'CREATE_ORDER_ITEMS', 'CREATE_PAYMENTS')"
);
const rolePermissions = permissions.map(p => ({
createdAt: new Date(),
updatedAt: new Date(),
roles_permissionsId: publicRoleId,
permissionId: p.id
})).filter(rp => rp.permissionId);
if (rolePermissions.length > 0) {
// Check if they already exist to avoid duplicates
for (const rp of rolePermissions) {
const [existing] = await queryInterface.sequelize.query(
`SELECT 1 FROM "rolesPermissionsPermissions" WHERE "roles_permissionsId" = '${rp.roles_permissionsId}' AND "permissionId" = '${rp.permissionId}'`
);
if (existing.length === 0) {
await queryInterface.bulkInsert("rolesPermissionsPermissions", [rp]);
}
}
}
},
async down(queryInterface) {
}
};

View File

@ -0,0 +1,86 @@
module.exports = {
async up(queryInterface) {
const createdAt = new Date();
const updatedAt = new Date();
const [categories] = await queryInterface.bulkInsert('categories', [
{ id: '10000000-0000-0000-0000-000000000001', name: 'Bebidas', slug: 'bebidas', description: 'Refrescos, jugos y más', createdAt, updatedAt },
{ id: '10000000-0000-0000-0000-000000000002', name: 'Alimentos Ligeros', slug: 'alimentos-ligeros', description: 'Snacks y comidas rápidas', createdAt, updatedAt },
{ id: '10000000-0000-0000-0000-000000000003', name: 'Otros', slug: 'otros', description: 'Productos varios', createdAt, updatedAt }
], { returning: true });
await queryInterface.bulkInsert('products', [
{
id: '20000000-0000-0000-0000-000000000001',
name: 'Coca Cola 500ml',
sku: 'BEB-001',
slug: 'coca-cola-500ml',
short_description: 'Refresco de cola',
description: 'Bebida gaseosa refrescante',
price: 1.50,
stock_quantity: 100,
is_active: true,
categoryId: '10000000-0000-0000-0000-000000000001',
createdAt, updatedAt
},
{
id: '20000000-0000-0000-0000-000000000002',
name: 'Jugo de Naranja Natural',
sku: 'BEB-002',
slug: 'jugo-naranja',
short_description: 'Jugo 100% natural',
description: 'Exprimido al momento',
price: 2.50,
stock_quantity: 50,
is_active: true,
categoryId: '10000000-0000-0000-0000-000000000001',
createdAt, updatedAt
},
{
id: '20000000-0000-0000-0000-000000000003',
name: 'Sándwich de Jamón y Queso',
sku: 'ALM-001',
slug: 'sandwich-jamon-queso',
short_description: 'Clásico sándwich',
description: 'Pan integral, jamón de pavo y queso gouda',
price: 3.50,
stock_quantity: 30,
is_active: true,
categoryId: '10000000-0000-0000-0000-000000000002',
createdAt, updatedAt
},
{
id: '20000000-0000-0000-0000-000000000004',
name: 'Papas Fritas Artesanales',
sku: 'ALM-002',
slug: 'papas-fritas',
short_description: 'Crunchy snacks',
description: 'Papas fritas con sal de mar',
price: 1.80,
stock_quantity: 80,
is_active: true,
categoryId: '10000000-0000-0000-0000-000000000002',
createdAt, updatedAt
},
{
id: '20000000-0000-0000-0000-000000000005',
name: 'Café Americano',
sku: 'BEB-003',
slug: 'cafe-americano',
short_description: 'Café caliente',
description: 'Granos recién molidos',
price: 1.20,
stock_quantity: 200,
is_active: true,
categoryId: '10000000-0000-0000-0000-000000000001',
createdAt, updatedAt
}
]);
},
async down(queryInterface) {
await queryInterface.bulkDelete('products', null, {});
await queryInterface.bulkDelete('categories', null, {});
}
};

View File

@ -1,4 +1,3 @@
const express = require('express');
const cors = require('cors');
const app = express();
@ -111,9 +110,9 @@ app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoute
app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes);
app.use('/api/categories', passport.authenticate('jwt', {session: false}), categoriesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/products', passport.authenticate('jwt', {session: false}), productsRoutes);
app.use('/api/products', productsRoutes);
app.use('/api/addresses', passport.authenticate('jwt', {session: false}), addressesRoutes);
@ -121,11 +120,11 @@ app.use('/api/carts', passport.authenticate('jwt', {session: false}), cartsRoute
app.use('/api/cart_items', passport.authenticate('jwt', {session: false}), cart_itemsRoutes);
app.use('/api/orders', passport.authenticate('jwt', {session: false}), ordersRoutes);
app.use('/api/orders', ordersRoutes);
app.use('/api/order_items', passport.authenticate('jwt', {session: false}), order_itemsRoutes);
app.use('/api/order_items', order_itemsRoutes);
app.use('/api/payments', passport.authenticate('jwt', {session: false}), paymentsRoutes);
app.use('/api/payments', paymentsRoutes);
app.use('/api/coupons', passport.authenticate('jwt', {session: false}), couponsRoutes);
@ -175,4 +174,4 @@ db.sequelize.sync().then(function () {
});
});
module.exports = app;
module.exports = app;

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

View File

@ -5,6 +5,7 @@ import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
import Link from 'next/link';
import Logo from './Logo';
type Props = {
@ -37,11 +38,9 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
<div
className={`flex flex-row h-14 items-center justify-between ${asideBrandStyle}`}
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">Tienda Virtual</b>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0 flex items-center justify-center lg:justify-start xl:justify-center gap-2">
<Logo className="w-auto h-8" />
<b className="font-black">Tienda Virtual</b>
</div>
<button
className="hidden lg:inline-block xl:hidden p-3"
@ -60,4 +59,4 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
</div>
</aside>
)
}
}

View File

@ -7,9 +7,9 @@ type Props = {
export default function Logo({ className = '' }: Props) {
return (
<img
src={"https://flatlogic.com/logo.svg"}
src={"/logo.png"}
className={className}
alt={'Flatlogic logo'}>
alt={'App logo'}>
</img>
)
}
}

View File

@ -10,6 +10,8 @@ import {
mdiThemeLightDark,
mdiGithub,
mdiVuejs,
mdiCart,
mdiShopping,
} from '@mdi/js'
import { MenuNavBarItem } from './interfaces'
@ -47,7 +49,16 @@ const menuNavBar: MenuNavBarItem[] = [
]
export const webPagesNavBar = [
{
href: '/',
label: 'Shop',
icon: mdiShopping,
},
{
href: '/checkout',
label: 'Cart',
icon: mdiCart,
}
];
export default menuNavBar
export default menuNavBar

View File

@ -0,0 +1,285 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { mdiCart, mdiCheckCircle, mdiAlertCircle, mdiArrowLeft, mdiCreditCard, mdiShopping } from '@mdi/js';
import BaseButton from '../components/BaseButton';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import BaseIcon from '../components/BaseIcon';
import FormField from '../components/FormField';
import axios from 'axios';
import Logo from '../components/Logo';
export default function Checkout() {
const router = useRouter();
const [cart, setCart] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState({
customer_name: '',
customer_email: '',
customer_phone: '',
address_line1: '',
city: '',
notes: '',
});
useEffect(() => {
const savedCart = localStorage.getItem('cart');
if (savedCart) {
try {
const parsed = JSON.parse(savedCart);
setCart(parsed);
if (parsed.length === 0) {
router.push('/');
}
} catch (e) {
console.error('Failed to parse cart', e);
router.push('/');
}
} else {
router.push('/');
}
}, [router]);
const cartTotal = cart.reduce((acc, item) => acc + item.product.price * item.quantity, 0);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setError(null);
try {
const orderNumber = `ORD-${Math.floor(100000 + Math.random() * 900000)}`;
const orderData = {
order_number: orderNumber,
customer_note: `Name: ${formData.customer_name}, Email: ${formData.customer_email}, Phone: ${formData.customer_phone}, Address: ${formData.address_line1}, ${formData.city}. Notes: ${formData.notes}`,
subtotal: cartTotal,
total: cartTotal,
status: 'pending_payment',
payment_method: 'card',
payment_status: 'authorized',
};
await axios.post('/orders', { data: orderData });
localStorage.removeItem('cart');
setIsSuccess(true);
} catch (err: any) {
console.error('Checkout failed', err);
setError('Hubo un problema al procesar tu pedido. Por favor intenta de nuevo.');
} finally {
setIsSubmitting(false);
}
};
if (isSuccess) {
return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-dark-900">
<Head><title>{getPageTitle('Pedido Exitoso')}</title></Head>
<header className="bg-white dark:bg-dark-800 border-b border-gray-200 dark:border-dark-700 h-16 flex items-center">
<div className="container mx-auto px-4">
<Link href="/" className="flex items-center gap-2">
<Logo className="w-auto h-10" />
<span className="text-2xl font-extrabold text-emerald-600 hidden sm:inline">Tienda Virtual</span>
</Link>
</div>
</header>
<main className="flex-grow container mx-auto px-4 py-20 text-center">
<BaseIcon path={mdiCheckCircle} size={80} className="text-emerald-500 mx-auto mb-6" />
<h1 className="text-3xl font-extrabold mb-4">¡Gracias por tu compra!</h1>
<p className="text-xl text-gray-500 mb-8">Tu pedido ha sido recibido y está siendo procesado.</p>
<BaseButton label="Volver a la tienda" color="success" onClick={() => router.push('/')} icon={mdiArrowLeft} />
</main>
<footer className="bg-white dark:bg-dark-800 py-12 border-t border-gray-200 dark:border-dark-700">
<div className="container mx-auto px-4 text-center">
<p className="text-gray-500">© 2026 Tienda Virtual. Todos los derechos reservados.</p>
</div>
</footer>
</div>
);
}
return (
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-dark-900">
<Head>
<title>{getPageTitle('Tienda Virtual - Finalizar Compra')}</title>
</Head>
<header className="bg-white dark:bg-dark-800 border-b border-gray-200 dark:border-dark-700 h-16 flex items-center sticky top-0 z-50">
<div className="container mx-auto px-4">
<Link href="/" className="flex items-center gap-2">
<Logo className="w-auto h-10" />
<span className="text-2xl font-extrabold text-emerald-600 hidden sm:inline">Tienda Virtual</span>
</Link>
</div>
</header>
<main className="flex-grow container mx-auto px-4 py-12">
<div className="flex flex-col lg:flex-row gap-12">
{/* Checkout Form */}
<div className="flex-grow">
<h2 className="text-3xl font-extrabold mb-8 flex items-center gap-2">
<BaseButton icon={mdiArrowLeft} color="white" onClick={() => router.push('/')} className="mr-2" />
Información de Envío
</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label="Nombre Completo" help="Como aparece en tu identificación">
<input
name="customer_name"
value={formData.customer_name}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all dark:bg-dark-800 dark:border-dark-700"
placeholder="Juan Pérez"
/>
</FormField>
<FormField label="Correo Electrónico">
<input
name="customer_email"
type="email"
value={formData.customer_email}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all dark:bg-dark-800 dark:border-dark-700"
placeholder="juan@ejemplo.com"
/>
</FormField>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<FormField label="Teléfono">
<input
name="customer_phone"
value={formData.customer_phone}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all dark:bg-dark-800 dark:border-dark-700"
placeholder="+1 234 567 890"
/>
</FormField>
<FormField label="Ciudad">
<input
name="city"
value={formData.city}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all dark:bg-dark-800 dark:border-dark-700"
placeholder="Ciudad de México"
/>
</FormField>
</div>
<FormField label="Dirección de Entrega">
<input
name="address_line1"
value={formData.address_line1}
onChange={handleInputChange}
required
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all dark:bg-dark-800 dark:border-dark-700"
placeholder="Calle, número, colonia..."
/>
</FormField>
<FormField label="Notas Adicionales (Opcional)">
<textarea
name="notes"
value={formData.notes}
onChange={handleInputChange}
rows={3}
className="w-full px-4 py-2 rounded-lg border border-gray-300 focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none transition-all dark:bg-dark-800 dark:border-dark-700"
placeholder="Instrucciones especiales para el repartidor"
/>
</FormField>
<div className="bg-emerald-50 dark:bg-dark-800/50 p-6 rounded-xl border border-emerald-100 dark:border-emerald-900">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
<BaseIcon path={mdiCreditCard} /> Método de Pago
</h3>
<div className="flex items-center gap-4 p-4 bg-white dark:bg-dark-800 rounded-lg border-2 border-emerald-500">
<input type="radio" checked readOnly className="text-emerald-500" />
<span className="font-bold flex-grow">Tarjeta de Crédito / Débito (Simulado)</span>
<div className="flex gap-2">
<span className="bg-gray-100 dark:bg-dark-700 px-2 py-1 rounded text-xs">VISA</span>
<span className="bg-gray-100 dark:bg-dark-700 px-2 py-1 rounded text-xs">Mastercard</span>
</div>
</div>
</div>
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded-lg flex items-center gap-2">
<BaseIcon path={mdiAlertCircle} /> {error}
</div>
)}
<BaseButton
type="submit"
label={isSubmitting ? "Procesando..." : `Confirmar y Pagar $${cartTotal.toFixed(2)}`}
color="success"
className="w-full rounded-xl py-4 text-xl font-bold"
disabled={isSubmitting}
/>
</form>
</div>
{/* Order Summary */}
<div className="w-full lg:w-1/3">
<div className="bg-white dark:bg-dark-800 p-8 rounded-2xl shadow-sm border border-gray-100 dark:border-dark-700 sticky top-24">
<h3 className="text-xl font-bold mb-6">Resumen del Pedido</h3>
<div className="space-y-4 mb-8">
{cart.map((item) => (
<div key={item.product.id} className="flex justify-between items-center text-sm">
<div className="flex items-center gap-3">
<span className="bg-gray-100 dark:bg-dark-700 px-2 py-1 rounded font-bold text-xs">{item.quantity}x</span>
<span className="font-medium truncate max-w-[150px]">{item.product.name}</span>
</div>
<span className="font-bold">${(item.product.price * item.quantity).toFixed(2)}</span>
</div>
))}
</div>
<div className="space-y-2 border-t pt-4 mb-6">
<div className="flex justify-between text-gray-500">
<span>Subtotal</span>
<span>${cartTotal.toFixed(2)}</span>
</div>
<div className="flex justify-between text-gray-500">
<span>Envío</span>
<span className="text-emerald-600 font-bold">GRATIS</span>
</div>
</div>
<div className="flex justify-between items-center text-2xl font-extrabold text-emerald-600 border-t pt-4">
<span>Total</span>
<span>${cartTotal.toFixed(2)}</span>
</div>
</div>
</div>
</div>
</main>
<footer className="bg-white dark:bg-dark-800 py-12 border-t border-gray-200 dark:border-dark-700">
<div className="container mx-auto px-4 text-center">
<p className="text-gray-500">© 2026 Tienda Virtual. Todos los derechos reservados.</p>
</div>
</footer>
</div>
);
}
Checkout.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -1,166 +1,256 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { mdiCart, mdiShopping, mdiPlus, mdiMinus, mdiDelete, mdiAccount } from '@mdi/js';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { fetch as fetchProducts } from '../stores/products/productsSlice';
import { fetch as fetchCategories } from '../stores/categories/categoriesSlice';
import BaseIcon from '../components/BaseIcon';
import Logo from '../components/Logo';
export default function Storefront() {
const dispatch = useAppDispatch();
const router = useRouter();
const { products, loading: productsLoading } = useAppSelector((state) => state.products);
const { categories } = useAppSelector((state) => state.categories);
const [selectedCategory, setSelectedCategory] = useState('all');
const [cart, setCart] = useState<any[]>([]);
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('image');
const [contentPosition, setContentPosition] = useState('right');
const textColor = useAppSelector((state) => state.style.linkColor);
useEffect(() => {
dispatch(fetchProducts({ query: '?limit=100' }));
dispatch(fetchCategories({ query: '' }));
const savedCart = localStorage.getItem('cart');
if (savedCart) {
try {
setCart(JSON.parse(savedCart));
} catch (e) {
console.error('Failed to parse cart', e);
}
}
}, [dispatch]);
const title = 'Tienda Virtual'
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cart));
}, [cart]);
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
const addToCart = (product: any) => {
setCart((prevCart) => {
const existingItem = prevCart.find((item) => item.product.id === product.id);
if (existingItem) {
return prevCart.map((item) =>
item.product.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
);
}
return [...prevCart, { product, quantity: 1 }];
});
};
const removeFromCart = (productId: string) => {
setCart((prevCart) => prevCart.filter((item) => item.product.id !== productId));
};
const updateQuantity = (productId: string, delta: number) => {
setCart((prevCart) =>
prevCart.map((item) => {
if (item.product.id === productId) {
const newQty = Math.max(1, item.quantity + delta);
return { ...item, quantity: newQty };
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
return item;
})
);
};
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
const filteredProducts = selectedCategory === 'all'
? products
: products.filter((p: any) => p.categoryId === selectedCategory || p.category?.id === selectedCategory);
const cartTotal = cart.reduce((acc, item) => acc + item.product.price * item.quantity, 0);
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<div className="min-h-screen flex flex-col bg-gray-50 dark:bg-dark-900">
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Tienda Virtual - Inicio')}</title>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Tienda Virtual app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
{/* Simple Header */}
<header className="bg-white dark:bg-dark-800 border-b border-gray-200 dark:border-dark-700 sticky top-0 z-50">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<Logo className="w-auto h-10" />
<span className="text-2xl font-extrabold text-emerald-600 hidden sm:inline">Tienda Virtual</span>
</Link>
<div className="flex items-center gap-4">
<Link href="/login" className="text-gray-600 hover:text-emerald-600 dark:text-gray-300 flex items-center gap-1 font-medium">
<BaseIcon path={mdiAccount} size={20} /> Admin
</Link>
<button onClick={() => router.push('/checkout')} className="relative p-2 bg-emerald-50 text-emerald-600 rounded-full">
<BaseIcon path={mdiCart} />
{cart.length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-[10px] font-bold w-5 h-5 flex items-center justify-center rounded-full">
{cart.reduce((a, b) => a + b.quantity, 0)}
</span>
)}
</button>
</div>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</header>
{/* Hero Section */}
<section className="bg-emerald-600 py-16 px-6 text-center text-white">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl md:text-6xl font-extrabold mb-4">¡Tus antojos a un clic!</h1>
<p className="text-xl md:text-2xl mb-8 opacity-90">Bebidas, alimentos ligeros y más, directo a tu puerta.</p>
<div className="flex justify-center gap-4">
<a href="#productos" className="bg-white text-emerald-700 px-8 py-3 rounded-full font-bold hover:bg-emerald-50 transition-colors">Ver Menú</a>
</div>
</div>
</section>
<main className="flex-grow container mx-auto px-4 py-8 flex flex-col md:flex-row gap-8" id="productos">
{/* Sidebar / Filters */}
<aside className="w-full md:w-1/4">
<div className="bg-white dark:bg-dark-800 p-6 rounded-xl shadow-sm border border-gray-100 dark:border-dark-700 sticky top-20">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<BaseIcon path={mdiShopping} /> Categorías
</h2>
<ul className="space-y-2">
<li>
<button
onClick={() => setSelectedCategory('all')}
className={`w-full text-left px-4 py-2 rounded-lg transition-colors ${selectedCategory === 'all' ? 'bg-emerald-100 text-emerald-700 font-bold' : 'hover:bg-gray-100 dark:hover:bg-dark-700'}`}
>
Todos los productos
</button>
</li>
{categories && categories.map((cat: any) => (
<li key={cat.id}>
<button
onClick={() => setSelectedCategory(cat.id)}
className={`w-full text-left px-4 py-2 rounded-lg transition-colors ${selectedCategory === cat.id ? 'bg-emerald-100 text-emerald-700 font-bold' : 'hover:bg-gray-100 dark:hover:bg-dark-700'}`}
>
{cat.name}
</button>
</li>
))}
</ul>
</div>
</aside>
{/* Product Grid */}
<section className="flex-grow">
{productsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="animate-pulse bg-gray-200 dark:bg-dark-700 h-80 rounded-xl"></div>
))}
</div>
) : (
<>
{filteredProducts && filteredProducts.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredProducts.map((product: any) => (
<CardBox key={product.id} className="flex flex-col h-full hover:shadow-lg transition-shadow border-none overflow-hidden">
<div className="h-48 bg-emerald-50 dark:bg-dark-700 flex items-center justify-center relative">
{product.images && product.images.length > 0 ? (
<img src={product.images[0].url} alt={product.name} className="object-cover w-full h-full" />
) : (
<BaseIcon path={mdiShopping} size={48} className="text-emerald-300" />
)}
<span className="absolute top-2 right-2 bg-emerald-500 text-white text-xs font-bold px-2 py-1 rounded">
${parseFloat(product.price).toFixed(2)}
</span>
</div>
<div className="p-4 flex-grow flex flex-col">
<h3 className="text-lg font-bold mb-1">{product.name}</h3>
<p className="text-sm text-gray-500 dark:text-dark-400 mb-4 line-clamp-2">{product.short_description || product.description}</p>
<div className="mt-auto">
<BaseButton
label="Agregar al carrito"
color="success"
className="w-full rounded-lg"
onClick={() => addToCart(product)}
icon={mdiPlus}
/>
</div>
</div>
</CardBox>
))}
</div>
) : (
<div className="text-center py-20 bg-white dark:bg-dark-800 rounded-xl">
<BaseIcon path={mdiShopping} size={64} className="mx-auto text-gray-300 mb-4" />
<h3 className="text-xl font-bold">No se encontraron productos</h3>
<p className="text-gray-500">Prueba con otra categoría.</p>
</div>
)}
</>
)}
</section>
{/* Cart Drawer */}
{cart.length > 0 && (
<aside className="w-full md:w-1/4">
<div className="bg-white dark:bg-dark-800 p-6 rounded-xl shadow-lg border-2 border-emerald-500 dark:border-emerald-600 sticky top-20">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<BaseIcon path={mdiCart} /> Tu Carrito
</h2>
<div className="space-y-4 max-h-[60vh] overflow-y-auto mb-6 pr-2">
{cart.map((item) => (
<div key={item.product.id} className="flex items-center gap-3">
<div className="w-12 h-12 bg-gray-100 dark:bg-dark-700 rounded flex-shrink-0 flex items-center justify-center overflow-hidden">
{item.product.images && item.product.images.length > 0 ? (
<img src={item.product.images[0].url} alt={item.product.name} className="object-cover w-full h-full" />
) : (
<BaseIcon path={mdiShopping} className="text-gray-300" />
)}
</div>
<div className="flex-grow">
<h4 className="text-sm font-bold truncate">{item.product.name}</h4>
<div className="flex items-center gap-2 mt-1">
<button onClick={() => updateQuantity(item.product.id, -1)} className="text-gray-500 hover:text-emerald-600"><BaseIcon path={mdiMinus} size={16} /></button>
<span className="text-xs font-bold">{item.quantity}</span>
<button onClick={() => updateQuantity(item.product.id, 1)} className="text-gray-500 hover:text-emerald-600"><BaseIcon path={mdiPlus} size={16} /></button>
<span className="text-xs ml-auto">${(item.product.price * item.quantity).toFixed(2)}</span>
</div>
</div>
<button onClick={() => removeFromCart(item.product.id)} className="text-red-400 hover:text-red-600"><BaseIcon path={mdiDelete} size={18} /></button>
</div>
))}
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center mb-4">
<span className="text-gray-500">Total:</span>
<span className="text-2xl font-extrabold text-emerald-600">${cartTotal.toFixed(2)}</span>
</div>
<BaseButton
label="Ir a Pagar"
color="success"
className="w-full rounded-lg py-3 text-lg font-bold"
onClick={() => router.push('/checkout')}
/>
</div>
</div>
</aside>
)}
</main>
<footer className="bg-white dark:bg-dark-800 py-12 border-t border-gray-200 dark:border-dark-700 mt-20">
<div className="container mx-auto px-4 text-center">
<p className="text-gray-500">© 2026 Tienda Virtual. Todos los derechos reservados.</p>
</div>
</footer>
</div>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
Storefront.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};