Skip to main content

Frontend Structure

Hiro CRM’s frontend is built with Next.js 16 using the App Router architecture, React Server Components, and TypeScript. The codebase is organized for scalability, maintainability, and developer experience.

Directory Structure

frontend/
├── app/                    # Next.js App Router
│   ├── (app)/             # Authenticated app routes
│   ├── api/               # API route handlers
│   ├── actions/           # Server Actions
│   ├── layout.tsx         # Root layout
│   └── page.tsx           # Landing page
├── components/            # React components
│   ├── ui/               # Base UI components
│   ├── customers/        # Customer-specific components
│   ├── dashboard/        # Dashboard widgets
│   ├── reservations/     # Reservation components
│   └── marketing/        # Marketing components
├── lib/                   # Business logic & utilities
│   ├── supabase/         # Database clients
│   ├── validations/      # Zod schemas
│   ├── services/         # Business services
│   └── utils/            # Helper functions
├── hooks/                 # Custom React hooks
├── context/               # React Context providers
├── types/                 # TypeScript definitions
├── public/                # Static assets
└── middleware.ts          # Edge middleware

App Router Architecture

Route Organization

The app/ directory uses Next.js App Router conventions:
app/
├── (app)/                 # Route group (authenticated)
│   ├── dashboard/
│   │   └── page.tsx      # /dashboard
│   ├── customers/
│   │   ├── page.tsx      # /customers
│   │   ├── [id]/
│   │   │   └── page.tsx  # /customers/[id]
│   │   └── import/
│   │       └── page.tsx  # /customers/import
│   ├── reservations/
│   ├── marketing/
│   ├── hotels/
│   └── layout.tsx        # Shared layout for all routes
├── api/                   # API routes
│   ├── health/
│   ├── sync/
│   └── cron/
├── login/
│   └── page.tsx          # /login
└── layout.tsx            # Root layout
Route Groups like (app) don’t affect the URL but allow shared layouts.

Layouts and Templates

Root Layout

// app/layout.tsx
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({ subsets: ['latin'] })

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="es" suppressHydrationWarning>
      <body className={inter.className}>
        {children}
      </body>
    </html>
  )
}

App Layout (Authenticated Routes)

// app/(app)/layout.tsx
import { AppShell } from '@/components/AppShell'
import { createClient } from '@/lib/supabase/server'

export default async function AppLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login')
  }
  
  return <AppShell user={user}>{children}</AppShell>
}

Component Architecture

Component Categories

1. UI Components (components/ui/)

