Sviluppo Frontend
Guida completa allo sviluppo frontend con Next.js 14, React, TypeScript e Tailwind CSS nell'ecosistema Emblema.
π Stack Tecnologicoβ
Core Frameworkβ
- Next.js 14: App Router, Server Components, API Routes
- React 18: Components, Hooks, Suspense
- TypeScript 5: Type safety completo
- Tailwind CSS: Utility-first styling
UI e Componentiβ
- Shadcn/ui: Component library base
- Radix UI: Primitive headless components
- Lucide React: Icon system
- Framer Motion: Animazioni
State Managementβ
- SWR: Server state e caching
- React Hook Form: Form state management
- Zustand: Client state (se necessario)
- Context API: State globale (settings, user)
Sviluppo e Buildβ
- Turbo: Build orchestration
- ESLint + Prettier: Code quality
- pnpm: Package management
π Struttura App Principaleβ
apps/www-emblema/
βββ app/ # Next.js 14 App Router
β βββ layout.tsx # Root layout
β βββ page.tsx # Home page
β βββ dashboard/ # Dashboard routes
β βββ documents/ # Document management
β βββ chat/ # Chat interface
β βββ settings/ # Settings pages
β βββ api/ # API Routes
β β βββ v1/ # Versioned API
β β βββ auth/ # NextAuth endpoints
β β βββ cron/ # Scheduled tasks
β βββ globals.css # Global styles
β βββ layout.tsx # Root app layout
βββ components/ # React components
βββ hooks/ # Custom hooks
βββ lib/ # Utilities
βββ config/ # App configuration
βββ middleware.ts # Next.js middleware
βββ schema/ # Zod validation
βββ types/ # Type definitions
π§© Componenti e Patternβ
1. Component Organizationβ
Per Feature (Domain-Driven)β
// components/document/
βββ form.tsx # Document form
βββ list.tsx # Document list
βββ view.tsx # Single document view
βββ data-table.tsx # Sortable table
βββ preview.tsx # File preview
βββ empty-state.tsx # Empty state
// components/chat/
βββ chat.tsx # Main chat component
βββ message.tsx # Single message
βββ input.tsx # Chat input
βββ sidebar.tsx # Chat sidebar
βββ artifact.tsx # AI artifacts
Form Pattern con forwardRefβ
import { forwardRef, useImperativeHandle } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormRef } from '@/types/form-refs';
import { UI_CONSTANTS } from '@/lib/constants';
interface DocumentFormProps extends BaseFormProps<DocumentSchema> {
onCancel?: () => void;
}
export const DocumentForm = forwardRef<FormRef<DocumentSchema>, DocumentFormProps>(
({ defaultValues, onSubmit, variant = 'page', onCancel }, ref) => {
const form = useForm<DocumentSchema>({
resolver: zodResolver(createDocumentSchema()),
defaultValues,
});
useImperativeHandle(ref, () => ({
submit: async () => await form.handleSubmit(onSubmit)(),
reset: (values) => form.reset(values || defaultValues),
getValues: () => form.getValues(),
isDirty: () => form.formState.isDirty,
isValid: () => form.formState.isValid,
}));
const showButtons = variant === 'page';
return (
<Form {...form}>
<form onSubmit={showButtons ? form.handleSubmit(onSubmit) : undefined}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Nome Documento</FormLabel>
<FormControl>
<Input
placeholder="Inserisci il nome del documento"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{showButtons && (
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={onCancel}>
{UI_CONSTANTS.actions.cancel}
</Button>
<Button type="submit">
{UI_CONSTANTS.actions.save}
</Button>
</div>
)}
</form>
</Form>
);
}
);
DocumentForm.displayName = 'DocumentForm';
2. Custom Hooks Patternβ
Data Fetching Hookβ
import useSWR from "swr";
import { useCallback } from "react";
interface UseDocumentsOptions {
filters?: {
status?: string;
knowledgeBaseId?: string;
};
enabled?: boolean;
}
export const useDocuments = (options: UseDocumentsOptions = {}) => {
const { filters, enabled = true } = options;
const searchParams = new URLSearchParams();
if (filters?.status) searchParams.set("status", filters.status);
if (filters?.knowledgeBaseId)
searchParams.set("kb_id", filters.knowledgeBaseId);
const { data, error, mutate, isLoading } = useSWR(
enabled ? `/api/v1/documents?${searchParams}` : null,
async (url: string) => {
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch documents");
return response.json();
},
);
const createDocument = useCallback(
async (documentData: CreateDocumentInput) => {
const response = await fetch("/api/v1/documents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(documentData),
});
if (!response.ok) throw new Error("Failed to create document");
const newDocument = await response.json();
mutate(); // Refresh list
return newDocument;
},
[mutate],
);
return {
documents: data?.documents || [],
total: data?.total || 0,
error,
isLoading,
createDocument,
refresh: mutate,
};
};
File Upload Hookβ
export const useFileUpload = () => {
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const uploadFile = useCallback(
async (file: File, options: UploadOptions = {}) => {
setIsUploading(true);
setError(null);
setProgress(0);
try {
const formData = new FormData();
formData.append("file", file);
if (options.knowledgeBaseId) {
formData.append("knowledge_base_id", options.knowledgeBaseId);
}
const response = await fetch("/api/v1/documents/upload", {
method: "POST",
body: formData,
// Track upload progress
onUploadProgress: (progressEvent) => {
const percentCompleted = Math.round(
(progressEvent.loaded * 100) / progressEvent.total,
);
setProgress(percentCompleted);
},
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Upload failed");
}
const result = await response.json();
setProgress(100);
return result;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : "Upload failed";
setError(errorMessage);
throw err;
} finally {
setIsUploading(false);
}
},
[],
);
const reset = useCallback(() => {
setProgress(0);
setError(null);
setIsUploading(false);
}, []);
return {
uploadFile,
progress,
error,
isUploading,
reset,
};
};
3. Data Table Patternβ
import { useState } from 'react';
import { DataTable } from '@/components/data-table/data-table';
import { DocumentColumns } from './columns';
interface DocumentListProps {
knowledgeBaseId?: string;
}
export const DocumentList = ({ knowledgeBaseId }: DocumentListProps) => {
const [sorting, setSorting] = useState<SortingState>([]);
const [filtering, setFiltering] = useState<string>('');
const { documents, isLoading, total } = useDocuments({
filters: { knowledgeBaseId },
});
const columns = DocumentColumns();
if (isLoading) {
return <DocumentTableSkeleton />;
}
return (
<div className="space-y-4">
<DataTable
data={documents}
columns={columns}
sorting={sorting}
onSortingChange={setSorting}
filtering={filtering}
onFilteringChange={setFiltering}
totalItems={total}
emptyState={
<EmptyDocumentState
knowledgeBaseId={knowledgeBaseId}
onUpload={() => {/* handle upload */}}
/>
}
/>
</div>
);
};
π¨ Styling e Themingβ
1. Tailwind Configurationβ
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// ... other colors
},
},
},
plugins: [require("tailwindcss-animate")],
};
2. CSS Variables per Themingβ
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
/* ... */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
/* ... */
}
}
@layer components {
.container {
@apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8;
}
.page-header {
@apply flex items-center justify-between pb-4 border-b;
}
}
3. Component Variants con CVAβ
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
π Configurazione Appβ
1. Settings Contextβ
// contexts/settings-context.tsx
import { createContext, useContext, ReactNode } from 'react';
interface Settings {
maxFileSize: number;
supportedFileTypes: string[];
chunkingPresets: ChunkingPreset[];
defaultModel: string;
}
const SettingsContext = createContext<Settings | undefined>(undefined);
export const SettingsProvider = ({
children,
settings
}: {
children: ReactNode;
settings: Settings;
}) => {
return (
<SettingsContext.Provider value={settings}>
{children}
</SettingsContext.Provider>
);
};
export const useSettings = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within SettingsProvider');
}
return context;
};
2. Costanti UIβ
// lib/constants.ts
export const UI_CONSTANTS = {
// Testi UI statici in italiano
actions: {
save: "Salva",
cancel: "Annulla",
delete: "Elimina",
export: "Esporta",
},
labels: {
name: "Nome",
description: "Descrizione",
status: "Stato",
createdAt: "Creato il",
},
messages: {
success: {
saved: "Salvato con successo",
deleted: "Eliminato con successo",
},
error: {
notFound: "Non trovato",
generic: "Si Γ¨ verificato un errore",
},
},
placeholders: {
search: "Cerca...",
enterName: "Inserisci il nome",
},
};
3. Feature Flagsβ
// lib/feature-flags.ts
export const FEATURES = {
CHAT_ARTIFACTS: process.env.NEXT_PUBLIC_FEATURE_CHAT_ARTIFACTS === "true",
MCP_SUPPORT: process.env.NEXT_PUBLIC_FEATURE_MCP === "true",
ADVANCED_CHUNKING:
process.env.NEXT_PUBLIC_FEATURE_ADVANCED_CHUNKING === "true",
};
// Uso nei componenti
if (FEATURES.CHAT_ARTIFACTS) {
// Mostra funzionalitΓ artifacts
}
π‘οΈ Gestione Erroriβ
1. Error Boundariesβ
// components/feedback/error-boundary.tsx
import React from 'react';
import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary';
import { Button } from '@/components/ui/button';
interface ErrorFallbackProps {
error: Error;
resetErrorBoundary: () => void;
}
function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">
Si Γ¨ verificato un errore
</h2>
<p className="text-muted-foreground max-w-md">
Qualcosa Γ¨ andato storto. Riprova o contatta il supporto se il problema persiste.
</p>
</div>
{process.env.NODE_ENV === 'development' && (
<details className="text-left text-sm text-red-600 bg-red-50 p-4 rounded-md max-w-2xl">
<summary className="cursor-pointer">Error Details</summary>
<pre className="mt-2 whitespace-pre-wrap">{error.message}</pre>
<pre className="mt-2 whitespace-pre-wrap text-xs">{error.stack}</pre>
</details>
)}
<Button onClick={resetErrorBoundary}>
Riprova
</Button>
</div>
);
}
export const ErrorBoundary = ({ children }: { children: React.ReactNode }) => {
return (
<ReactErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, errorInfo) => {
console.error('Error caught by boundary:', error, errorInfo);
// Send to error reporting service
}}
>
{children}
</ReactErrorBoundary>
);
};
2. Toast Notificationsβ
// hooks/use-toast.ts (da Shadcn/ui)
import { toast } from "sonner";
import { UI_CONSTANTS } from "@/lib/constants";
export const useToast = () => {
return {
success: (message: string) => toast.success(message),
error: (message: string) => toast.error(message),
// Convenience methods
saveSuccess: () => toast.success(UI_CONSTANTS.messages.success.saved),
deleteSuccess: () => toast.success(UI_CONSTANTS.messages.success.deleted),
uploadSuccess: () => toast.success("Caricamento completato"),
saveError: () => toast.error("Impossibile salvare"),
deleteError: () => toast.error("Impossibile eliminare"),
uploadError: (error?: string) =>
toast.error(
error ? `Caricamento fallito: ${error}` : "Caricamento fallito",
),
};
};
π Server-Side Rendering (SSR)β
1. SSR-Safe Patternsβ
// β Problematico per SSR
const MyComponent = () => {
const width = window.innerWidth; // window non definito su server
return <div>Width: {width}</div>;
};
// β
SSR-Safe
const MyComponent = () => {
const [width, setWidth] = useState<number>(0);
useEffect(() => {
if (typeof window !== 'undefined') {
setWidth(window.innerWidth);
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}
}, []);
return <div>Width: {width || 'Loading...'}</div>;
};
2. Dynamic Imports per Component Browser-Onlyβ
import dynamic from 'next/dynamic';
// Component che usa API browser
const FileViewer = dynamic(() => import('./file-viewer'), {
ssr: false,
loading: () => <div>Loading viewer...</div>
});
// Hook SSR-safe
const useIsClient = () => {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
};
3. Next.js App Router Best Practicesβ
// app/dashboard/layout.tsx - Server Component
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen">
<aside className="w-64 bg-gray-100">
<Navigation />
</aside>
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
}
// app/dashboard/documents/page.tsx - Server Component
export default async function DocumentsPage() {
// Data fetching sul server
const documents = await fetchDocuments();
return (
<div className="p-6">
<h1>Documents</h1>
<DocumentList initialData={documents} />
</div>
);
}
// components/document/list.tsx - Client Component
'use client';
export const DocumentList = ({
initialData
}: {
initialData: Document[]
}) => {
const { documents } = useDocuments({
initialData, // SWR userΓ questi come dati iniziali
});
return (
<div>
{documents.map(doc => (
<DocumentCard key={doc.id} document={doc} />
))}
</div>
);
};
π§ͺ Testing Frontendβ
1. Unit Testing con Vitestβ
// components/__tests__/document-form.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { DocumentForm } from '../document/form';
import { vi } from 'vitest';
const mockOnSubmit = vi.fn();
describe('DocumentForm', () => {
it('should render form fields', () => {
render(
<DocumentForm
defaultValues={{}}
onSubmit={mockOnSubmit}
/>
);
expect(screen.getByLabelText(/nome documento/i)).toBeInTheDocument();
expect(screen.getByLabelText(/descrizione/i)).toBeInTheDocument();
});
it('should validate required fields', async () => {
render(
<DocumentForm
defaultValues={{}}
onSubmit={mockOnSubmit}
/>
);
const submitButton = screen.getByRole('button', { name: /salva/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/nome Γ¨ richiesto/i)).toBeInTheDocument();
});
});
it('should submit valid form', async () => {
render(
<DocumentForm
defaultValues={{}}
onSubmit={mockOnSubmit}
/>
);
const nameInput = screen.getByLabelText(/nome documento/i);
fireEvent.change(nameInput, { target: { value: 'Test Document' } });
const submitButton = screen.getByRole('button', { name: /salva/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
name: 'Test Document',
});
});
});
});
2. Integration Testingβ
// hooks/__tests__/use-documents.test.tsx
import { renderHook, waitFor } from "@testing-library/react";
import { useDocuments } from "../use-documents";
import { rest } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
rest.get("/api/v1/documents", (req, res, ctx) => {
return res(
ctx.json({
documents: [
{ id: "1", name: "Test Doc 1" },
{ id: "2", name: "Test Doc 2" },
],
total: 2,
}),
);
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("useDocuments", () => {
it("should fetch documents", async () => {
const { result } = renderHook(() => useDocuments());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.documents).toHaveLength(2);
expect(result.current.total).toBe(2);
});
});
π Performance Optimizationβ
1. Code Splittingβ
// Lazy loading di pagine pesanti
const DocumentEditor = dynamic(() => import('./document-editor'), {
loading: () => <EditorSkeleton />,
});
// Lazy loading di componenti condizionali
const AdminPanel = dynamic(() => import('./admin-panel'), {
ssr: false,
});
2. Memoizationβ
import { memo, useMemo, useCallback } from 'react';
export const DocumentCard = memo(({ document }: { document: Document }) => {
const formattedDate = useMemo(() =>
formatDate(document.createdAt),
[document.createdAt]
);
const handleClick = useCallback(() => {
// Handle click
}, [document.id]);
return (
<div onClick={handleClick}>
<h3>{document.name}</h3>
<p>{formattedDate}</p>
</div>
);
});
3. Bundle Analysisβ
# Analizza bundle size
pnpm build
pnpm dlx @next/bundle-analyzer
# Monitor performance in development
ANALYZE=true pnpm build
π§ Development Toolsβ
1. VS Code Setupβ
// .vscode/settings.json
{
"typescript.preferences.importModuleSpecifier": "relative",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.organizeImports": true
},
"emmet.includeLanguages": {
"typescript": "html",
"typescriptreact": "html"
},
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}
2. Debuggingβ
// React DevTools con display names
DocumentForm.displayName = "DocumentForm";
DocumentList.displayName = "DocumentList";
// Debug hooks
const useDebugValue = (value: any, formatter?: (value: any) => any) => {
if (process.env.NODE_ENV === "development") {
console.log("Debug:", formatter ? formatter(value) : value);
}
};
Questo setup frontend garantisce:
- β Type Safety: TypeScript completo con validazione Zod
- β Performance: Code splitting, memoization, bundle optimization
- β DX: Hot reload, debugging tools, error boundaries
- β Accessibility: Radix UI primitives, ARIA support
- β Configurazione: Settings context, feature flags, costanti UI
- β Testing: Unit e integration testing setup