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
Theapp/ 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
(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 }
}
'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 inlib/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 inlib/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 inhooks/:
// 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 inglobals.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
