diff --git a/backend/src/db/migrations/1772306633138.js b/backend/src/db/migrations/1772306633138.js new file mode 100644 index 0000000..aa29599 --- /dev/null +++ b/backend/src/db/migrations/1772306633138.js @@ -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 + } +}; diff --git a/backend/src/index.js b/backend/src/index.js index 16088f2..6166e26 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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; \ No newline at end of file diff --git a/frontend/src/components/CartDrawer.tsx b/frontend/src/components/CartDrawer.tsx new file mode 100644 index 0000000..57b1a43 --- /dev/null +++ b/frontend/src/components/CartDrawer.tsx @@ -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 ( +
+ {/* Backdrop */} +
dispatch(toggleCart())} + >
+ + {/* Drawer */} +
+
+

Tu Carrito

+ +
+ +
+ {items.length === 0 ? ( +
+

Tu carrito está vacío

+ dispatch(toggleCart())} + /> +
+ ) : ( + items.map((item) => ( +
+ {item.image && ( + {item.name} + )} +
+

{item.name}

+

${item.price.toFixed(2)}

+
+ + {item.quantity} + +
+
+ +
+ )) + )} +
+ + {items.length > 0 && ( +
+
+ Total: + ${totalPrice.toFixed(2)} +
+ { + alert('¡Próximamente! Estamos preparando el proceso de pago.'); + }} + /> +

+ Envío gratuito en pedidos superiores a $50 +

+
+ )} +
+ +
+ ); +}; + +export default CartDrawer; diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index df48bca..b6b4fa3 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } + 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 ( -
- - {getPageTitle('Starter Page')} - + const heroImage = "https://images.pexels.com/photos/958545/pexels-photo-958545.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1"; - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - + return ( +
+ + {getPageTitle('El Calentico - Tu Tienda Virtual')} + - - + + + {/* Navigation / Header */} + + + {/* Hero Section */} +
+
+ Food hero +
+
+
+
+

+ Sabor y Frescura
+ Directo a tu Mesa +

+

+ Descubre la mejor selección de alimentos, bebidas y productos locales en El Calentico. Calidad garantizada en cada pedido. +

+
+ + +
+
+
+
+ + {/* Categories Section */} +
+
+
+
+

Categorías

+

Explora nuestra variedad de productos

+
+
+ + {categoriesLoading ? ( +
+ ) : ( +
+ {product_categories?.map((cat: any) => ( + +
+ {cat.name.charAt(0)} +
+ {cat.name} + + ))} +
+ )} +
+
+ + {/* Featured Products */} +
+
+
+
+

Productos Destacados

+

Lo más buscado de esta semana

+
+ + Ver todos + +
+ + {productsLoading ? ( +
+ ) : ( +
+ {products?.map((product: any) => ( +
+ + + {product.price < product.compare_at_price && ( +
+ OFERTA +
+ )} + +
+
+ {product.category?.name || 'Producto'} +
+ +

{product.name}

+ +
+
+ {product.compare_at_price > 0 && ( + ${product.compare_at_price} + )} + ${product.price} +
+ +
+
+
+ ))} +
+ )} +
+
+ + {/* CTA Section */} +
+
+

¿Listo para hacer tu primer pedido?

+

+ Únete a miles de clientes satisfechos que ya disfrutan de la frescura de El Calentico en sus hogares. +

+ +
+
+ + {/* Footer */} +
+
+
+
+

El Calentico

+

+ Tu tienda virtual de confianza para alimentos, bebidas y productos del día a día. Llevamos la mejor calidad directamente a tu puerta. +

+
+
+

Enlaces Rápidos

+
    +
  • Productos
  • +
  • Categorías
  • +
  • Mi Cuenta
  • +
+
+
+

Legal

+
    +
  • Privacidad
  • +
  • Términos
  • +
+
+
+
+

© 2026 El Calentico. Todos los derechos reservados.

+
+ {/* Social icons could go here */} +
+
+
+
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
- -
- ); + ); } -Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - +Home.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; \ No newline at end of file diff --git a/frontend/src/stores/localCartSlice.ts b/frontend/src/stores/localCartSlice.ts new file mode 100644 index 0000000..0ac7d59 --- /dev/null +++ b/frontend/src/stores/localCartSlice.ts @@ -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) => { + 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) => { + 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) => { + state.isOpen = action.payload; + }, + }, +}); + +export const { addToCart, removeFromCart, updateQuantity, clearCart, toggleCart, setCartOpen } = localCartSlice.actions; + +export default localCartSlice.reducer; diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index 70d1525..349d806 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -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 // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} -export type AppDispatch = typeof store.dispatch +export type AppDispatch = typeof store.dispatch \ No newline at end of file