├── src ├── vite-env.d.ts ├── assets │ ├── product-white-cap.jpg │ ├── product-black-jacket.jpg │ ├── product-black-tshirt.jpg │ ├── product-white-hoodie.jpg │ ├── product-black-backpack.jpg │ ├── product-grey-sweatshirt.jpg │ └── react.svg ├── lib │ ├── sleep.ts │ ├── utils.ts │ └── currency-formatter.ts ├── shop │ ├── pages │ │ ├── product │ │ │ └── ProductPage.tsx │ │ ├── home │ │ │ └── HomePage.tsx │ │ └── gender │ │ │ └── GenderPage.tsx │ ├── layouts │ │ └── ShopLayout.tsx │ ├── components │ │ ├── CustomJumbotron.tsx │ │ ├── ProductCard.tsx │ │ ├── CustomFooter.tsx │ │ ├── ProductsGrid.tsx │ │ ├── FilterSidebar.tsx │ │ └── CustomHeader.tsx │ ├── actions │ │ └── get-products.action.ts │ └── hooks │ │ └── useProducts.tsx ├── interfaces │ ├── user.interface.ts │ ├── products.response.ts │ └── product.interface.ts ├── auth │ ├── interfaces │ │ └── auth.response.ts │ ├── layouts │ │ └── AuthLayout.tsx │ ├── actions │ │ ├── login.action.ts │ │ └── check-auth.action.ts │ ├── store │ │ └── auth.store.ts │ └── pages │ │ ├── register │ │ └── RegisterPage.tsx │ │ └── login │ │ └── LoginPage.tsx ├── main.tsx ├── admin │ ├── components │ │ ├── AdminTitle.tsx │ │ ├── Chart.tsx │ │ ├── StatCard.tsx │ │ ├── QuickActions.tsx │ │ ├── ActivityFeed.tsx │ │ ├── AdminHeader.tsx │ │ └── AdminSidebar.tsx │ ├── layouts │ │ └── AdminLayout.tsx │ ├── actions │ │ ├── get-product-by-id.action.ts │ │ └── create-update-product.action.ts │ ├── hooks │ │ └── useProduct.tsx │ └── pages │ │ ├── product │ │ ├── AdminProductPage.tsx │ │ └── ui │ │ │ └── ProductForm.tsx │ │ ├── products │ │ └── AdminProductsPage.tsx │ │ └── dashboard │ │ └── DashboardPage.tsx ├── api │ └── tesloApi.ts ├── components │ ├── custom │ │ ├── CustomFullScreenLoading.tsx │ │ ├── CustomLogo.tsx │ │ └── CustomPagination.tsx │ ├── ui │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── input.tsx │ │ ├── checkbox.tsx │ │ ├── radio-group.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── table.tsx │ └── routes │ │ └── ProtectedRoutes.tsx ├── TesloShopApp.tsx ├── app.router.tsx ├── mocks │ └── products.mock.ts └── index.css ├── .env.template ├── tsconfig.json ├── README.md ├── .gitignore ├── vite.config.ts ├── components.json ├── eslint.config.js ├── index.html ├── tsconfig.node.json ├── tsconfig.app.json ├── public ├── vite.svg └── placeholder.svg └── package.json /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | VITE_API_URL=http://localhost:3000/api -------------------------------------------------------------------------------- /src/assets/product-white-cap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klerith/teslo-shop-react/HEAD/src/assets/product-white-cap.jpg -------------------------------------------------------------------------------- /src/assets/product-black-jacket.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klerith/teslo-shop-react/HEAD/src/assets/product-black-jacket.jpg -------------------------------------------------------------------------------- /src/assets/product-black-tshirt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klerith/teslo-shop-react/HEAD/src/assets/product-black-tshirt.jpg -------------------------------------------------------------------------------- /src/assets/product-white-hoodie.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klerith/teslo-shop-react/HEAD/src/assets/product-white-hoodie.jpg -------------------------------------------------------------------------------- /src/assets/product-black-backpack.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klerith/teslo-shop-react/HEAD/src/assets/product-black-backpack.jpg -------------------------------------------------------------------------------- /src/assets/product-grey-sweatshirt.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Klerith/teslo-shop-react/HEAD/src/assets/product-grey-sweatshirt.jpg -------------------------------------------------------------------------------- /src/lib/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number = 1000) => { 2 | return new Promise((resolve) => setTimeout(resolve, ms)); 3 | }; 4 | -------------------------------------------------------------------------------- /src/shop/pages/product/ProductPage.tsx: -------------------------------------------------------------------------------- 1 | export const ProductPage = () => { 2 | return ( 3 | <> 4 |

Product Page

