Skip to main content

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

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.
pages/tienda/index.astro
---
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>

Example: Shopping Cart Island

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

stores/cartStore.ts
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

stores/authStore.ts
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',
  }
));

Form Handling

React Hook Form with Zod Validation

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

tailwind.config.mjs
/** @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>
  );
}

Performance Optimizations

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

vitest.config.ts
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

Build docs developers (and LLMs) love