Reusable, framework-agnostic UI primitives:
// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium',
  {
    variants: {
      variant: {
        default: 'bg-primary text-white hover:bg-primary/90',
        secondary: 'bg-secondary text-secondary-foreground',
        outline: 'border border-input bg-background',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 px-3',
        lg: 'h-11 px-8',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

export function Button({ variant, size, ...props }: ButtonProps) {
  return (
    <button 
      className={buttonVariants({ variant, size })} 
      {...props} 
    />
  )
}

2. Feature Components

Domain-specific components with business logic:
// components/customers/CustomerCard.tsx
import { Database } from '@/types/database'
import { Badge } from '@/components/ui/Badge'

type Customer = Database['public']['Tables']['customers']['Row']

export function CustomerCard({ customer }: { customer: Customer }) {
  return (
    <div className="rounded-lg border p-4">
      <div className="flex items-center gap-4">
        <Avatar src={customer.photo_url} />
        <div>
          <h3>{customer.first_name} {customer.last_name}</h3>
          <p className="text-sm text-muted-foreground">{customer.email}</p>
        </div>
        <Badge variant={getTierVariant(customer.loyalty_tier)}>
          {customer.loyalty_tier}
        </Badge>
      </div>
      <div className="mt-4 grid grid-cols-3 gap-4 text-sm">
        <div>
          <p className="text-muted-foreground">Total Visits</p>
          <p className="font-medium">{customer.total_visits}</p>
        </div>
        <div>
          <p className="text-muted-foreground">Total Spend</p>
          <p className="font-medium">{customer.total_spend}</p>
        </div>
        <div>
          <p className="text-muted-foreground">Last Visit</p>
          <p className="font-medium">
            {formatDate(customer.last_visit_at)}
          </p>
        </div>
      </div>
    </div>
  )
}

3. Layout Components

Page structure and navigation:
// components/AppShell.tsx
import { Sidebar } from './Sidebar'
import { Header } from './Header'

export function AppShell({ user, children }: AppShellProps) {
  return (
    <div className="flex h-screen">
      <Sidebar />
      <div className="flex-1 flex flex-col">
        <Header user={user} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  )
}

Server vs Client Components

Server Components (Default)

Used for data fetching and static rendering:
// app/(app)/customers/page.tsx
import { createClient } from '@/lib/supabase/server'
import { CustomerList } from '@/components/customers/CustomerList'

export default async function CustomersPage() {
  const supabase = await createClient()
  
  const { data: customers } = await supabase
    .from('customers')
    .select('*')
    .order('created_at', { ascending: false })
    .limit(50)
  
  return (
    <div>
      <h1>Customers</h1>
      <CustomerList customers={customers} />
    </div>
  )
}

Client Components

Used for interactivity and browser APIs:
'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'

export function CustomerSearch() {
  const [query, setQuery] = useState('')
  const router = useRouter()
  
  const handleSearch = (e: FormEvent) => {
    e.preventDefault()
    router.push(`/customers?search=${query}`)
  }
  
  return (
    <form onSubmit={handleSearch}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search customers..."
      />
      <button type="submit">Search</button>
    </form>
  )
}

Server Actions

Server-side mutations called from client components:
// app/actions/customers.ts
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'

const createCustomerSchema = z.object({
  first_name: z.string().min(1),
  last_name: z.string().min(1),
  email: z.string().email().optional(),
  phone: z.string().optional(),
})

export async function createCustomer(formData: FormData) {
  const supabase = await createClient()
  
  // Validate input
  const data = createCustomerSchema.parse({
    first_name: formData.get('first_name'),
    last_name: formData.get('last_name'),
    email: formData.get('email'),
    phone: formData.get('phone'),
  })
  
  // Insert customer
  const { data: customer, error } = await supabase
    .from('customers')
    .insert(data)
    .select()
    .single()
  
  if (error) {
    return { error: error.message }
  }
  
  // Revalidate the customers page
  revalidatePath('/customers')
  
  return { customer }
}
Usage in Client Component:
'use client'

import { createCustomer } from '@/app/actions/customers'
import { useFormState } from 'react-dom'

export function CreateCustomerForm() {
  const [state, formAction] = useFormState(createCustomer, null)
  
  return (
    <form action={formAction}>
      <input name="first_name" required />
      <input name="last_name" required />
      <input name="email" type="email" />
      <button type="submit">Create Customer</button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}

Business Logic Layer

Services

Reusable business logic in lib/services/:
// lib/services/customer-service.ts
import { createClient } from '@/lib/supabase/server'

export class CustomerService {
  private supabase
  
  constructor() {
    this.supabase = createClient()
  }
  
  async getCustomerWithMetrics(customerId: string) {
    const { data, error } = await this.supabase
      .rpc('get_customer_with_metrics', { customer_uuid: customerId })
    
    if (error) throw error
    return data
  }
  
  async calculateLoyaltyTier(customerId: string) {
    const { data } = await this.supabase
      .from('customers')
      .select('total_visits, total_spend')
      .eq('id', customerId)
      .single()
    
    if (!data) return 'Bronce'
    
    if (data.total_spend >= 5000) return 'Platino'
    if (data.total_spend >= 2500) return 'Oro'
    if (data.total_spend >= 1000) return 'Plata'
    return 'Bronce'
  }
}

Utilities

Helper functions in lib/utils/:
// lib/utils/format.ts
export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('es-ES', {
    style: 'currency',
    currency: 'EUR',
  }).format(amount)
}

export function formatDate(date: string | Date): string {
  return new Intl.DateTimeFormat('es-ES', {
    day: 'numeric',
    month: 'short',
    year: 'numeric',
  }).format(new Date(date))
}

Custom Hooks

Reusable React hooks in hooks/:
// hooks/usePermissions.ts
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'

export function usePermissions() {
  const [role, setRole] = useState<string | null>(null)
  const supabase = createClient()
  
  useEffect(() => {
    const fetchRole = async () => {
      const { data: { user } } = await supabase.auth.getUser()
      if (user) {
        const { data: profile } = await supabase
          .from('profiles')
          .select('role')
          .eq('id', user.id)
          .single()
        
        setRole(profile?.role || null)
      }
    }
    
    fetchRole()
  }, [])
  
  return {
    role,
    isAdmin: role === 'super_admin',
    isCEO: role === 'ceo',
    isMarketing: role === 'marketing',
  }
}

State Management

React Context

Global state with React Context:
// context/OrganizationContext.tsx
'use client'

import { createContext, useContext, ReactNode } from 'react'

type OrganizationContextType = {
  brandId: string
  locationIds: string[]
}

const OrganizationContext = createContext<OrganizationContextType | null>(null)

export function OrganizationProvider({ 
  children,
  value 
}: { 
  children: ReactNode
  value: OrganizationContextType 
}) {
  return (
    <OrganizationContext.Provider value={value}>
      {children}
    </OrganizationContext.Provider>
  )
}

export function useOrganization() {
  const context = useContext(OrganizationContext)
  if (!context) {
    throw new Error('useOrganization must be used within OrganizationProvider')
  }
  return context
}

URL State

Using URL search params for filters:
'use client'

import { useSearchParams, useRouter } from 'next/navigation'

export function CustomerFilters() {
  const searchParams = useSearchParams()
  const router = useRouter()
  
  const tier = searchParams.get('tier')
  
  const setFilter = (key: string, value: string) => {
    const params = new URLSearchParams(searchParams)
    params.set(key, value)
    router.push(`?${params.toString()}`)
  }
  
  return (
    <select 
      value={tier || ''} 
      onChange={(e) => setFilter('tier', e.target.value)}
    >
      <option value="">All Tiers</option>
      <option value="Bronce">Bronce</option>
      <option value="Plata">Plata</option>
      <option value="Oro">Oro</option>
    </select>
  )
}

Styling

Tailwind CSS

Utility-first CSS framework:
<div className="rounded-lg border bg-white p-6 shadow-sm">
  <h2 className="text-2xl font-bold text-gray-900">
    Customer Overview
  </h2>
  <p className="mt-2 text-sm text-gray-600">
    View and manage your customer relationships
  </p>
</div>

CSS Variables

Theme variables in globals.css:
:root {
  --background: 0 0% 100%;
  --foreground: 222.2 84% 4.9%;
  --primary: 221.2 83.2% 53.3%;
  --primary-foreground: 210 40% 98%;
  --secondary: 210 40% 96.1%;
}

.dark {
  --background: 222.2 84% 4.9%;
  --foreground: 210 40% 98%;
}

Performance Optimization

Code Splitting

import dynamic from 'next/dynamic'

// Lazy load heavy components
const HeavyChart = dynamic(() => import('@/components/charts/HeavyChart'), {
  loading: () => <Skeleton />,
  ssr: false,
})

Image Optimization

import Image from 'next/image'

<Image
  src={customer.photo_url}
  alt={customer.first_name}
  width={48}
  height={48}
  className="rounded-full"
/>

Testing

Unit Tests with Vitest

// lib/utils/__tests__/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency } from '../format'

describe('formatCurrency', () => {
  it('formats currency correctly', () => {
    expect(formatCurrency(1234.56)).toBe('1.234,56 €')
  })
})

Integration Tests

// tests/integration/customer-flow.test.ts
import { test, expect } from '@playwright/test'

test('create customer flow', async ({ page }) => {
  await page.goto('/customers')
  await page.click('text=New Customer')
  await page.fill('input[name="first_name"]', 'Juan')
  await page.fill('input[name="last_name"]', 'García')
  await page.click('button[type="submit"]')
  await expect(page.locator('text=Juan García')).toBeVisible()
})

Next Steps

API Routes

Explore the API endpoint structure

Database Schema

Understand the data models

Build docs developers (and LLMs) love