Auto commit: 2026-02-28T19:37:33.140Z
This commit is contained in:
parent
1ad6469a29
commit
428e4e9eed
40
backend/src/db/migrations/1772306633138.js
Normal file
40
backend/src/db/migrations/1772306633138.js
Normal file
@ -0,0 +1,40 @@
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
const createdAt = new Date();
|
||||
const updatedAt = new Date();
|
||||
|
||||
const [roles] = await queryInterface.sequelize.query(
|
||||
`SELECT id FROM roles WHERE name = 'Public' LIMIT 1;`
|
||||
);
|
||||
const publicRoleId = roles[0]?.id;
|
||||
|
||||
if (!publicRoleId) return;
|
||||
|
||||
const [permissions] = await queryInterface.sequelize.query(
|
||||
`SELECT id FROM permissions WHERE name IN ('READ_PRODUCTS', 'READ_PRODUCT_CATEGORIES');`
|
||||
);
|
||||
|
||||
if (permissions.length === 0) return;
|
||||
|
||||
const rolePermissions = permissions.map(p => ({
|
||||
roles_permissionsId: publicRoleId,
|
||||
permissionId: p.id,
|
||||
createdAt,
|
||||
updatedAt
|
||||
}));
|
||||
|
||||
// Use a try-catch to ignore duplicate key errors if already exists
|
||||
for (const rp of rolePermissions) {
|
||||
try {
|
||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', [rp]);
|
||||
} catch (e) {
|
||||
console.log(`Permission ${rp.permissionId} already exists for role ${rp.roles_permissionsId}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
// Optional: remove the permissions if needed
|
||||
}
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const app = express();
|
||||
@ -121,11 +120,11 @@ app.use('/api/permissions', passport.authenticate('jwt', {session: false}), perm
|
||||
|
||||
app.use('/api/customer_addresses', passport.authenticate('jwt', {session: false}), customer_addressesRoutes);
|
||||
|
||||
app.use('/api/product_categories', passport.authenticate('jwt', {session: false}), product_categoriesRoutes);
|
||||
app.use('/api/product_categories', product_categoriesRoutes);
|
||||
|
||||
app.use('/api/product_tags', passport.authenticate('jwt', {session: false}), product_tagsRoutes);
|
||||
|
||||
app.use('/api/products', passport.authenticate('jwt', {session: false}), productsRoutes);
|
||||
app.use('/api/products', productsRoutes);
|
||||
|
||||
app.use('/api/inventory_movements', passport.authenticate('jwt', {session: false}), inventory_movementsRoutes);
|
||||
|
||||
@ -191,4 +190,4 @@ db.sequelize.sync().then(function () {
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
module.exports = app;
|
||||
112
frontend/src/components/CartDrawer.tsx
Normal file
112
frontend/src/components/CartDrawer.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React from 'react';
|
||||
import { mdiClose, mdiTrashCan, mdiMinus, mdiPlus } from '@mdi/js';
|
||||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||
import { removeFromCart, updateQuantity, toggleCart } from '../stores/localCartSlice';
|
||||
import BaseButton from './BaseButton';
|
||||
import BaseIcon from './BaseIcon';
|
||||
|
||||
const CartDrawer = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { items, isOpen } = useAppSelector((state) => state.localCart);
|
||||
|
||||
const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex justify-end">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
||||
onClick={() => dispatch(toggleCart())}
|
||||
></div>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="relative w-full max-w-md bg-white h-full shadow-xl flex flex-col animate-fade-in-right">
|
||||
<div className="p-4 border-b flex items-center justify-between bg-orange-600 text-white">
|
||||
<h2 className="text-xl font-bold">Tu Carrito</h2>
|
||||
<button onClick={() => dispatch(toggleCart())} className="p-1 hover:bg-orange-700 rounded">
|
||||
<BaseIcon path={mdiClose} size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow overflow-y-auto p-4 space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-gray-500 mb-4">Tu carrito está vacío</p>
|
||||
<BaseButton
|
||||
label="Empezar a comprar"
|
||||
color="info"
|
||||
onClick={() => dispatch(toggleCart())}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="flex items-center space-x-4 border-b pb-4">
|
||||
{item.image && (
|
||||
<img src={item.image} alt={item.name} className="w-16 h-16 object-cover rounded" />
|
||||
)}
|
||||
<div className="flex-grow">
|
||||
<h3 className="font-semibold text-gray-800">{item.name}</h3>
|
||||
<p className="text-orange-600 font-bold">${item.price.toFixed(2)}</p>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<button
|
||||
onClick={() => dispatch(updateQuantity({ id: item.id, quantity: item.quantity - 1 }))}
|
||||
className="p-1 border rounded hover:bg-gray-100"
|
||||
>
|
||||
<BaseIcon path={mdiMinus} size={16} />
|
||||
</button>
|
||||
<span className="w-8 text-center">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => dispatch(updateQuantity({ id: item.id, quantity: item.quantity + 1 }))}
|
||||
className="p-1 border rounded hover:bg-gray-100"
|
||||
>
|
||||
<BaseIcon path={mdiPlus} size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dispatch(removeFromCart(item.id))}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<BaseIcon path={mdiTrashCan} size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 && (
|
||||
<div className="p-4 border-t bg-gray-50">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-lg font-semibold text-gray-600">Total:</span>
|
||||
<span className="text-2xl font-bold text-orange-600">${totalPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
<BaseButton
|
||||
label="Finalizar Compra"
|
||||
color="success"
|
||||
className="w-full py-3 text-lg font-bold"
|
||||
onClick={() => {
|
||||
alert('¡Próximamente! Estamos preparando el proceso de pago.');
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-center text-gray-400 mt-2">
|
||||
Envío gratuito en pedidos superiores a $50
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<style jsx>{`
|
||||
@keyframes fade-in-right {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.animate-fade-in-right {
|
||||
animation: fade-in-right 0.3s ease-out forwards;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartDrawer;
|
||||
@ -1,166 +1,280 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
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 { useAppSelector, useAppDispatch } from '../stores/hooks';
|
||||
import { fetch as fetchProducts } from '../stores/products/productsSlice';
|
||||
import { fetch as fetchCategories } from '../stores/product_categories/product_categoriesSlice';
|
||||
import ImageField from '../components/ImageField';
|
||||
import { mdiCartOutline, mdiArrowRight } from '@mdi/js';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import CartDrawer from '../components/CartDrawer';
|
||||
import { addToCart, toggleCart } from '../stores/localCartSlice';
|
||||
|
||||
export default function Home() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { products, loading: productsLoading } = useAppSelector((state) => state.products);
|
||||
const { product_categories, loading: categoriesLoading } = useAppSelector((state) => state.product_categories);
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode);
|
||||
const { items: cartItems } = useAppSelector((state) => state.localCart);
|
||||
|
||||
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('background');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const cartCount = cartItems.reduce((sum, item) => sum + item.quantity, 0);
|
||||
|
||||
const title = 'El Calentico Store'
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
dispatch(fetchProducts({ query: '?limit=8' }));
|
||||
dispatch(fetchCategories({ query: '' }));
|
||||
}, [dispatch]);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
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 handleAddToCart = (product: any) => {
|
||||
dispatch(addToCart({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
image: product.images ? product.images[0]?.url : null
|
||||
}));
|
||||
dispatch(toggleCart());
|
||||
};
|
||||
|
||||
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',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
</Head>
|
||||
const heroImage = "https://images.pexels.com/photos/958545/pexels-photo-958545.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1";
|
||||
|
||||
<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 El Calentico Store 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'
|
||||
/>
|
||||
return (
|
||||
<div className={`min-h-screen ${darkMode ? 'bg-dark-900 text-white' : 'bg-gray-50 text-gray-900'}`}>
|
||||
<Head>
|
||||
<title>{getPageTitle('El Calentico - Tu Tienda Virtual')}</title>
|
||||
</Head>
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<CartDrawer />
|
||||
|
||||
{/* Navigation / Header */}
|
||||
<nav className={`sticky top-0 z-40 w-full border-b ${darkMode ? 'bg-dark-800 border-dark-700' : 'bg-white border-gray-200'} px-4 py-3 shadow-sm`}>
|
||||
<div className="container mx-auto flex items-center justify-between">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-orange-600">El Calentico</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/search" className="hidden md:block text-sm font-medium hover:text-orange-600 transition-colors">
|
||||
Buscar
|
||||
</Link>
|
||||
|
||||
{/* Cart Toggle Button */}
|
||||
<button
|
||||
onClick={() => dispatch(toggleCart())}
|
||||
className="relative p-2 text-gray-600 hover:text-orange-600 transition-colors"
|
||||
>
|
||||
<BaseIcon path={mdiCartOutline} size={28} />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute top-0 right-0 bg-orange-600 text-white text-[10px] font-bold w-5 h-5 flex items-center justify-center rounded-full border-2 border-white">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{currentUser ? (
|
||||
<Link href="/dashboard" className="text-sm font-medium px-4 py-2 rounded-full bg-orange-600 text-white hover:bg-orange-700 transition-colors">
|
||||
Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/login" className="hidden sm:block text-sm font-medium hover:text-orange-600 transition-colors">
|
||||
Iniciar Sesión
|
||||
</Link>
|
||||
<Link href="/register" className="text-sm font-medium px-4 py-2 rounded-full bg-orange-600 text-white hover:bg-orange-700 transition-colors">
|
||||
Registrarse
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative h-[500px] w-full overflow-hidden flex items-center">
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img src={heroImage} alt="Food hero" className="w-full h-full object-cover" />
|
||||
<div className="absolute inset-0 bg-black/40"></div>
|
||||
</div>
|
||||
<div className="container mx-auto relative z-10 px-4">
|
||||
<div className="max-w-2xl text-white">
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold mb-6 leading-tight">
|
||||
Sabor y Frescura <br />
|
||||
<span className="text-orange-400">Directo a tu Mesa</span>
|
||||
</h1>
|
||||
<p className="text-xl mb-8 text-gray-100">
|
||||
Descubre la mejor selección de alimentos, bebidas y productos locales en El Calentico. Calidad garantizada en cada pedido.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<BaseButton
|
||||
label="Comprar Ahora"
|
||||
color="warning"
|
||||
roundedFull
|
||||
className="px-8 py-3 text-lg font-bold"
|
||||
href="#products"
|
||||
/>
|
||||
<BaseButton
|
||||
label="Ver Categorías"
|
||||
color="white"
|
||||
outline
|
||||
roundedFull
|
||||
className="px-8 py-3 text-lg font-bold"
|
||||
href="#categories"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories Section */}
|
||||
<section id="categories" className="py-16 bg-white dark:bg-dark-800">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex justify-between items-end mb-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-2">Categorías</h2>
|
||||
<p className="text-gray-500">Explora nuestra variedad de productos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categoriesLoading ? (
|
||||
<div className="flex justify-center py-10"><LoadingSpinner /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{product_categories?.map((cat: any) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/search?category=${cat.id}`}
|
||||
className={`group flex flex-col items-center p-6 rounded-2xl transition-all hover:shadow-lg ${darkMode ? 'bg-dark-700 border-dark-600 hover:bg-dark-600' : 'bg-gray-50 border-gray-100 hover:bg-orange-50 hover:border-orange-200'} border`}
|
||||
>
|
||||
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform">
|
||||
<span className="text-2xl font-bold text-orange-600">{cat.name.charAt(0)}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-center group-hover:text-orange-600">{cat.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Products */}
|
||||
<section id="products" className="py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex justify-between items-end mb-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold mb-2">Productos Destacados</h2>
|
||||
<p className="text-gray-500">Lo más buscado de esta semana</p>
|
||||
</div>
|
||||
<Link href="/search" className="text-orange-600 font-bold flex items-center gap-1 hover:underline">
|
||||
Ver todos <BaseIcon path={mdiArrowRight} size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{productsLoading ? (
|
||||
<div className="flex justify-center py-10"><LoadingSpinner /></div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{products?.map((product: any) => (
|
||||
<div key={product.id} className={`group flex flex-col overflow-hidden rounded-2xl shadow-sm border transition-all hover:shadow-xl ${darkMode ? 'bg-dark-800 border-dark-700' : 'bg-white border-gray-200'}`}>
|
||||
<Link href={`/products/products-view/?id=${product.id}`} className="relative h-64 overflow-hidden">
|
||||
<ImageField
|
||||
image={product.images}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
{product.price < product.compare_at_price && (
|
||||
<div className="absolute top-4 left-4 bg-red-600 text-white text-xs font-bold px-3 py-1 rounded-full">
|
||||
OFERTA
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
<div className="p-6 flex flex-col flex-grow">
|
||||
<div className="text-xs text-gray-500 mb-2 uppercase tracking-wider font-semibold">
|
||||
{product.category?.name || 'Producto'}
|
||||
</div>
|
||||
<Link href={`/products/products-view/?id=${product.id}`}>
|
||||
<h3 className="text-lg font-bold mb-2 group-hover:text-orange-600 transition-colors line-clamp-2">{product.name}</h3>
|
||||
</Link>
|
||||
<div className="mt-auto flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
{product.compare_at_price > 0 && (
|
||||
<span className="text-sm text-gray-400 line-through">${product.compare_at_price}</span>
|
||||
)}
|
||||
<span className="text-2xl font-extrabold text-orange-600">${product.price}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAddToCart(product)}
|
||||
className="p-3 bg-gray-100 hover:bg-orange-600 hover:text-white rounded-full transition-all duration-300 group/cart"
|
||||
title="Añadir al carrito"
|
||||
>
|
||||
<BaseIcon path={mdiCartOutline} size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-orange-600 text-white">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-4xl font-bold mb-6">¿Listo para hacer tu primer pedido?</h2>
|
||||
<p className="text-xl mb-10 max-w-2xl mx-auto opacity-90">
|
||||
Únete a miles de clientes satisfechos que ya disfrutan de la frescura de El Calentico en sus hogares.
|
||||
</p>
|
||||
<BaseButton
|
||||
label="Crear una Cuenta Gratis"
|
||||
color="white"
|
||||
roundedFull
|
||||
className="px-10 py-4 text-lg font-bold text-orange-600"
|
||||
href="/register"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className={`py-12 ${darkMode ? 'bg-dark-800 text-gray-400' : 'bg-gray-100 text-gray-600'}`}>
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<h3 className="text-2xl font-bold text-orange-600 mb-6">El Calentico</h3>
|
||||
<p className="mb-6 max-w-md">
|
||||
Tu tienda virtual de confianza para alimentos, bebidas y productos del día a día. Llevamos la mejor calidad directamente a tu puerta.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-bold mb-6 text-gray-900 dark:text-white">Enlaces Rápidos</h4>
|
||||
<ul className="space-y-4">
|
||||
<li><Link href="/search" className="hover:text-orange-600 transition-colors">Productos</Link></li>
|
||||
<li><Link href="#categories" className="hover:text-orange-600 transition-colors">Categorías</Link></li>
|
||||
<li><Link href="/login" className="hover:text-orange-600 transition-colors">Mi Cuenta</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-bold mb-6 text-gray-900 dark:text-white">Legal</h4>
|
||||
<ul className="space-y-4">
|
||||
<li><Link href="/privacy-policy" className="hover:text-orange-600 transition-colors">Privacidad</Link></li>
|
||||
<li><Link href="/terms-of-use" className="hover:text-orange-600 transition-colors">Términos</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-dark-700 pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-sm">© 2026 El Calentico. Todos los derechos reservados.</p>
|
||||
<div className="flex gap-6">
|
||||
{/* Social icons could go here */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</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>
|
||||
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
Home.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
63
frontend/src/stores/localCartSlice.ts
Normal file
63
frontend/src/stores/localCartSlice.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
interface LocalCartState {
|
||||
items: CartItem[];
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const initialState: LocalCartState = {
|
||||
items: typeof window !== 'undefined' ? JSON.parse(localStorage.getItem('cart') || '[]') : [],
|
||||
isOpen: false,
|
||||
};
|
||||
|
||||
export const localCartSlice = createSlice({
|
||||
name: 'localCart',
|
||||
initialState,
|
||||
reducers: {
|
||||
addToCart: (state, action: PayloadAction<CartItem>) => {
|
||||
const existingItem = state.items.find((item) => item.id === action.payload.id);
|
||||
if (existingItem) {
|
||||
existingItem.quantity += action.payload.quantity;
|
||||
} else {
|
||||
state.items.push(action.payload);
|
||||
}
|
||||
localStorage.setItem('cart', JSON.stringify(state.items));
|
||||
},
|
||||
removeFromCart: (state, action: PayloadAction<string>) => {
|
||||
state.items = state.items.filter((item) => item.id !== action.payload);
|
||||
localStorage.setItem('cart', JSON.stringify(state.items));
|
||||
},
|
||||
updateQuantity: (state, action: PayloadAction<{ id: string; quantity: number }>) => {
|
||||
const item = state.items.find((item) => item.id === action.id);
|
||||
if (item) {
|
||||
item.quantity = Math.max(0, action.payload.quantity);
|
||||
if (item.quantity === 0) {
|
||||
state.items = state.items.filter((i) => i.id !== action.payload.id);
|
||||
}
|
||||
}
|
||||
localStorage.setItem('cart', JSON.stringify(state.items));
|
||||
},
|
||||
clearCart: (state) => {
|
||||
state.items = [];
|
||||
localStorage.removeItem('cart');
|
||||
},
|
||||
toggleCart: (state) => {
|
||||
state.isOpen = !state.isOpen;
|
||||
},
|
||||
setCartOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.isOpen = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { addToCart, removeFromCart, updateQuantity, clearCart, toggleCart, setCartOpen } = localCartSlice.actions;
|
||||
|
||||
export default localCartSlice.reducer;
|
||||
@ -3,6 +3,7 @@ import styleReducer from './styleSlice';
|
||||
import mainReducer from './mainSlice';
|
||||
import authSlice from './authSlice';
|
||||
import openAiSlice from './openAiSlice';
|
||||
import localCartReducer from './localCartSlice';
|
||||
|
||||
import usersSlice from "./users/usersSlice";
|
||||
import rolesSlice from "./roles/rolesSlice";
|
||||
@ -28,6 +29,7 @@ export const store = configureStore({
|
||||
main: mainReducer,
|
||||
auth: authSlice,
|
||||
openAi: openAiSlice,
|
||||
localCart: localCartReducer,
|
||||
|
||||
users: usersSlice,
|
||||
roles: rolesSlice,
|
||||
@ -52,4 +54,4 @@ product_reviews: product_reviewsSlice,
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
Loading…
x
Reference in New Issue
Block a user