Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33a44f2b31 |
BIN
assets/pasted-20260228-202230-c78e8860.png
Normal file
BIN
assets/pasted-20260228-202230-c78e8860.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
assets/pasted-20260302-181039-8b1c369a.png
Normal file
BIN
assets/pasted-20260302-181039-8b1c369a.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@ -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) {
|
||||
}
|
||||
};
|
||||
86
backend/src/db/seeders/20260228100001-food-store-data.js
Normal file
86
backend/src/db/seeders/20260228100001-food-store-data.js
Normal 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, {});
|
||||
}
|
||||
};
|
||||
@ -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
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
285
frontend/src/pages/checkout.tsx
Normal file
285
frontend/src/pages/checkout.tsx
Normal 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>;
|
||||
};
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user