Overview
The PC Fix frontend is built with Astro 5 , leveraging its islands architecture for optimal performance. Interactive components are built with React 18 , while static content is rendered server-side for instant page loads.
The frontend achieves a 95+ Lighthouse score through Astro’s partial hydration and View Transitions.
Core Technologies
Astro 5.16.3 Meta-framework for SSR, static generation, and partial hydration
React 18.3 UI library for interactive islands (cart, forms, admin dashboard)
Tailwind CSS 3.4 Utility-first CSS framework for responsive design
TypeScript 5.9 Type-safe development across the frontend
Astro Configuration
astro.config.mjs
import { defineConfig } from 'astro/config' ;
import react from '@astrojs/react' ;
import tailwind from '@astrojs/tailwind' ;
import vercel from '@astrojs/vercel' ;
import node from '@astrojs/node' ;
import sitemap from '@astrojs/sitemap' ;
import sentry from '@sentry/astro' ;
const isVercel = !! process . env . VERCEL ;
export default defineConfig ({
site: 'https://pcfixbaru.com.ar' ,
output: 'server' , // SSR mode
// Dynamic adapter selection based on environment
adapter: isVercel
? vercel ({
webAnalytics: { enabled: true },
imageService: true ,
})
: node ({
mode: 'standalone' ,
}) ,
integrations: [
react (),
tailwind (),
sitemap ({
filter : ( page ) => {
const excludePatterns = [
'/admin' , '/auth' , '/checkout' , '/perfil' ,
'/mis-consultas' , '/404' , '/success' ,
'/acceso-denegado' , '/error' , '/mantenimiento' ,
'/login' , '/reset-password' , '/cuenta' ,
'/tienda/carrito'
];
return ! excludePatterns . some ( pattern => page . includes ( pattern ));
}
}),
... ( process . env . PUBLIC_SENTRY_DSN ? [ sentry ({
dsn: process . env . PUBLIC_SENTRY_DSN ,
sourceMapsUploadOptions: { enabled: false },
})] : [])
] ,
image: {
domains: [
'placehold.co' ,
'images.unsplash.com' ,
'res.cloudinary.com' // Cloudinary CDN
],
remotePatterns: [{ protocol: "https" }],
} ,
}) ;
The configuration dynamically selects the adapter based on the deployment environment (Vercel vs. Node.js standalone).
Project Structure
packages/web/src/
├── assets/ # Static assets
├── components/ # React components
│ ├── admin/ # Admin dashboard components
│ │ ├── core/ # Dashboard, stats, charts
│ │ ├── dashboard/ # Intelligence, analytics
│ │ ├── products/ # Product management
│ │ └── sales/ # Sales management
│ ├── cart/ # Shopping cart UI
│ ├── checkout/ # Checkout flow
│ ├── layout/ # Header, footer, navigation
│ ├── product/ # Product cards, filters
│ └── ui/ # Reusable UI components
├── data/ # Static data files
├── layouts/ # Astro layouts
│ └── Layout.astro # Main layout wrapper
├── pages/ # File-based routing
│ ├── admin/ # Admin panel pages
│ ├── api/ # API endpoints (Astro)
│ ├── auth/ # Login, register
│ ├── checkout/ # Checkout pages
│ ├── perfil/ # User profile
│ ├── tienda/ # Store pages
│ └── index.astro # Homepage
├── stores/ # Zustand stores
│ ├── authStore.ts # Authentication state
│ ├── cartStore.ts # Shopping cart state
│ ├── favoritesStore.ts
│ ├── serviceStore.ts
│ └── toastStore.ts
├── styles/ # Global CSS
├── types/ # TypeScript types
└── utils/ # Utility functions
Astro Islands Architecture
What are Islands?
Astro Islands allow you to mix static HTML with interactive JavaScript components. Only the interactive parts are hydrated on the client.
Static Content
Client Directives
---
import Layout from '~/layouts/Layout.astro' ;
import ProductList from '~/components/product/ProductList' ;
// This runs on the server
const products = await fetch ( ` ${ API_URL } /api/products` ). then ( r => r . json ());
---
< Layout title = "Tienda" >
< h1 > Nuestros Productos </ h1 >
<!-- This component is hydrated on the client -->
< ProductList client:load products = { products } />
</ Layout >
Astro provides several client directives for controlling hydration: Directive Behavior client:loadHydrate immediately on page load client:idleHydrate when browser is idle client:visibleHydrate when component enters viewport client:mediaHydrate based on media query client:onlySkip SSR, render only on client
components/cart/CartButton.tsx
import { useCartStore } from '~/stores/cartStore' ;
import { ShoppingCart } from 'lucide-react' ;
export default function CartButton () {
const { items , totalItems } = useCartStore ();
return (
< button className = "relative p-2 hover:bg-gray-100 rounded-full" >
< ShoppingCart size = { 24 } />
{ totalItems > 0 && (
< span className = "absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center" >
{ totalItems }
</ span >
) }
</ button >
);
}
components/layout/Header.astro
---
import CartButton from '~/components/cart/CartButton' ;
---
< header >
< nav >
<!-- Static navigation -->
< a href = "/" > Inicio </ a >
< a href = "/tienda" > Tienda </ a >
<!-- Interactive cart button -->
< CartButton client:load />
</ nav >
</ header >
State Management with Zustand
Cart Store Example
import { create } from 'zustand' ;
import { persist } from 'zustand/middleware' ;
interface CartItem {
id : number ;
nombre : string ;
precio : number ;
quantity : number ;
foto : string | null ;
}
interface CartStore {
items : CartItem [];
totalItems : number ;
totalPrice : number ;
addItem : ( item : CartItem ) => void ;
removeItem : ( id : number ) => void ;
updateQuantity : ( id : number , quantity : number ) => void ;
clearCart : () => void ;
}
export const useCartStore = create < CartStore >()( persist (
( set , get ) => ({
items: [],
totalItems: 0 ,
totalPrice: 0 ,
addItem : ( item ) => set (( state ) => {
const existingItem = state . items . find ( i => i . id === item . id );
if ( existingItem ) {
return {
items: state . items . map ( i =>
i . id === item . id
? { ... i , quantity: i . quantity + item . quantity }
: i
)
};
}
return { items: [ ... state . items , item ] };
}),
removeItem : ( id ) => set (( state ) => ({
items: state . items . filter ( i => i . id !== id )
})),
updateQuantity : ( id , quantity ) => set (( state ) => ({
items: state . items . map ( i =>
i . id === id ? { ... i , quantity } : i
)
})),
clearCart : () => set ({ items: [] }),
}),
{
name: 'cart-storage' , // localStorage key
}
));
Auth Store Example
import { create } from 'zustand' ;
import { persist } from 'zustand/middleware' ;
interface User {
id : number ;
email : string ;
nombre : string ;
apellido : string ;
role : 'USER' | 'ADMIN' ;
}
interface AuthStore {
user : User | null ;
accessToken : string | null ;
isAuthenticated : boolean ;
login : ( user : User , accessToken : string ) => void ;
logout : () => void ;
updateUser : ( user : Partial < User >) => void ;
}
export const useAuthStore = create < AuthStore >()( persist (
( set ) => ({
user: null ,
accessToken: null ,
isAuthenticated: false ,
login : ( user , accessToken ) => set ({
user ,
accessToken ,
isAuthenticated: true ,
}),
logout : () => set ({
user: null ,
accessToken: null ,
isAuthenticated: false ,
}),
updateUser : ( userData ) => set (( state ) => ({
user: state . user ? { ... state . user , ... userData } : null ,
})),
}),
{
name: 'auth-storage' ,
}
));
components/auth/LoginForm.tsx
import { useForm } from 'react-hook-form' ;
import { zodResolver } from '@hookform/resolvers/zod' ;
import { z } from 'zod' ;
import { useAuthStore } from '~/stores/authStore' ;
import { toast } from 'sonner' ;
const loginSchema = z . object ({
email: z . string (). email ( 'Email inválido' ),
password: z . string (). min ( 6 , 'Mínimo 6 caracteres' ),
});
type LoginForm = z . infer < typeof loginSchema >;
export default function LoginForm () {
const { login } = useAuthStore ();
const {
register ,
handleSubmit ,
formState : { errors , isSubmitting },
} = useForm < LoginForm >({
resolver: zodResolver ( loginSchema ),
});
const onSubmit = async ( data : LoginForm ) => {
try {
const response = await fetch ( ` ${ import . meta . env . PUBLIC_API_URL } /api/auth/login` , {
method: 'POST' ,
headers: { 'Content-Type' : 'application/json' },
body: JSON . stringify ( data ),
});
if ( ! response . ok ) throw new Error ( 'Login failed' );
const { user , accessToken } = await response . json ();
login ( user , accessToken );
toast . success ( '¡Bienvenido!' );
window . location . href = '/perfil' ;
} catch ( error ) {
toast . error ( 'Error al iniciar sesión' );
}
};
return (
< form onSubmit = { handleSubmit ( onSubmit ) } className = "space-y-4" >
< div >
< label htmlFor = "email" className = "block text-sm font-medium" >
Email
</ label >
< input
{ ... register ( 'email' ) }
type = "email"
className = "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
{ errors . email && (
< p className = "mt-1 text-sm text-red-600" > { errors . email . message } </ p >
) }
</ div >
< div >
< label htmlFor = "password" className = "block text-sm font-medium" >
Contraseña
</ label >
< input
{ ... register ( 'password' ) }
type = "password"
className = "mt-1 block w-full rounded-md border border-gray-300 px-3 py-2"
/>
{ errors . password && (
< p className = "mt-1 text-sm text-red-600" > { errors . password . message } </ p >
) }
</ div >
< button
type = "submit"
disabled = { isSubmitting }
className = "w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{ isSubmitting ? 'Iniciando...' : 'Iniciar Sesión' }
</ button >
</ form >
);
}
Tailwind CSS Configuration
/** @type {import('tailwindcss').Config} */
export default {
content: [ './src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}' ] ,
theme: {
extend: {
colors: {
primary: {
50 : '#eff6ff' ,
500 : '#3b82f6' ,
600 : '#2563eb' ,
700 : '#1d4ed8' ,
},
},
fontFamily: {
sans: [ 'Inter' , 'system-ui' , 'sans-serif' ],
},
},
} ,
plugins: [] ,
} ;
UI Components
Toast Notifications with Sonner
components/ui/Toaster.tsx
import { Toaster as SonnerToaster } from 'sonner' ;
export default function Toaster () {
return (
< SonnerToaster
position = "top-right"
richColors
closeButton
duration = { 4000 }
/>
);
}
Usage:
import { toast } from 'sonner' ;
// Success
toast . success ( 'Producto agregado al carrito' );
// Error
toast . error ( 'Error al procesar la solicitud' );
// Loading
toast . loading ( 'Procesando...' );
// Custom
toast ( 'Evento personalizado' , {
description: 'Descripción adicional' ,
action: {
label: 'Deshacer' ,
onClick : () => console . log ( 'Undo' ),
},
});
Swiper Carousels
components/product/ProductCarousel.tsx
import { Swiper , SwiperSlide } from 'swiper/react' ;
import { Navigation , Pagination } from 'swiper/modules' ;
import 'swiper/css' ;
import 'swiper/css/navigation' ;
import 'swiper/css/pagination' ;
interface Product {
id : number ;
nombre : string ;
foto : string | null ;
precio : number ;
}
interface Props {
products : Product [];
}
export default function ProductCarousel ({ products } : Props ) {
return (
< Swiper
modules = { [ Navigation , Pagination ] }
spaceBetween = { 20 }
slidesPerView = { 1 }
navigation
pagination = { { clickable: true } }
breakpoints = { {
640 : { slidesPerView: 2 },
768 : { slidesPerView: 3 },
1024 : { slidesPerView: 4 },
} }
>
{ products . map (( product ) => (
< SwiperSlide key = { product . id } >
< div className = "border rounded-lg p-4" >
< img
src = { product . foto || '/placeholder.jpg' }
alt = { product . nombre }
className = "w-full h-48 object-cover rounded"
/>
< h3 className = "mt-2 font-semibold" > { product . nombre } </ h3 >
< p className = "text-lg font-bold" > $ { product . precio } </ p >
</ div >
</ SwiperSlide >
)) }
</ Swiper >
);
}
View Transitions Astro’s built-in View Transitions provide smooth page navigation without full page reloads
Image Optimization Automatic image optimization with Astro’s <Image /> component and Cloudinary CDN
Partial Hydration Only interactive components are hydrated, reducing JavaScript payload
Code Splitting Automatic code splitting per route and component
Testing
Vitest Configuration
import { defineConfig } from 'vitest/config' ;
import react from '@vitejs/plugin-react' ;
export default defineConfig ({
plugins: [ react ()] ,
test: {
environment: 'jsdom' ,
globals: true ,
setupFiles: './src/test/setup.ts' ,
} ,
}) ;
Component Test Example
components/cart/CartButton.test.tsx
import { render , screen } from '@testing-library/react' ;
import { describe , it , expect } from 'vitest' ;
import CartButton from './CartButton' ;
describe ( 'CartButton' , () => {
it ( 'displays cart item count' , () => {
render (< CartButton />);
const badge = screen . getByText ( '3' );
expect ( badge ). toBeInTheDocument ();
});
});
Next Steps
Backend API Learn about the Express API
Database Schema Explore the data model
Deployment Deploy to production