5 | 6 | ); 7 | }; 8 | -------------------------------------------------------------------------------- /src/interfaces/user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | email: string; 4 | fullName: string; 5 | isActive: boolean; 6 | roles: string[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/products.response.ts: -------------------------------------------------------------------------------- 1 | import type { Product } from './product.interface'; 2 | 3 | export interface ProductsResponse { 4 | count: number; 5 | pages: number; 6 | products: Product[]; 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/interfaces/auth.response.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@/interfaces/user.interface'; 2 | 3 | // Login, Register, CheckStatus 4 | export interface AuthResponse { 5 | user: User; 6 | token: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/currency-formatter.ts: -------------------------------------------------------------------------------- 1 | export const currencyFormatter = (value: number) => { 2 | return value.toLocaleString('es-ES', { 3 | style: 'currency', 4 | currency: 'EUR', 5 | minimumFractionDigits: 2, 6 | }); 7 | }; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["./src/*"] 6 | } 7 | }, 8 | "files": [], 9 | "references": [ 10 | { "path": "./tsconfig.app.json" }, 11 | { "path": "./tsconfig.node.json" } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { TesloShopApp } from './TesloShopApp'; 4 | 5 | import './index.css'; 6 | 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | 11 | ); 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TesloShop 2 | 3 | 1. Clonar el repositorio 4 | 2. Clonar el archivo .env.template y renombrarlo a .env 5 | 3. Ejecutar el comando `npm install` para instalar las dependencias 6 | 4. Verificar que el backend este corriendo en el puerto 3000 7 | 5. Ejecutar el comando `npm run dev` para iniciar el servidor de desarrollo 8 | -------------------------------------------------------------------------------- /src/admin/components/AdminTitle.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | title: string; 3 | subtitle: string; 4 | } 5 | 6 | export const AdminTitle = ({ title, subtitle }: Props) => { 7 | return ( 8 |
9 |

{title}

10 |

{subtitle}

11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/auth/layouts/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router'; 2 | 3 | const AuthLayout = () => { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | }; 12 | 13 | export default AuthLayout; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | 16 | .env 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | .DS_Store 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | -------------------------------------------------------------------------------- /src/api/tesloApi.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const tesloApi = axios.create({ 4 | baseURL: import.meta.env.VITE_API_URL, 5 | }); 6 | 7 | tesloApi.interceptors.request.use((config) => { 8 | const token = localStorage.getItem('token'); 9 | if (token) { 10 | config.headers.Authorization = `Bearer ${token}`; 11 | } 12 | 13 | return config; 14 | }); 15 | 16 | export { tesloApi }; 17 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import { defineConfig } from 'vite'; 4 | import react from '@vitejs/plugin-react-swc'; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | '@': path.resolve(__dirname, './src'), 12 | }, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/shop/layouts/ShopLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router'; 2 | import { CustomHeader } from '../components/CustomHeader'; 3 | import { CustomFooter } from '../components/CustomFooter'; 4 | 5 | export const ShopLayout = () => { 6 | return ( 7 |
8 | 9 | 10 | 11 | 12 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/custom/CustomFullScreenLoading.tsx: -------------------------------------------------------------------------------- 1 | export const CustomFullScreenLoading = () => { 2 | return ( 3 |
4 |
5 |
6 |

Espere un momento...

7 |
8 |
9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /src/interfaces/product.interface.ts: -------------------------------------------------------------------------------- 1 | import type { User } from './user.interface'; 2 | 3 | export interface Product { 4 | id: string; 5 | title: string; 6 | price: number; 7 | description: string; 8 | slug: string; 9 | stock: number; 10 | sizes: Size[]; 11 | gender: Gender; 12 | tags: string[]; 13 | images: string[]; 14 | user: User; 15 | } 16 | 17 | export type Size = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL'; 18 | 19 | export type Gender = 'kid' | 'men' | 'women' | 'unisex'; 20 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /src/auth/actions/login.action.ts: -------------------------------------------------------------------------------- 1 | import { tesloApi } from '@/api/tesloApi'; 2 | import type { AuthResponse } from '../interfaces/auth.response'; 3 | 4 | export const loginAction = async ( 5 | email: string, 6 | password: string 7 | ): Promise => { 8 | try { 9 | const { data } = await tesloApi.post('/auth/login', { 10 | email, 11 | password, 12 | }); 13 | 14 | // console.log(data); 15 | 16 | return data; 17 | } catch (error) { 18 | console.log(error); 19 | throw error; 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/custom/CustomLogo.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router'; 2 | 3 | interface Props { 4 | subtitle?: string; 5 | } 6 | 7 | export const CustomLogo = ({ subtitle = 'Shop' }: Props) => { 8 | return ( 9 | 10 | 11 | Teslo | 12 | 13 |

14 | {subtitle} 15 |

16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /src/shop/pages/home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import { CustomPagination } from '@/components/custom/CustomPagination'; 2 | 3 | import { CustomJumbotron } from '@/shop/components/CustomJumbotron'; 4 | import { ProductsGrid } from '@/shop/components/ProductsGrid'; 5 | import { useProducts } from '@/shop/hooks/useProducts'; 6 | 7 | export const HomePage = () => { 8 | const { data } = useProducts(); 9 | 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/auth/actions/check-auth.action.ts: -------------------------------------------------------------------------------- 1 | import { tesloApi } from '@/api/tesloApi'; 2 | import type { AuthResponse } from '../interfaces/auth.response'; 3 | 4 | export const checkAuthAction = async (): Promise => { 5 | const token = localStorage.getItem('token'); 6 | if (!token) throw new Error('No token found'); 7 | 8 | try { 9 | const { data } = await tesloApi.get('/auth/check-status'); 10 | 11 | localStorage.setItem('token', data.token); 12 | 13 | return data; 14 | } catch (error) { 15 | console.log(error); 16 | localStorage.removeItem('token'); 17 | throw new Error('Token expired or not valid'); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | import { globalIgnores } from 'eslint/config' 7 | 8 | export default tseslint.config([ 9 | globalIgnores(['dist']), 10 | { 11 | files: ['**/*.{ts,tsx}'], 12 | extends: [ 13 | js.configs.recommended, 14 | tseslint.configs.recommended, 15 | reactHooks.configs['recommended-latest'], 16 | reactRefresh.configs.vite, 17 | ], 18 | languageOptions: { 19 | ecmaVersion: 2020, 20 | globals: globals.browser, 21 | }, 22 | }, 23 | ]) 24 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Teslo | Shop 8 | 9 | 10 | 11 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2023", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "verbatimModuleSyntax": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "erasableSyntaxOnly": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "noUncheckedSideEffectImports": true 23 | }, 24 | "include": ["vite.config.ts"] 25 | } 26 | -------------------------------------------------------------------------------- /src/shop/components/CustomJumbotron.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | title: string; 3 | subTitle?: string; 4 | } 5 | 6 | export const CustomJumbotron = ({ title, subTitle }: Props) => { 7 | const defaultSubTitle = 8 | 'Ropa minimalista y elegante inspirada en el diseño futurista de Tesla. Calidad premium para un estilo atemporal.'; 9 | 10 | return ( 11 |
12 |
13 |

14 | {title} 15 |

16 |

17 | {subTitle || defaultSubTitle} 18 |

19 |
20 |
21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function Separator({ 7 | className, 8 | orientation = "horizontal", 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ) 24 | } 25 | 26 | export { Separator } 27 | -------------------------------------------------------------------------------- /src/admin/layouts/AdminLayout.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Outlet } from 'react-router'; 3 | 4 | import { AdminSidebar } from '../components/AdminSidebar'; 5 | import { AdminHeader } from '../components/AdminHeader'; 6 | 7 | const AdminLayout = () => { 8 | const [sidebarCollapsed, setSidebarCollapsed] = useState(false); 9 | 10 | return ( 11 |
12 | setSidebarCollapsed(!sidebarCollapsed)} 15 | /> 16 | 17 |
18 | 19 | 20 |
21 | 22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default AdminLayout; 29 | -------------------------------------------------------------------------------- /src/shop/pages/gender/GenderPage.tsx: -------------------------------------------------------------------------------- 1 | import { CustomPagination } from '@/components/custom/CustomPagination'; 2 | import { CustomJumbotron } from '@/shop/components/CustomJumbotron'; 3 | import { ProductsGrid } from '@/shop/components/ProductsGrid'; 4 | import { useProducts } from '@/shop/hooks/useProducts'; 5 | import { useParams } from 'react-router'; 6 | 7 | export const GenderPage = () => { 8 | const { gender } = useParams(); 9 | const { data } = useProducts(); 10 | 11 | const genderLabel = 12 | gender === 'men' ? 'Hombres' : gender === 'women' ? 'Mujeres' : 'Niños'; 13 | 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": [ 6 | "./src/*" 7 | ] 8 | }, 9 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 10 | "target": "ES2022", 11 | "useDefineForClassFields": true, 12 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 13 | "module": "ESNext", 14 | "skipLibCheck": true, 15 | 16 | /* Bundler mode */ 17 | "moduleResolution": "bundler", 18 | "allowImportingTsExtensions": true, 19 | "verbatimModuleSyntax": true, 20 | "moduleDetection": "force", 21 | "noEmit": true, 22 | "jsx": "react-jsx", 23 | 24 | /* Linting */ 25 | "strict": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | "erasableSyntaxOnly": true, 29 | "noFallthroughCasesInSwitch": true, 30 | "noUncheckedSideEffectImports": true 31 | }, 32 | "include": ["src"] 33 | } 34 | -------------------------------------------------------------------------------- /src/admin/actions/get-product-by-id.action.ts: -------------------------------------------------------------------------------- 1 | import { tesloApi } from '@/api/tesloApi'; 2 | import type { Product } from '@/interfaces/product.interface'; 3 | 4 | export const getProductByIdAction = async (id: string): Promise => { 5 | if (!id) throw new Error('Id is required'); 6 | 7 | if (id === 'new') { 8 | return { 9 | id: 'new', 10 | title: '', 11 | price: 0, 12 | description: '', 13 | slug: '', 14 | stock: 0, 15 | sizes: [], 16 | gender: 'men', 17 | tags: [], 18 | images: [], 19 | } as unknown as Product; 20 | } 21 | 22 | const { data } = await tesloApi.get(`/products/${id}`); 23 | 24 | const images = data.images.map((image) => { 25 | if (image.includes('http')) return image; 26 | return `${import.meta.env.VITE_API_URL}/files/product/${image}`; 27 | }); 28 | 29 | return { 30 | ...data, 31 | images, 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ) 19 | } 20 | 21 | export { Input } 22 | -------------------------------------------------------------------------------- /src/components/routes/ProtectedRoutes.tsx: -------------------------------------------------------------------------------- 1 | import { useAuthStore } from '@/auth/store/auth.store'; 2 | import type { PropsWithChildren } from 'react'; 3 | import { Navigate } from 'react-router'; 4 | 5 | export const AuthenticatedRoute = ({ children }: PropsWithChildren) => { 6 | const { authStatus } = useAuthStore(); 7 | if (authStatus === 'checking') return null; 8 | 9 | if (authStatus === 'not-authenticated') return ; 10 | 11 | return children; 12 | }; 13 | 14 | export const NotAuthenticatedRoute = ({ children }: PropsWithChildren) => { 15 | const { authStatus } = useAuthStore(); 16 | if (authStatus === 'checking') return null; 17 | 18 | if (authStatus === 'authenticated') return ; 19 | 20 | return children; 21 | }; 22 | 23 | export const AdminRoute = ({ children }: PropsWithChildren) => { 24 | const { authStatus, isAdmin } = useAuthStore(); 25 | 26 | if (authStatus === 'checking') return null; 27 | 28 | if (authStatus === 'not-authenticated') return ; 29 | 30 | if (!isAdmin()) return ; 31 | 32 | return children; 33 | }; 34 | -------------------------------------------------------------------------------- /src/shop/actions/get-products.action.ts: -------------------------------------------------------------------------------- 1 | import { tesloApi } from '@/api/tesloApi'; 2 | import type { ProductsResponse } from '@/interfaces/products.response'; 3 | 4 | interface Options { 5 | limit?: number | string; 6 | offset?: number | string; 7 | sizes?: string; 8 | gender?: string; 9 | minPrice?: number; 10 | maxPrice?: number; 11 | query?: string; 12 | } 13 | 14 | export const getProductsAction = async ( 15 | options: Options 16 | ): Promise => { 17 | const { limit, offset, gender, sizes, minPrice, maxPrice, query } = options; 18 | 19 | const { data } = await tesloApi.get('/products', { 20 | params: { 21 | limit, 22 | offset, 23 | gender, 24 | sizes, 25 | minPrice, 26 | maxPrice, 27 | q: query, 28 | }, 29 | }); 30 | 31 | const productsWithImageUrls = data.products.map((product) => ({ 32 | ...product, 33 | images: product.images.map( 34 | (image) => `${import.meta.env.VITE_API_URL}/files/product/${image}` 35 | ), 36 | })); 37 | 38 | return { 39 | ...data, 40 | products: productsWithImageUrls, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/admin/components/Chart.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ChartProps { 4 | title: string; 5 | data: { label: string; value: number }[]; 6 | } 7 | 8 | const Chart: React.FC = ({ title, data }) => { 9 | const maxValue = Math.max(...data.map(d => d.value)); 10 | 11 | return ( 12 |
13 |

{title}

14 |
15 | {data.map((item, index) => ( 16 |
17 |
18 | {item.label} 19 |
20 |
21 |
25 |
26 |
27 | {item.value} 28 |
29 |
30 | ))} 31 |
32 |
33 | ); 34 | }; 35 | 36 | export default Chart; -------------------------------------------------------------------------------- /src/admin/hooks/useProduct.tsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 2 | import { getProductByIdAction } from '../actions/get-product-by-id.action'; 3 | import type { Product } from '@/interfaces/product.interface'; 4 | import { createUpdateProductAction } from '../actions/create-update-product.action'; 5 | 6 | export const useProduct = (id: string) => { 7 | const queryClient = useQueryClient(); 8 | 9 | const query = useQuery({ 10 | queryKey: ['product', { id }], 11 | queryFn: () => getProductByIdAction(id), 12 | retry: false, 13 | staleTime: 1000 * 60 * 5, // 5 minutos 14 | // enabled: !!id 15 | }); 16 | 17 | const mutation = useMutation({ 18 | mutationFn: createUpdateProductAction, 19 | onSuccess: (product: Product) => { 20 | // Invalidar caché 21 | queryClient.invalidateQueries({ queryKey: ['products'] }); 22 | queryClient.invalidateQueries({ 23 | queryKey: ['product', { id: product.id }], 24 | }); 25 | 26 | // Actualizar queryData 27 | queryClient.setQueryData(['products', { id: product.id }], product); 28 | }, 29 | }); 30 | 31 | // const handleSubmitForm = async (productLike: Partial) => { 32 | // console.log({ productLike }); 33 | // }; 34 | 35 | return { 36 | ...query, 37 | mutation, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { CheckIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function Checkbox({ 10 | className, 11 | ...props 12 | }: React.ComponentProps) { 13 | return ( 14 | 22 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export { Checkbox } 33 | -------------------------------------------------------------------------------- /src/TesloShopApp.tsx: -------------------------------------------------------------------------------- 1 | import { type PropsWithChildren } from 'react'; 2 | import { RouterProvider } from 'react-router'; 3 | import { appRouter } from './app.router'; 4 | 5 | import { 6 | QueryClient, 7 | QueryClientProvider, 8 | useQuery, 9 | } from '@tanstack/react-query'; 10 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; 11 | import { Toaster } from 'sonner'; 12 | 13 | import { CustomFullScreenLoading } from './components/custom/CustomFullScreenLoading'; 14 | import { useAuthStore } from './auth/store/auth.store'; 15 | 16 | const queryClient = new QueryClient(); 17 | 18 | const CheckAuthProvider = ({ children }: PropsWithChildren) => { 19 | const { checkAuthStatus } = useAuthStore(); 20 | 21 | const { isLoading } = useQuery({ 22 | queryKey: ['auth'], 23 | queryFn: checkAuthStatus, 24 | retry: false, 25 | refetchInterval: 1000 * 60 * 1.5, 26 | refetchOnWindowFocus: true, 27 | }); 28 | 29 | if (isLoading) return ; 30 | 31 | return children; 32 | }; 33 | 34 | export const TesloShopApp = () => { 35 | return ( 36 | 37 | 38 | 39 | {/* Custom Provider */} 40 | 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/admin/components/StatCard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DivideIcon as LucideIcon } from 'lucide-react'; 3 | 4 | interface StatCardProps { 5 | title: string; 6 | value: string; 7 | change: string; 8 | changeType: 'positive' | 'negative' | 'neutral'; 9 | icon: typeof LucideIcon; 10 | color: string; 11 | } 12 | 13 | const StatCard: React.FC = ({ 14 | title, 15 | value, 16 | change, 17 | changeType, 18 | icon: Icon, 19 | color, 20 | }) => { 21 | const changeColor = { 22 | positive: 'text-green-600 bg-green-50', 23 | negative: 'text-red-600 bg-red-50', 24 | neutral: 'text-gray-600 bg-gray-50', 25 | }[changeType]; 26 | 27 | return ( 28 |
29 |
30 |
31 |

{title}

32 |

{value}

33 |
36 | {change} 37 |
38 |
39 |
40 | 41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export default StatCard; 48 | -------------------------------------------------------------------------------- /src/admin/components/QuickActions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Plus, UserPlus, FileText, Settings, Download, Upload } from 'lucide-react'; 3 | 4 | const QuickActions: React.FC = () => { 5 | const actions = [ 6 | { icon: Plus, label: 'New Project', color: 'bg-blue-500 hover:bg-blue-600' }, 7 | { icon: UserPlus, label: 'Add User', color: 'bg-green-500 hover:bg-green-600' }, 8 | { icon: FileText, label: 'Generate Report', color: 'bg-purple-500 hover:bg-purple-600' }, 9 | { icon: Download, label: 'Export Data', color: 'bg-orange-500 hover:bg-orange-600' }, 10 | { icon: Upload, label: 'Import Data', color: 'bg-teal-500 hover:bg-teal-600' }, 11 | { icon: Settings, label: 'Settings', color: 'bg-gray-500 hover:bg-gray-600' }, 12 | ]; 13 | 14 | return ( 15 |
16 |

Quick Actions

17 |
18 | {actions.map((action, index) => { 19 | const Icon = action.icon; 20 | return ( 21 | 28 | ); 29 | })} 30 |
31 |
32 | ); 33 | }; 34 | 35 | export default QuickActions; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "teslo-shop", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-checkbox": "^1.3.2", 14 | "@radix-ui/react-label": "^2.1.7", 15 | "@radix-ui/react-radio-group": "^1.3.7", 16 | "@radix-ui/react-separator": "^1.1.7", 17 | "@radix-ui/react-slot": "^1.2.3", 18 | "@tailwindcss/vite": "^4.1.11", 19 | "@tanstack/react-query": "^5.83.0", 20 | "@tanstack/react-query-devtools": "^5.83.0", 21 | "axios": "^1.11.0", 22 | "class-variance-authority": "^0.7.1", 23 | "clsx": "^2.1.1", 24 | "lucide-react": "^0.525.0", 25 | "react": "^19.1.0", 26 | "react-dom": "^19.1.0", 27 | "react-hook-form": "^7.61.1", 28 | "react-router": "^7.7.0", 29 | "sonner": "^2.0.6", 30 | "tailwind-merge": "^3.3.1", 31 | "tailwindcss": "^4.1.11", 32 | "zustand": "^5.0.6" 33 | }, 34 | "devDependencies": { 35 | "@eslint/js": "^9.30.1", 36 | "@types/node": "^24.0.15", 37 | "@types/react": "^19.1.8", 38 | "@types/react-dom": "^19.1.6", 39 | "@vitejs/plugin-react-swc": "^3.10.2", 40 | "eslint": "^9.30.1", 41 | "eslint-plugin-react-hooks": "^5.2.0", 42 | "eslint-plugin-react-refresh": "^0.4.20", 43 | "globals": "^16.3.0", 44 | "tw-animate-css": "^1.3.5", 45 | "typescript": "~5.8.3", 46 | "typescript-eslint": "^8.35.1", 47 | "vite": "^7.0.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/ui/radio-group.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" 3 | import { CircleIcon } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | function RadioGroup({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | ) 18 | } 19 | 20 | function RadioGroupItem({ 21 | className, 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 33 | 37 | 38 | 39 | 40 | ) 41 | } 42 | 43 | export { RadioGroup, RadioGroupItem } 44 | -------------------------------------------------------------------------------- /src/shop/hooks/useProducts.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { useParams, useSearchParams } from 'react-router'; 3 | 4 | import { getProductsAction } from '../actions/get-products.action'; 5 | 6 | export const useProducts = () => { 7 | const { gender } = useParams(); 8 | const [searchParams] = useSearchParams(); 9 | 10 | const query = searchParams.get('query') || undefined; 11 | const limit = searchParams.get('limit') || 9; 12 | const page = searchParams.get('page') || 1; 13 | const sizes = searchParams.get('sizes') || undefined; 14 | 15 | const offset = (Number(page) - 1) * Number(limit); 16 | 17 | const price = searchParams.get('price') || 'any'; 18 | let minPrice = undefined; 19 | let maxPrice = undefined; 20 | 21 | switch (price) { 22 | case 'any': 23 | // 24 | break; 25 | case '0-50': 26 | minPrice = 0; 27 | maxPrice = 50; 28 | break; 29 | 30 | case '50-100': 31 | minPrice = 50; 32 | maxPrice = 100; 33 | break; 34 | 35 | case '100-200': 36 | minPrice = 100; 37 | maxPrice = 200; 38 | break; 39 | 40 | case '200+': 41 | minPrice = 200; 42 | maxPrice = undefined; 43 | break; 44 | } 45 | 46 | return useQuery({ 47 | queryKey: [ 48 | 'products', 49 | { offset, limit, gender, sizes, minPrice, maxPrice, query }, 50 | ], 51 | queryFn: () => 52 | getProductsAction({ 53 | limit: isNaN(+limit) ? 9 : limit, 54 | offset: isNaN(offset) ? 0 : offset, 55 | gender, 56 | sizes, 57 | minPrice, 58 | maxPrice, 59 | query, 60 | }), 61 | staleTime: 1000 * 60 * 5, 62 | }); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/custom/CustomPagination.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'react-router'; 2 | import { ChevronLeft, ChevronRight } from 'lucide-react'; 3 | import { Button } from '../ui/button'; 4 | 5 | interface Props { 6 | totalPages: number; 7 | } 8 | 9 | export const CustomPagination = ({ totalPages }: Props) => { 10 | const [searchParams, setSearchParams] = useSearchParams(); 11 | 12 | const queryPage = searchParams.get('page') || '1'; 13 | const page = isNaN(+queryPage) ? 1 : +queryPage; 14 | 15 | const handlePageChange = (page: number) => { 16 | if (page < 1 || page > totalPages) return; 17 | 18 | searchParams.set('page', page.toString()); 19 | 20 | setSearchParams(searchParams); 21 | }; 22 | 23 | return ( 24 |
25 | 34 | 35 | {Array.from({ length: totalPages }).map((_, index) => ( 36 | 44 | ))} 45 | 46 | 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /src/admin/pages/product/AdminProductPage.tsx: -------------------------------------------------------------------------------- 1 | // https://github.com/Klerith/bolt-product-editor 2 | import { Navigate, useNavigate, useParams } from 'react-router'; 3 | import { toast } from 'sonner'; 4 | 5 | import { useProduct } from '@/admin/hooks/useProduct'; 6 | import { CustomFullScreenLoading } from '@/components/custom/CustomFullScreenLoading'; 7 | import { ProductForm } from './ui/ProductForm'; 8 | import type { Product } from '@/interfaces/product.interface'; 9 | 10 | export const AdminProductPage = () => { 11 | const { id } = useParams(); 12 | const navigate = useNavigate(); 13 | 14 | const { isLoading, isError, data: product, mutation } = useProduct(id || ''); 15 | 16 | const title = id === 'new' ? 'Nuevo producto' : 'Editar producto'; 17 | const subtitle = 18 | id === 'new' 19 | ? 'Aquí puedes crear un nuevo producto.' 20 | : 'Aquí puedes editar el producto.'; 21 | 22 | const handleSubmit = async ( 23 | productLike: Partial & { files?: File[] } 24 | ) => { 25 | await mutation.mutateAsync(productLike, { 26 | onSuccess: (data) => { 27 | toast.success('Producto actualizado correctamente', { 28 | position: 'top-right', 29 | }); 30 | navigate(`/admin/products/${data.id}`); 31 | }, 32 | onError: (error) => { 33 | console.log(error); 34 | toast.error('Error al actualizar el producto'); 35 | }, 36 | }); 37 | }; 38 | 39 | if (isError) { 40 | return ; 41 | } 42 | 43 | if (isLoading) { 44 | return ; 45 | } 46 | 47 | if (!product) { 48 | return ; 49 | } 50 | 51 | return ( 52 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/shop/components/ProductCard.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { Card, CardContent } from '@/components/ui/card'; 3 | import type { Size } from '@/interfaces/product.interface'; 4 | 5 | interface ProductCardProps { 6 | id: string; 7 | name: string; 8 | price: number; 9 | image: string; 10 | category: string; 11 | sizes: Size[]; 12 | } 13 | 14 | export const ProductCard = ({ 15 | name, 16 | price, 17 | image, 18 | category, 19 | sizes, 20 | }: ProductCardProps) => { 21 | return ( 22 | 23 | 24 |
25 | {name} 30 |
31 |
32 | 33 |
34 |
35 |

{name}

36 |

37 | {category} - {sizes.join(', ')} 38 |

39 |
40 | 41 |
42 |

${price}

43 | 50 |
51 |
52 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/admin/actions/create-update-product.action.ts: -------------------------------------------------------------------------------- 1 | import { tesloApi } from '@/api/tesloApi'; 2 | import type { Product } from '@/interfaces/product.interface'; 3 | import { sleep } from '@/lib/sleep'; 4 | 5 | export const createUpdateProductAction = async ( 6 | productLike: Partial & { files?: File[] } 7 | ): Promise => { 8 | await sleep(1500); 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 11 | const { id, user, images = [], files = [], ...rest } = productLike; 12 | 13 | const isCreating = id === 'new'; 14 | 15 | rest.stock = Number(rest.stock || 0); 16 | rest.price = Number(rest.price || 0); 17 | 18 | // Preparar las imágenes 19 | if (files.length > 0) { 20 | const newImageNames = await uploadFiles(files); 21 | images.push(...newImageNames); 22 | } 23 | 24 | const imagesToSave = images.map((image) => { 25 | if (image.includes('http')) return image.split('/').pop() || ''; 26 | return image; 27 | }); 28 | 29 | const { data } = await tesloApi({ 30 | url: isCreating ? '/products' : `/products/${id}`, 31 | method: isCreating ? 'POST' : 'PATCH', 32 | data: { 33 | ...rest, 34 | images: imagesToSave, 35 | }, 36 | }); 37 | 38 | return { 39 | ...data, 40 | images: data.images.map((image) => { 41 | if (image.includes('http')) return image; 42 | return `${import.meta.env.VITE_API_URL}/files/product/${image}`; 43 | }), 44 | }; 45 | }; 46 | 47 | export interface FileUploadResponse { 48 | secureUrl: string; 49 | fileName: string; 50 | } 51 | 52 | const uploadFiles = async (files: File[]) => { 53 | const uploadPromises = files.map(async (file) => { 54 | const formData = new FormData(); 55 | formData.append('file', file); 56 | 57 | const { data } = await tesloApi({ 58 | url: '/files/product', 59 | method: 'POST', 60 | data: formData, 61 | }); 62 | 63 | return data.fileName; 64 | }); 65 | 66 | const uploadedFileNames = await Promise.all(uploadPromises); 67 | return uploadedFileNames; 68 | }; 69 | -------------------------------------------------------------------------------- /src/admin/components/ActivityFeed.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { User, ShoppingCart, FileText, Bell } from 'lucide-react'; 3 | 4 | const ActivityFeed: React.FC = () => { 5 | const activities = [ 6 | { 7 | icon: User, 8 | title: 'New user registered', 9 | description: 'Sarah Johnson joined the platform', 10 | time: '2 minutes ago', 11 | color: 'bg-blue-500' 12 | }, 13 | { 14 | icon: ShoppingCart, 15 | title: 'New order received', 16 | description: 'Order #12847 worth $299.99', 17 | time: '5 minutes ago', 18 | color: 'bg-green-500' 19 | }, 20 | { 21 | icon: FileText, 22 | title: 'Report generated', 23 | description: 'Monthly sales report is ready', 24 | time: '15 minutes ago', 25 | color: 'bg-purple-500' 26 | }, 27 | { 28 | icon: Bell, 29 | title: 'System notification', 30 | description: 'Server maintenance scheduled', 31 | time: '1 hour ago', 32 | color: 'bg-orange-500' 33 | } 34 | ]; 35 | 36 | return ( 37 |
38 |

Recent Activity

39 |
40 | {activities.map((activity, index) => { 41 | const Icon = activity.icon; 42 | return ( 43 |
44 |
45 | 46 |
47 |
48 |

49 | {activity.title} 50 |

51 |

52 | {activity.description} 53 |

54 |

55 | {activity.time} 56 |

57 |
58 |
59 | ); 60 | })} 61 |
62 | 65 |
66 | ); 67 | }; 68 | 69 | export default ActivityFeed; -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | "cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 16 | outline: 17 | 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 20 | ghost: 21 | 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 22 | link: 'text-primary underline-offset-4 hover:underline', 23 | }, 24 | size: { 25 | default: 'h-9 px-4 py-2 has-[>svg]:px-3', 26 | sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', 27 | lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', 28 | icon: 'size-9', 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: 'default', 33 | size: 'default', 34 | }, 35 | } 36 | ); 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<'button'> & 45 | VariantProps & { 46 | asChild?: boolean; 47 | }) { 48 | const Comp = asChild ? Slot : 'button'; 49 | 50 | return ( 51 | 56 | ); 57 | } 58 | 59 | export { Button, buttonVariants }; 60 | -------------------------------------------------------------------------------- /src/auth/store/auth.store.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import type { User } from '@/interfaces/user.interface'; 3 | 4 | import { loginAction } from '../actions/login.action'; 5 | import { checkAuthAction } from '../actions/check-auth.action'; 6 | 7 | type AuthStatus = 'authenticated' | 'not-authenticated' | 'checking'; 8 | 9 | type AuthState = { 10 | // Properties 11 | user: User | null; 12 | token: string | null; 13 | authStatus: AuthStatus; 14 | 15 | // Getters 16 | isAdmin: () => boolean; 17 | 18 | // Actions 19 | login: (email: string, password: string) => Promise; 20 | logout: () => void; 21 | checkAuthStatus: () => Promise; 22 | }; 23 | 24 | export const useAuthStore = create()((set, get) => ({ 25 | // Implementación del Store 26 | user: null, 27 | token: null, 28 | authStatus: 'checking', 29 | 30 | // Getters 31 | isAdmin: () => { 32 | const roles = get().user?.roles || []; 33 | return roles.includes('admin'); 34 | // return !!get().user?.roles.includes('admin') 35 | }, 36 | 37 | // Actions 38 | login: async (email: string, password: string) => { 39 | console.log({ email, password }); 40 | 41 | try { 42 | const data = await loginAction(email, password); 43 | localStorage.setItem('token', data.token); 44 | 45 | set({ user: data.user, token: data.token, authStatus: 'authenticated' }); 46 | 47 | return true; 48 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 49 | } catch (error) { 50 | localStorage.removeItem('token'); 51 | set({ user: null, token: null, authStatus: 'not-authenticated' }); 52 | return false; 53 | } 54 | }, 55 | 56 | logout: () => { 57 | localStorage.removeItem('token'); 58 | set({ user: null, token: null, authStatus: 'not-authenticated' }); 59 | }, 60 | 61 | checkAuthStatus: async () => { 62 | try { 63 | const { user, token } = await checkAuthAction(); 64 | set({ 65 | user: user, 66 | token: token, 67 | authStatus: 'authenticated', 68 | }); 69 | return true; 70 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 71 | } catch (error) { 72 | set({ 73 | user: undefined, 74 | token: undefined, 75 | authStatus: 'not-authenticated', 76 | }); 77 | 78 | return false; 79 | } 80 | }, 81 | })); 82 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ) 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ) 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ) 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ) 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ) 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ) 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ) 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | } 93 | -------------------------------------------------------------------------------- /src/admin/components/AdminHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, type KeyboardEvent } from 'react'; 2 | import { useNavigate } from 'react-router'; 3 | import { Search, Bell, MessageSquare, Settings } from 'lucide-react'; 4 | 5 | export const AdminHeader: React.FC = () => { 6 | const inputRef = useRef(null); 7 | const navigate = useNavigate(); 8 | 9 | const handleSearch = (event: KeyboardEvent) => { 10 | if (event.key !== 'Enter') return; 11 | 12 | const query = inputRef.current?.value; 13 | 14 | if (!query) { 15 | navigate('/admin/products'); 16 | return; 17 | } 18 | 19 | navigate(`/admin/products?query=${query}`); 20 | }; 21 | 22 | return ( 23 |
24 |
25 | {/* Search */} 26 |
27 |
28 | 32 | 39 |
40 |
41 | 42 | {/* Actions */} 43 |
44 | 48 | 49 | 52 | 53 | 56 | 57 |
58 | JD 59 |
60 |
61 |
62 |
63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /src/app.router.tsx: -------------------------------------------------------------------------------- 1 | import { lazy } from 'react'; 2 | import { createHashRouter, Navigate } from 'react-router'; 3 | 4 | import { ShopLayout } from './shop/layouts/ShopLayout'; 5 | import { HomePage } from './shop/pages/home/HomePage'; 6 | import { ProductPage } from './shop/pages/product/ProductPage'; 7 | import { GenderPage } from './shop/pages/gender/GenderPage'; 8 | 9 | import { LoginPage } from './auth/pages/login/LoginPage'; 10 | import { RegisterPage } from './auth/pages/register/RegisterPage'; 11 | 12 | import { DashboardPage } from './admin/pages/dashboard/DashboardPage'; 13 | import { AdminProductPage } from './admin/pages/product/AdminProductPage'; 14 | import { AdminProductsPage } from './admin/pages/products/AdminProductsPage'; 15 | 16 | import { 17 | AdminRoute, 18 | NotAuthenticatedRoute, 19 | } from './components/routes/ProtectedRoutes'; 20 | 21 | const AuthLayout = lazy(() => import('./auth/layouts/AuthLayout')); 22 | const AdminLayout = lazy(() => import('./admin/layouts/AdminLayout')); 23 | 24 | export const appRouter = createHashRouter([ 25 | // export const appRouter = createBrowserRouter([ 26 | // Main routes 27 | { 28 | path: '/', 29 | element: , 30 | children: [ 31 | { 32 | index: true, 33 | element: , 34 | }, 35 | { 36 | path: 'product/:idSlug', 37 | element: , 38 | }, 39 | { 40 | path: 'gender/:gender', 41 | element: , 42 | }, 43 | ], 44 | }, 45 | 46 | // Auth Routes 47 | { 48 | path: '/auth', 49 | element: ( 50 | 51 | 52 | 53 | ), 54 | children: [ 55 | { 56 | index: true, 57 | element: , 58 | }, 59 | { 60 | path: 'login', 61 | element: , 62 | }, 63 | { 64 | path: 'register', 65 | element: , 66 | }, 67 | ], 68 | }, 69 | // Admin Routes 70 | { 71 | path: '/admin', 72 | element: ( 73 | 74 | 75 | 76 | ), 77 | children: [ 78 | { 79 | index: true, 80 | element: , 81 | }, 82 | { 83 | path: 'products', 84 | element: , 85 | }, 86 | { 87 | path: 'products/:id', 88 | element: , 89 | }, 90 | ], 91 | }, 92 | { 93 | path: '*', 94 | element: , 95 | }, 96 | ]); 97 | -------------------------------------------------------------------------------- /src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Table({ className, ...props }: React.ComponentProps<"table">) { 6 | return ( 7 |
11 | 16 | 17 | ) 18 | } 19 | 20 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 21 | return ( 22 | 27 | ) 28 | } 29 | 30 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { 31 | return ( 32 | 37 | ) 38 | } 39 | 40 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { 41 | return ( 42 | tr]:last:border-b-0", 46 | className 47 | )} 48 | {...props} 49 | /> 50 | ) 51 | } 52 | 53 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) { 54 | return ( 55 | 63 | ) 64 | } 65 | 66 | function TableHead({ className, ...props }: React.ComponentProps<"th">) { 67 | return ( 68 |
[role=checkbox]]:translate-y-[2px]", 72 | className 73 | )} 74 | {...props} 75 | /> 76 | ) 77 | } 78 | 79 | function TableCell({ className, ...props }: React.ComponentProps<"td">) { 80 | return ( 81 | [role=checkbox]]:translate-y-[2px]", 85 | className 86 | )} 87 | {...props} 88 | /> 89 | ) 90 | } 91 | 92 | function TableCaption({ 93 | className, 94 | ...props 95 | }: React.ComponentProps<"caption">) { 96 | return ( 97 |
102 | ) 103 | } 104 | 105 | export { 106 | Table, 107 | TableHeader, 108 | TableBody, 109 | TableFooter, 110 | TableHead, 111 | TableRow, 112 | TableCell, 113 | TableCaption, 114 | } 115 | -------------------------------------------------------------------------------- /public/placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/admin/pages/products/AdminProductsPage.tsx: -------------------------------------------------------------------------------- 1 | import { AdminTitle } from '@/admin/components/AdminTitle'; 2 | import { CustomFullScreenLoading } from '@/components/custom/CustomFullScreenLoading'; 3 | import { CustomPagination } from '@/components/custom/CustomPagination'; 4 | import { Button } from '@/components/ui/button'; 5 | import { 6 | Table, 7 | TableHeader, 8 | TableRow, 9 | TableHead, 10 | TableBody, 11 | TableCell, 12 | } from '@/components/ui/table'; 13 | import { currencyFormatter } from '@/lib/currency-formatter'; 14 | import { useProducts } from '@/shop/hooks/useProducts'; 15 | import { PencilIcon, PlusIcon } from 'lucide-react'; 16 | import { Link } from 'react-router'; 17 | 18 | export const AdminProductsPage = () => { 19 | const { data, isLoading } = useProducts(); 20 | 21 | if (isLoading) { 22 | return ; 23 | } 24 | 25 | return ( 26 | <> 27 |
28 | 32 | 33 |
34 | 35 | 39 | 40 |
41 |
42 | 43 | 44 | 45 | 46 | Imagen 47 | Nombre 48 | Precio 49 | Categoría 50 | Inventario 51 | Tallas 52 | Acciones 53 | 54 | 55 | 56 | {data!.products.map((product) => ( 57 | 58 | 59 | {product.title} 64 | 65 | 66 | 70 | {product.title} 71 | 72 | 73 | {currencyFormatter(product.price)} 74 | {product.gender} 75 | {product.stock} stock 76 | {product.sizes.join(', ')} 77 | 78 | {/* Editar */} 79 | 80 | 81 | 82 | 83 | 84 | ))} 85 | 86 |
87 | 88 | 89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/mocks/products.mock.ts: -------------------------------------------------------------------------------- 1 | // Import all product images 2 | import blackTshirt from '@/assets/product-black-tshirt.jpg'; 3 | import whiteHoodie from '@/assets/product-white-hoodie.jpg'; 4 | import greySweatshirt from '@/assets/product-grey-sweatshirt.jpg'; 5 | import blackJacket from '@/assets/product-black-jacket.jpg'; 6 | import whiteCap from '@/assets/product-white-cap.jpg'; 7 | import blackBackpack from '@/assets/product-black-backpack.jpg'; 8 | 9 | export interface Product { 10 | id: string; 11 | name: string; 12 | price: number; 13 | image: string; 14 | category: string; 15 | description: string; 16 | sizes: string[]; 17 | colors: string[]; 18 | } 19 | 20 | export const products: Product[] = [ 21 | { 22 | id: '1', 23 | name: 'Camiseta Tesla Negro', 24 | price: 35, 25 | image: blackTshirt, 26 | category: 'Camisetas', 27 | description: 28 | 'Camiseta de algodón premium con diseño minimalista inspirado en Tesla.', 29 | sizes: ['S', 'M', 'L', 'XL', 'XXL'], 30 | colors: ['Negro', 'Blanco', 'Gris'], 31 | }, 32 | { 33 | id: '2', 34 | name: 'Sudadera Tesla Blanca', 35 | price: 85, 36 | image: whiteHoodie, 37 | category: 'Sudaderas', 38 | description: 'Sudadera con capucha de alta calidad con logo Tesla bordado.', 39 | sizes: ['S', 'M', 'L', 'XL', 'XXL'], 40 | colors: ['Blanco', 'Negro', 'Gris'], 41 | }, 42 | { 43 | id: '3', 44 | name: 'Sudadera Tesla Gris', 45 | price: 75, 46 | image: greySweatshirt, 47 | category: 'Sudaderas', 48 | description: 'Sudadera clásica sin capucha con corte moderno y cómodo.', 49 | sizes: ['S', 'M', 'L', 'XL'], 50 | colors: ['Gris', 'Negro', 'Azul Marino'], 51 | }, 52 | { 53 | id: '4', 54 | name: 'Chaqueta Tesla Negro', 55 | price: 150, 56 | image: blackJacket, 57 | category: 'Chaquetas', 58 | description: 'Chaqueta técnica resistente al agua con diseño elegante.', 59 | sizes: ['S', 'M', 'L', 'XL', 'XXL'], 60 | colors: ['Negro', 'Gris Oscuro'], 61 | }, 62 | { 63 | id: '5', 64 | name: 'Gorra Tesla Blanca', 65 | price: 25, 66 | image: whiteCap, 67 | category: 'Accesorios', 68 | description: 'Gorra ajustable con logo Tesla bordado en alta calidad.', 69 | sizes: ['Único'], 70 | colors: ['Blanco', 'Negro', 'Gris'], 71 | }, 72 | { 73 | id: '6', 74 | name: 'Mochila Tesla Negro', 75 | price: 120, 76 | image: blackBackpack, 77 | category: 'Accesorios', 78 | description: 79 | 'Mochila minimalista con compartimentos organizados y diseño ergonómico.', 80 | sizes: ['Único'], 81 | colors: ['Negro', 'Gris'], 82 | }, 83 | { 84 | id: '7', 85 | name: 'Camiseta Tesla Blanca', 86 | price: 35, 87 | image: blackTshirt, // Reutilizamos la imagen por simplicidad 88 | category: 'Camisetas', 89 | description: 'Camiseta blanca de algodón orgánico con logo Tesla discreto.', 90 | sizes: ['S', 'M', 'L', 'XL', 'XXL'], 91 | colors: ['Blanco', 'Negro', 'Gris'], 92 | }, 93 | { 94 | id: '8', 95 | name: 'Sudadera Tesla Negro', 96 | price: 85, 97 | image: whiteHoodie, 98 | category: 'Sudaderas', 99 | description: 'Sudadera negra con capucha y bolsillo frontal tipo canguro.', 100 | sizes: ['S', 'M', 'L', 'XL', 'XXL'], 101 | colors: ['Negro', 'Gris', 'Azul Marino'], 102 | }, 103 | ]; 104 | -------------------------------------------------------------------------------- /src/shop/components/CustomFooter.tsx: -------------------------------------------------------------------------------- 1 | import { CustomLogo } from '@/components/custom/CustomLogo'; 2 | 3 | export const CustomFooter = () => { 4 | return ( 5 | 103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/admin/components/AdminSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation } from 'react-router'; 2 | import { 3 | Home, 4 | Users, 5 | BarChart3, 6 | Settings, 7 | FileText, 8 | ShoppingCart, 9 | Bell, 10 | HelpCircle, 11 | ChevronLeft, 12 | ChevronRight, 13 | } from 'lucide-react'; 14 | import { CustomLogo } from '@/components/custom/CustomLogo'; 15 | import { useAuthStore } from '@/auth/store/auth.store'; 16 | 17 | interface SidebarProps { 18 | isCollapsed: boolean; 19 | onToggle: () => void; 20 | } 21 | 22 | export const AdminSidebar: React.FC = ({ 23 | isCollapsed, 24 | onToggle, 25 | }) => { 26 | const { pathname } = useLocation(); 27 | const { user } = useAuthStore(); 28 | 29 | const menuItems = [ 30 | { icon: Home, label: 'Dashboard', to: '/admin' }, 31 | { icon: BarChart3, label: 'Productos', to: '/admin/products' }, 32 | { icon: Users, label: 'Usuarios' }, 33 | { icon: ShoppingCart, label: 'Ordenes' }, 34 | { icon: FileText, label: 'Reportes' }, 35 | { icon: Bell, label: 'Notificaciones' }, 36 | { icon: Settings, label: 'Ajustes' }, 37 | { icon: HelpCircle, label: 'Ayuda' }, 38 | ]; 39 | 40 | const isActiveRoute = (to: string) => { 41 | // TODO: ajustarlo cuando estemos en la pantalla de producto 42 | if (pathname.includes('/admin/products/') && to === '/admin/products') { 43 | return true; 44 | } 45 | 46 | return pathname === to; // true, false 47 | }; 48 | 49 | return ( 50 |
55 | {/* Header */} 56 |
57 | {!isCollapsed && } 58 | 64 |
65 | 66 | {/* Navigation */} 67 | 91 | 92 | {/* User Profile */} 93 | {!isCollapsed && ( 94 |
95 |
96 |
97 | {user?.fullName.substring(0, 2)} 98 |
99 |
100 |

101 | {user?.fullName} 102 |

103 |

{user?.email}

104 |
105 |
106 |
107 | )} 108 |
109 | ); 110 | }; 111 | -------------------------------------------------------------------------------- /src/shop/components/ProductsGrid.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { Filter, Grid, List } from 'lucide-react'; 3 | import { ProductCard } from './ProductCard'; 4 | import { FilterSidebar } from './FilterSidebar'; 5 | import { useSearchParams } from 'react-router'; 6 | import { useState } from 'react'; 7 | import type { Product } from '@/interfaces/product.interface'; 8 | 9 | interface Props { 10 | products: Product[]; 11 | } 12 | 13 | export const ProductsGrid = ({ products }: Props) => { 14 | const [searchParams, setSearchParams] = useSearchParams(); 15 | 16 | const [showFilters, setShowFilters] = useState(false); 17 | const viewMode = searchParams.get('viewMode') || 'grid'; 18 | 19 | const handleViewModeChange = (mode: 'grid' | 'list') => { 20 | searchParams.set('viewMode', mode); 21 | setSearchParams(searchParams); 22 | }; 23 | 24 | return ( 25 |
26 |
27 |
28 |
29 |

Productos

30 | 31 | ({products.length} productos) 32 | 33 |
34 | 35 |
36 | 45 | 46 |
47 | 55 | 63 |
64 |
65 |
66 | 67 |
68 | {/* Filters Sidebar - Desktop */} 69 |
70 | 71 |
72 | 73 | {/* Mobile Filters */} 74 | {showFilters && ( 75 |
76 |
77 |

Filtros

78 | 85 |
86 | 87 |
88 | )} 89 | 90 | {/* Products Grid */} 91 |
92 |
99 | {products.map((product) => ( 100 | 109 | ))} 110 |
111 |
112 |
113 |
114 |
115 | ); 116 | }; 117 | -------------------------------------------------------------------------------- /src/shop/components/FilterSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from 'react-router'; 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import { Separator } from '@/components/ui/separator'; 5 | import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; 6 | import { Label } from '@/components/ui/label'; 7 | 8 | export const FilterSidebar = () => { 9 | const [searchParams, setSearchParams] = useSearchParams(); 10 | 11 | const currentSizes = searchParams.get('sizes')?.split(',') || []; // xs,l,xl 12 | const currentPrice = searchParams.get('price') || 'any'; 13 | 14 | const handleSizeChanged = (size: string) => { 15 | const newSizes = currentSizes.includes(size) 16 | ? currentSizes.filter((s) => s !== size) 17 | : [...currentSizes, size]; 18 | 19 | searchParams.set('page', '1'); 20 | searchParams.set('sizes', newSizes.join(',')); 21 | setSearchParams(searchParams); 22 | }; 23 | 24 | const handlePriceChange = (price: string) => { 25 | searchParams.set('page', '1'); 26 | searchParams.set('price', price); 27 | setSearchParams(searchParams); 28 | }; 29 | 30 | const sizes = [ 31 | { id: 'xs', label: 'XS' }, 32 | { id: 's', label: 'S' }, 33 | { id: 'm', label: 'M' }, 34 | { id: 'l', label: 'L' }, 35 | { id: 'xl', label: 'XL' }, 36 | { id: 'xxl', label: 'XXL' }, 37 | ]; 38 | 39 | return ( 40 |
41 |
42 |

Filtros

43 |
44 | 45 | {/* Sizes */} 46 |
47 |

Tallas

48 |
49 | {sizes.map((size) => ( 50 | 59 | ))} 60 |
61 |
62 | 63 | 64 | 65 | {/* Price Range */} 66 |
67 |

Precio

68 | 69 |
70 | handlePriceChange('any')} 75 | /> 76 | 79 |
80 |
81 | handlePriceChange('0-50')} 86 | /> 87 | 90 |
91 |
92 | handlePriceChange('50-100')} 97 | /> 98 | 101 |
102 |
103 | handlePriceChange('100-200')} 108 | /> 109 | 112 |
113 |
114 | handlePriceChange('200+')} 119 | /> 120 | 123 |
124 |
125 |
126 |
127 | ); 128 | }; 129 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | @import 'tw-animate-css'; 3 | 4 | @custom-variant dark (&:is(.dark *)); 5 | 6 | @theme inline { 7 | --radius-sm: calc(var(--radius) - 4px); 8 | --radius-md: calc(var(--radius) - 2px); 9 | --radius-lg: var(--radius); 10 | --radius-xl: calc(var(--radius) + 4px); 11 | --color-background: var(--background); 12 | --color-foreground: var(--foreground); 13 | --color-card: var(--card); 14 | --color-card-foreground: var(--card-foreground); 15 | --color-popover: var(--popover); 16 | --color-popover-foreground: var(--popover-foreground); 17 | --color-primary: var(--primary); 18 | --color-primary-foreground: var(--primary-foreground); 19 | --color-secondary: var(--secondary); 20 | --color-secondary-foreground: var(--secondary-foreground); 21 | --color-muted: var(--muted); 22 | --color-muted-foreground: var(--muted-foreground); 23 | --color-accent: var(--accent); 24 | --color-accent-foreground: var(--accent-foreground); 25 | --color-destructive: var(--destructive); 26 | --color-border: var(--border); 27 | --color-input: var(--input); 28 | --color-ring: var(--ring); 29 | --color-chart-1: var(--chart-1); 30 | --color-chart-2: var(--chart-2); 31 | --color-chart-3: var(--chart-3); 32 | --color-chart-4: var(--chart-4); 33 | --color-chart-5: var(--chart-5); 34 | --color-sidebar: var(--sidebar); 35 | --color-sidebar-foreground: var(--sidebar-foreground); 36 | --color-sidebar-primary: var(--sidebar-primary); 37 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 38 | --color-sidebar-accent: var(--sidebar-accent); 39 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 40 | --color-sidebar-border: var(--sidebar-border); 41 | --color-sidebar-ring: var(--sidebar-ring); 42 | } 43 | 44 | :root { 45 | --radius: 0.625rem; 46 | --background: oklch(1 0 0); 47 | --foreground: oklch(0.145 0 0); 48 | --card: oklch(1 0 0); 49 | --card-foreground: oklch(0.145 0 0); 50 | --popover: oklch(1 0 0); 51 | --popover-foreground: oklch(0.145 0 0); 52 | --primary: oklch(0.205 0 0); 53 | --primary-foreground: oklch(0.985 0 0); 54 | --secondary: oklch(0.97 0 0); 55 | --secondary-foreground: oklch(0.205 0 0); 56 | --muted: oklch(0.97 0 0); 57 | --muted-foreground: oklch(0.556 0 0); 58 | --accent: oklch(0.97 0 0); 59 | --accent-foreground: oklch(0.205 0 0); 60 | --destructive: oklch(0.577 0.245 27.325); 61 | --border: oklch(0.922 0 0); 62 | --input: oklch(0.922 0 0); 63 | --ring: oklch(0.708 0 0); 64 | --chart-1: oklch(0.646 0.222 41.116); 65 | --chart-2: oklch(0.6 0.118 184.704); 66 | --chart-3: oklch(0.398 0.07 227.392); 67 | --chart-4: oklch(0.828 0.189 84.429); 68 | --chart-5: oklch(0.769 0.188 70.08); 69 | --sidebar: oklch(0.985 0 0); 70 | --sidebar-foreground: oklch(0.145 0 0); 71 | --sidebar-primary: oklch(0.205 0 0); 72 | --sidebar-primary-foreground: oklch(0.985 0 0); 73 | --sidebar-accent: oklch(0.97 0 0); 74 | --sidebar-accent-foreground: oklch(0.205 0 0); 75 | --sidebar-border: oklch(0.922 0 0); 76 | --sidebar-ring: oklch(0.708 0 0); 77 | } 78 | 79 | .dark { 80 | --background: oklch(0.145 0 0); 81 | --foreground: oklch(0.985 0 0); 82 | --card: oklch(0.205 0 0); 83 | --card-foreground: oklch(0.985 0 0); 84 | --popover: oklch(0.205 0 0); 85 | --popover-foreground: oklch(0.985 0 0); 86 | --primary: oklch(0.922 0 0); 87 | --primary-foreground: oklch(0.205 0 0); 88 | --secondary: oklch(0.269 0 0); 89 | --secondary-foreground: oklch(0.985 0 0); 90 | --muted: oklch(0.269 0 0); 91 | --muted-foreground: oklch(0.708 0 0); 92 | --accent: oklch(0.269 0 0); 93 | --accent-foreground: oklch(0.985 0 0); 94 | --destructive: oklch(0.704 0.191 22.216); 95 | --border: oklch(1 0 0 / 10%); 96 | --input: oklch(1 0 0 / 15%); 97 | --ring: oklch(0.556 0 0); 98 | --chart-1: oklch(0.488 0.243 264.376); 99 | --chart-2: oklch(0.696 0.17 162.48); 100 | --chart-3: oklch(0.769 0.188 70.08); 101 | --chart-4: oklch(0.627 0.265 303.9); 102 | --chart-5: oklch(0.645 0.246 16.439); 103 | --sidebar: oklch(0.205 0 0); 104 | --sidebar-foreground: oklch(0.985 0 0); 105 | --sidebar-primary: oklch(0.488 0.243 264.376); 106 | --sidebar-primary-foreground: oklch(0.985 0 0); 107 | --sidebar-accent: oklch(0.269 0 0); 108 | --sidebar-accent-foreground: oklch(0.985 0 0); 109 | --sidebar-border: oklch(1 0 0 / 10%); 110 | --sidebar-ring: oklch(0.556 0 0); 111 | } 112 | 113 | @layer base { 114 | * { 115 | @apply border-border outline-ring/50; 116 | } 117 | body { 118 | @apply bg-background text-foreground; 119 | } 120 | } 121 | 122 | @theme { 123 | --font-montserrat: 'Montserrat Alternates', sans-serif; 124 | } 125 | -------------------------------------------------------------------------------- /src/shop/components/CustomHeader.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, type KeyboardEvent } from 'react'; 2 | import { Search } from 'lucide-react'; 3 | import { Button } from '@/components/ui/button'; 4 | import { Input } from '@/components/ui/input'; 5 | import { Link, useParams, useSearchParams } from 'react-router'; 6 | import { cn } from '@/lib/utils'; 7 | import { CustomLogo } from '@/components/custom/CustomLogo'; 8 | 9 | import { useAuthStore } from '@/auth/store/auth.store'; 10 | 11 | export const CustomHeader = () => { 12 | const [searchParams, setSearchParams] = useSearchParams(); 13 | const { authStatus, isAdmin, logout } = useAuthStore(); 14 | 15 | const { gender } = useParams(); 16 | 17 | const inputRef = useRef(null); 18 | const query = searchParams.get('query') || ''; 19 | 20 | const handleSearch = (event: KeyboardEvent) => { 21 | if (event.key !== 'Enter') return; 22 | const query = inputRef.current?.value; 23 | 24 | const newSearchParams = new URLSearchParams(); 25 | 26 | if (!query) { 27 | newSearchParams.delete('query'); 28 | } else { 29 | newSearchParams.set('query', inputRef.current!.value); 30 | } 31 | 32 | setSearchParams(newSearchParams); 33 | }; 34 | 35 | return ( 36 |
37 |
38 |
39 | {/* Logo */} 40 | 41 | 42 | {/* Navigation - Desktop */} 43 | 81 | 82 | {/* Search and Cart */} 83 |
84 |
85 |
86 | 87 | 94 |
95 |
96 | 97 | 100 | 101 | {authStatus === 'not-authenticated' ? ( 102 | 103 | 106 | 107 | ) : ( 108 | 116 | )} 117 | 118 | {isAdmin() && ( 119 | 120 | 128 | 129 | )} 130 |
131 |
132 |
133 |
134 | ); 135 | }; 136 | -------------------------------------------------------------------------------- /src/admin/pages/dashboard/DashboardPage.tsx: -------------------------------------------------------------------------------- 1 | import ActivityFeed from '@/admin/components/ActivityFeed'; 2 | import { AdminTitle } from '@/admin/components/AdminTitle'; 3 | import Chart from '@/admin/components/Chart'; 4 | import QuickActions from '@/admin/components/QuickActions'; 5 | import StatCard from '@/admin/components/StatCard'; 6 | 7 | import { 8 | Users, 9 | DollarSign, 10 | ShoppingCart, 11 | TrendingUp, 12 | Eye, 13 | BarChart3, 14 | } from 'lucide-react'; 15 | 16 | const stats = [ 17 | { 18 | title: 'Total Users', 19 | value: '24,567', 20 | change: '+12.5% from last month', 21 | changeType: 'positive' as const, 22 | icon: Users, 23 | color: 'bg-blue-500', 24 | }, 25 | { 26 | title: 'Revenue', 27 | value: '$84,230', 28 | change: '+8.2% from last month', 29 | changeType: 'positive' as const, 30 | icon: DollarSign, 31 | color: 'bg-green-500', 32 | }, 33 | { 34 | title: 'Orders', 35 | value: '1,429', 36 | change: '-2.4% from last month', 37 | changeType: 'negative' as const, 38 | icon: ShoppingCart, 39 | color: 'bg-purple-500', 40 | }, 41 | { 42 | title: 'Conversion Rate', 43 | value: '3.24%', 44 | change: '+0.3% from last month', 45 | changeType: 'positive' as const, 46 | icon: TrendingUp, 47 | color: 'bg-orange-500', 48 | }, 49 | ]; 50 | 51 | const chartData = [ 52 | { label: 'Desktop', value: 65 }, 53 | { label: 'Mobile', value: 28 }, 54 | { label: 'Tablet', value: 7 }, 55 | ]; 56 | 57 | const performanceData = [ 58 | { label: 'Page Views', value: 24567 }, 59 | { label: 'Sessions', value: 18234 }, 60 | { label: 'Users', value: 12847 }, 61 | { label: 'Bounce Rate', value: 23 }, 62 | ]; 63 | 64 | export const DashboardPage = () => { 65 | return ( 66 | <> 67 | {/* Welcome Section */} 68 | 72 | 73 | {/* Stats Grid */} 74 |
75 | {stats.map((stat, index) => ( 76 | 77 | ))} 78 |
79 | 80 | {/* Charts and Activity Section */} 81 |
82 |
83 | 84 | 85 |
86 | 87 |
88 | 89 | 90 |
91 |
92 | 93 | {/* Additional Dashboard Section */} 94 |
95 |
96 |
97 |

Top Pages

98 | 99 |
100 |
101 | {[ 102 | { page: '/dashboard', views: 2847, change: '+12%' }, 103 | { page: '/products', views: 1923, change: '+8%' }, 104 | { page: '/analytics', views: 1456, change: '+15%' }, 105 | { page: '/settings', views: 987, change: '-3%' }, 106 | ].map((item, index) => ( 107 |
111 |
112 |

{item.page}

113 |

114 | {item.views.toLocaleString()} views 115 |

116 |
117 | 124 | {item.change} 125 | 126 |
127 | ))} 128 |
129 |
130 | 131 |
132 |
133 |

134 | System Status 135 |

136 | 137 |
138 |
139 | {[ 140 | { 141 | service: 'API Server', 142 | status: 'Online', 143 | uptime: '99.9%', 144 | color: 'bg-green-500', 145 | }, 146 | { 147 | service: 'Database', 148 | status: 'Online', 149 | uptime: '99.8%', 150 | color: 'bg-green-500', 151 | }, 152 | { 153 | service: 'Cache Server', 154 | status: 'Warning', 155 | uptime: '98.2%', 156 | color: 'bg-yellow-500', 157 | }, 158 | { 159 | service: 'CDN', 160 | status: 'Online', 161 | uptime: '99.9%', 162 | color: 'bg-green-500', 163 | }, 164 | ].map((item, index) => ( 165 |
166 |
167 |
168 |
169 |

{item.service}

170 |

{item.status}

171 |
172 |
173 | 174 | {item.uptime} 175 | 176 |
177 | ))} 178 |
179 |
180 |
181 | 182 | ); 183 | }; 184 | -------------------------------------------------------------------------------- /src/auth/pages/register/RegisterPage.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { Card, CardContent } from '@/components/ui/card'; 3 | import { Input } from '@/components/ui/input'; 4 | import { Label } from '@/components/ui/label'; 5 | import { CustomLogo } from '@/components/custom/CustomLogo'; 6 | import { Link } from 'react-router'; 7 | 8 | export const RegisterPage = () => { 9 | return ( 10 |
11 | 12 | 13 |
14 |
15 |
16 | 17 | 18 |

19 | Crea una nueva cuenta 20 |

21 |
22 |
23 | 24 | 30 |
31 | 32 |
33 | 34 | 40 |
41 |
42 |
43 | 44 | 48 | ¿Olvidaste tu contraseña? 49 | 50 |
51 | 57 |
58 | 61 |
62 | 63 | O ingresa con 64 | 65 |
66 |
67 | 76 | 85 | 94 |
95 |
96 | ¿Ya tienes cuenta?{' '} 97 | 98 | Ingresa ahora 99 | 100 |
101 |
102 |
103 |
104 | Image 109 |
110 |
111 |
112 |
113 | Haciendo click, estás de acuerdo con{' '} 114 | términos y condiciones y{' '} 115 | políticas de uso. 116 |
117 |
118 | ); 119 | }; 120 | -------------------------------------------------------------------------------- /src/auth/pages/login/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import { useState, type FormEvent } from 'react'; 2 | import { Link, useNavigate } from 'react-router'; 3 | import { toast } from 'sonner'; 4 | 5 | import { Button } from '@/components/ui/button'; 6 | import { Card, CardContent } from '@/components/ui/card'; 7 | import { Input } from '@/components/ui/input'; 8 | import { Label } from '@/components/ui/label'; 9 | import { CustomLogo } from '@/components/custom/CustomLogo'; 10 | 11 | import { useAuthStore } from '@/auth/store/auth.store'; 12 | 13 | export const LoginPage = () => { 14 | const navigate = useNavigate(); 15 | const { login } = useAuthStore(); 16 | 17 | const [isPosting, setIsPosting] = useState(false); 18 | 19 | const handleLogin = async (event: FormEvent) => { 20 | event.preventDefault(); 21 | setIsPosting(true); 22 | 23 | const formData = new FormData(event.target as HTMLFormElement); 24 | const email = formData.get('email') as string; 25 | const password = formData.get('password') as string; 26 | 27 | const isValid = await login(email, password); 28 | 29 | if (isValid) { 30 | navigate('/'); 31 | return; 32 | } 33 | 34 | toast.error('Correo o/y contraseña no validos'); 35 | setIsPosting(false); 36 | }; 37 | 38 | return ( 39 |
40 | 41 | 42 |
43 |
44 |
45 | 46 | 47 |

48 | Ingrese a nuestra aplicación 49 |

50 |
51 |
52 | 53 | 60 |
61 |
62 |
63 | 64 | 68 | ¿Olvidaste tu contraseña? 69 | 70 |
71 | 78 |
79 | 82 |
83 | 84 | O continúa con 85 | 86 |
87 |
88 | 97 | 106 | 115 |
116 |
117 | ¿No tienes cuenta?{' '} 118 | 122 | Crea una 123 | 124 |
125 |
126 |
127 |
128 | Image 133 |
134 |
135 |
136 |
137 | Haciendo click, estás de acuerdo con{' '} 138 | términos y condiciones y{' '} 139 | políticas de uso. 140 |
141 |
142 | ); 143 | }; 144 | -------------------------------------------------------------------------------- /src/admin/pages/product/ui/ProductForm.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | import { useForm } from 'react-hook-form'; 5 | 6 | import { AdminTitle } from '@/admin/components/AdminTitle'; 7 | 8 | import { Button } from '@/components/ui/button'; 9 | import type { Product, Size } from '@/interfaces/product.interface'; 10 | import { X, SaveAll, Tag, Plus, Upload } from 'lucide-react'; 11 | import { cn } from '@/lib/utils'; 12 | 13 | interface Props { 14 | title: string; 15 | subTitle: string; 16 | product: Product; 17 | isPending: boolean; 18 | 19 | // Methods 20 | onSubmit: ( 21 | productLike: Partial & { files?: File[] } 22 | ) => Promise; 23 | } 24 | 25 | const availableSizes: Size[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL']; 26 | 27 | interface FormInputs extends Product { 28 | files?: File[]; 29 | } 30 | 31 | export const ProductForm = ({ 32 | title, 33 | subTitle, 34 | product, 35 | onSubmit, 36 | isPending, 37 | }: Props) => { 38 | const [dragActive, setDragActive] = useState(false); 39 | const { 40 | register, 41 | handleSubmit, 42 | formState: { errors }, 43 | getValues, 44 | setValue, 45 | watch, 46 | } = useForm({ 47 | defaultValues: product, 48 | }); 49 | 50 | const labelInputRef = useRef(null); 51 | const [files, setFiles] = useState([]); 52 | 53 | useEffect(() => { 54 | setFiles([]); 55 | }, [product]); 56 | 57 | const selectedSizes = watch('sizes'); 58 | const selectedTags = watch('tags'); 59 | const currentStock = watch('stock'); 60 | 61 | const addTag = () => { 62 | const newTag = labelInputRef.current!.value; 63 | if (newTag === '') return; 64 | 65 | const newTagSet = new Set(getValues('tags')); 66 | newTagSet.add(newTag); 67 | setValue('tags', Array.from(newTagSet)); 68 | }; 69 | 70 | const removeTag = (tag: string) => { 71 | const newTagSet = new Set(getValues('tags')); 72 | newTagSet.delete(tag); 73 | setValue('tags', Array.from(newTagSet)); 74 | }; 75 | 76 | const addSize = (size: Size) => { 77 | const sizeSet = new Set(getValues('sizes')); 78 | sizeSet.add(size); 79 | setValue('sizes', Array.from(sizeSet)); 80 | }; 81 | 82 | const removeSize = (size: Size) => { 83 | const sizeSet = new Set(getValues('sizes')); 84 | sizeSet.delete(size); 85 | setValue('sizes', Array.from(sizeSet)); 86 | }; 87 | 88 | const handleDrag = (e: React.DragEvent) => { 89 | e.preventDefault(); 90 | e.stopPropagation(); 91 | if (e.type === 'dragenter' || e.type === 'dragover') { 92 | setDragActive(true); 93 | } else if (e.type === 'dragleave') { 94 | setDragActive(false); 95 | } 96 | }; 97 | 98 | const handleDrop = (e: React.DragEvent) => { 99 | e.preventDefault(); 100 | e.stopPropagation(); 101 | setDragActive(false); 102 | const files = e.dataTransfer.files; 103 | 104 | if (!files) return; 105 | 106 | setFiles((prev) => [...prev, ...Array.from(files)]); 107 | 108 | const currentFiles = getValues('files') || []; 109 | setValue('files', [...currentFiles, ...Array.from(files)]); 110 | }; 111 | 112 | const handleFileChange = (e: React.ChangeEvent) => { 113 | const files = e.target.files; 114 | if (!files) return; 115 | 116 | setFiles((prev) => [...prev, ...Array.from(files)]); 117 | const currentFiles = getValues('files') || []; 118 | setValue('files', [...currentFiles, ...Array.from(files)]); 119 | }; 120 | 121 | return ( 122 |
123 |
124 | 125 |
126 | 132 | 133 | 137 |
138 |
139 | 140 |
141 |
142 | {/* Main Form */} 143 |
144 | {/* Basic Information */} 145 |
146 |

147 | Información del producto 148 |

149 | 150 |
151 |
152 | 155 | 168 | {errors.title && ( 169 |

170 | El título es requerido 171 |

172 | )} 173 |
174 | 175 |
176 |
177 | 180 | 194 | {errors.price && ( 195 |

196 | El precio debe de ser mayor a 0 197 |

198 | )} 199 |
200 | 201 |
202 | 205 | 219 | {errors.stock && ( 220 |

221 | El inventario debe de ser mayor a 0 222 |

223 | )} 224 |
225 |
226 | 227 |
228 | 231 | 236 | !/\s/.test(value) || 237 | 'El slug no puede contener espacios en blanco', 238 | })} 239 | className={cn( 240 | 'w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200', 241 | { 242 | 'border-red-500': errors.slug, 243 | } 244 | )} 245 | placeholder="Slug del producto" 246 | /> 247 | {errors.slug && ( 248 |

249 | {errors.slug.message || 'El slug es requerido.'} 250 |

251 | )} 252 |
253 | 254 |
255 | 258 | 271 |
272 | 273 |
274 | 277 |