📋 Commit iniziale con: - ✅ Documentazione unificata in docs/ - ✅ Codice Laravel in netgescon-laravel/ - ✅ Script automazione in scripts/ - ✅ Configurazione sync rsync - ✅ Struttura organizzata e pulita 🔄 Versione: 2025.07.19-1644 🎯 Sistema pronto per Git distribuito
1590 lines
43 KiB
Markdown
1590 lines
43 KiB
Markdown
# 🎨 **CAPITOLO 8 - FRONTEND E UX**
|
|
*Interfaccia Utente, JavaScript e User Experience*
|
|
|
|
**Versione:** 1.0
|
|
**Data:** 17 Luglio 2025
|
|
**Ambiente:** Laravel 11 + Bootstrap 5 + Alpine.js + Vite
|
|
|
|
---
|
|
|
|
## 📋 **INDICE CAPITOLO**
|
|
|
|
1. [**Architettura Frontend**](#1-architettura-frontend)
|
|
2. [**Stack Tecnologico**](#2-stack-tecnologico)
|
|
3. [**Componenti UI**](#3-componenti-ui)
|
|
4. [**JavaScript e Interattività**](#4-javascript-e-interattività)
|
|
5. [**Responsive Design**](#5-responsive-design)
|
|
6. [**Performance e Ottimizzazione**](#6-performance-e-ottimizzazione)
|
|
7. [**Gestione Stati**](#7-gestione-stati)
|
|
8. [**Validazione Frontend**](#8-validazione-frontend)
|
|
9. [**Esempi Pratici**](#9-esempi-pratici)
|
|
10. [**Best Practices**](#10-best-practices)
|
|
|
|
---
|
|
|
|
## 1. **ARCHITETTURA FRONTEND**
|
|
|
|
### 1.1 **Struttura Generale**
|
|
|
|
```
|
|
resources/
|
|
├── js/
|
|
│ ├── app.js # Entry point principale
|
|
│ ├── bootstrap.js # Configurazione librerie
|
|
│ ├── components/ # Componenti riutilizzabili
|
|
│ │ ├── forms/ # Componenti form
|
|
│ │ ├── tables/ # Componenti tabelle
|
|
│ │ ├── modals/ # Componenti modal
|
|
│ │ └── charts/ # Componenti grafici
|
|
│ ├── modules/ # Moduli specifici
|
|
│ │ ├── auth/ # Autenticazione
|
|
│ │ ├── dashboard/ # Dashboard
|
|
│ │ ├── condominiums/ # Gestione condomini
|
|
│ │ └── accounting/ # Contabilità
|
|
│ └── utils/ # Utility e helper
|
|
├── css/
|
|
│ ├── app.css # Stili principali
|
|
│ ├── components/ # Stili componenti
|
|
│ └── themes/ # Temi personalizzati
|
|
└── views/
|
|
├── layouts/ # Layout principali
|
|
├── components/ # Blade components
|
|
└── pages/ # Pagine specifiche
|
|
```
|
|
|
|
### 1.2 **Principi Architetturali**
|
|
|
|
#### **Modularità**
|
|
```javascript
|
|
// Esempio struttura modulare
|
|
const NetGescon = {
|
|
modules: {
|
|
auth: AuthModule,
|
|
dashboard: DashboardModule,
|
|
condominiums: CondominiumsModule,
|
|
accounting: AccountingModule
|
|
},
|
|
|
|
init() {
|
|
// Inizializzazione globale
|
|
this.initializeModules();
|
|
this.setupEventListeners();
|
|
this.configureAjax();
|
|
},
|
|
|
|
initializeModules() {
|
|
Object.keys(this.modules).forEach(key => {
|
|
if (this.modules[key].init) {
|
|
this.modules[key].init();
|
|
}
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
#### **Component-Based**
|
|
```javascript
|
|
// Componente base riutilizzabile
|
|
class BaseComponent {
|
|
constructor(selector, options = {}) {
|
|
this.element = document.querySelector(selector);
|
|
this.options = { ...this.defaults, ...options };
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.bindEvents();
|
|
this.render();
|
|
}
|
|
|
|
bindEvents() {
|
|
// Override in sottoclassi
|
|
}
|
|
|
|
render() {
|
|
// Override in sottoclassi
|
|
}
|
|
|
|
destroy() {
|
|
// Cleanup
|
|
this.element.removeEventListener();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2. **STACK TECNOLOGICO**
|
|
|
|
### 2.1 **Frontend Stack**
|
|
|
|
#### **Framework CSS**
|
|
```json
|
|
// package.json
|
|
{
|
|
"devDependencies": {
|
|
"bootstrap": "^5.3.0",
|
|
"sass": "^1.77.8",
|
|
"@fortawesome/fontawesome-free": "^6.5.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### **JavaScript Libraries**
|
|
```json
|
|
{
|
|
"dependencies": {
|
|
"alpinejs": "^3.13.0",
|
|
"axios": "^1.6.0",
|
|
"chart.js": "^4.4.0",
|
|
"datatables.net": "^1.13.0",
|
|
"sweetalert2": "^11.10.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2.2 **Build Tools (Vite)**
|
|
|
|
#### **Configurazione Vite**
|
|
```javascript
|
|
// vite.config.js
|
|
import { defineConfig } from 'vite';
|
|
import laravel from 'laravel-vite-plugin';
|
|
|
|
export default defineConfig({
|
|
plugins: [
|
|
laravel({
|
|
input: [
|
|
'resources/css/app.css',
|
|
'resources/js/app.js',
|
|
],
|
|
refresh: true,
|
|
}),
|
|
],
|
|
resolve: {
|
|
alias: {
|
|
'@': '/resources/js',
|
|
'~': '/resources/css',
|
|
},
|
|
},
|
|
build: {
|
|
rollupOptions: {
|
|
output: {
|
|
manualChunks: {
|
|
vendor: ['bootstrap', 'axios', 'alpinejs'],
|
|
charts: ['chart.js'],
|
|
tables: ['datatables.net'],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
### 2.3 **Asset Management**
|
|
|
|
#### **CSS Structure**
|
|
```scss
|
|
// resources/css/app.scss
|
|
@import 'bootstrap';
|
|
@import '@fortawesome/fontawesome-free/css/all.css';
|
|
|
|
// Custom variables
|
|
@import 'variables';
|
|
|
|
// Core styles
|
|
@import 'components/base';
|
|
@import 'components/forms';
|
|
@import 'components/tables';
|
|
@import 'components/modals';
|
|
@import 'components/dashboard';
|
|
|
|
// Theme styles
|
|
@import 'themes/default';
|
|
@import 'themes/dark';
|
|
```
|
|
|
|
---
|
|
|
|
## 3. **COMPONENTI UI**
|
|
|
|
### 3.1 **Componenti Base**
|
|
|
|
#### **Button Component**
|
|
```html
|
|
<!-- resources/views/components/button.blade.php -->
|
|
@php
|
|
$classes = [
|
|
'btn',
|
|
'btn-' . ($variant ?? 'primary'),
|
|
$size ? 'btn-' . $size : '',
|
|
$disabled ? 'disabled' : '',
|
|
$loading ? 'btn-loading' : '',
|
|
$attributes->get('class')
|
|
];
|
|
@endphp
|
|
|
|
<button
|
|
{{ $attributes->merge(['class' => implode(' ', array_filter($classes))]) }}
|
|
@if($loading) disabled @endif
|
|
>
|
|
@if($loading)
|
|
<span class="spinner-border spinner-border-sm me-2"></span>
|
|
@endif
|
|
|
|
@if($icon)
|
|
<i class="fas fa-{{ $icon }} me-2"></i>
|
|
@endif
|
|
|
|
{{ $slot }}
|
|
</button>
|
|
```
|
|
|
|
#### **Form Input Component**
|
|
```html
|
|
<!-- resources/views/components/form/input.blade.php -->
|
|
<div class="mb-3">
|
|
@if($label)
|
|
<label for="{{ $id ?? $name }}" class="form-label">
|
|
{{ $label }}
|
|
@if($required)
|
|
<span class="text-danger">*</span>
|
|
@endif
|
|
</label>
|
|
@endif
|
|
|
|
<input
|
|
type="{{ $type ?? 'text' }}"
|
|
name="{{ $name }}"
|
|
id="{{ $id ?? $name }}"
|
|
class="form-control @error($name) is-invalid @enderror"
|
|
value="{{ old($name, $value ?? '') }}"
|
|
@if($required) required @endif
|
|
{{ $attributes }}
|
|
>
|
|
|
|
@error($name)
|
|
<div class="invalid-feedback">
|
|
{{ $message }}
|
|
</div>
|
|
@enderror
|
|
|
|
@if($help)
|
|
<div class="form-text">{{ $help }}</div>
|
|
@endif
|
|
</div>
|
|
```
|
|
|
|
### 3.2 **Componenti Avanzati**
|
|
|
|
#### **DataTable Component**
|
|
```html
|
|
<!-- resources/views/components/datatable.blade.php -->
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover" id="{{ $id }}">
|
|
<thead class="table-dark">
|
|
<tr>
|
|
@foreach($columns as $column)
|
|
<th>{{ $column['title'] }}</th>
|
|
@endforeach
|
|
@if($actions)
|
|
<th width="150">Azioni</th>
|
|
@endif
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{ $slot }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
$('#{{ $id }}').DataTable({
|
|
processing: true,
|
|
serverSide: true,
|
|
ajax: '{{ $ajax }}',
|
|
columns: @json($columns),
|
|
language: {
|
|
url: '//cdn.datatables.net/plug-ins/1.13.7/i18n/it-IT.json'
|
|
},
|
|
responsive: true,
|
|
dom: 'Bfrtip',
|
|
buttons: ['copy', 'excel', 'pdf', 'print']
|
|
});
|
|
});
|
|
</script>
|
|
```
|
|
|
|
#### **Modal Component**
|
|
```html
|
|
<!-- resources/views/components/modal.blade.php -->
|
|
<div class="modal fade" id="{{ $id }}" tabindex="-1">
|
|
<div class="modal-dialog {{ $size ? 'modal-' . $size : '' }}">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">{{ $title }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
{{ $slot }}
|
|
</div>
|
|
@if($footer)
|
|
<div class="modal-footer">
|
|
{{ $footer }}
|
|
</div>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 4. **JAVASCRIPT E INTERATTIVITÀ**
|
|
|
|
### 4.1 **Gestione AJAX**
|
|
|
|
#### **Configurazione Axios**
|
|
```javascript
|
|
// resources/js/bootstrap.js
|
|
import axios from 'axios';
|
|
|
|
// Configurazione globale
|
|
window.axios = axios;
|
|
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
|
|
|
// CSRF Token
|
|
let token = document.head.querySelector('meta[name="csrf-token"]');
|
|
if (token) {
|
|
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
|
|
}
|
|
|
|
// Interceptors
|
|
axios.interceptors.request.use(request => {
|
|
// Mostra loading
|
|
showLoading();
|
|
return request;
|
|
});
|
|
|
|
axios.interceptors.response.use(
|
|
response => {
|
|
hideLoading();
|
|
return response;
|
|
},
|
|
error => {
|
|
hideLoading();
|
|
handleError(error);
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
```
|
|
|
|
#### **Form Handler**
|
|
```javascript
|
|
// resources/js/components/forms/FormHandler.js
|
|
class FormHandler {
|
|
constructor(formSelector, options = {}) {
|
|
this.form = document.querySelector(formSelector);
|
|
this.options = {
|
|
showToast: true,
|
|
resetOnSuccess: true,
|
|
...options
|
|
};
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.form.addEventListener('submit', this.handleSubmit.bind(this));
|
|
}
|
|
|
|
async handleSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(this.form);
|
|
const url = this.form.action;
|
|
const method = this.form.method;
|
|
|
|
try {
|
|
const response = await axios({
|
|
method,
|
|
url,
|
|
data: formData,
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data'
|
|
}
|
|
});
|
|
|
|
this.handleSuccess(response.data);
|
|
} catch (error) {
|
|
this.handleError(error);
|
|
}
|
|
}
|
|
|
|
handleSuccess(data) {
|
|
if (this.options.showToast) {
|
|
Toast.success(data.message || 'Operazione completata');
|
|
}
|
|
|
|
if (this.options.resetOnSuccess) {
|
|
this.form.reset();
|
|
}
|
|
|
|
if (this.options.onSuccess) {
|
|
this.options.onSuccess(data);
|
|
}
|
|
}
|
|
|
|
handleError(error) {
|
|
if (error.response?.status === 422) {
|
|
this.showValidationErrors(error.response.data.errors);
|
|
} else {
|
|
Toast.error(error.response?.data?.message || 'Errore durante l\'operazione');
|
|
}
|
|
}
|
|
|
|
showValidationErrors(errors) {
|
|
Object.keys(errors).forEach(field => {
|
|
const input = this.form.querySelector(`[name="${field}"]`);
|
|
if (input) {
|
|
input.classList.add('is-invalid');
|
|
|
|
let feedback = input.parentNode.querySelector('.invalid-feedback');
|
|
if (!feedback) {
|
|
feedback = document.createElement('div');
|
|
feedback.className = 'invalid-feedback';
|
|
input.parentNode.appendChild(feedback);
|
|
}
|
|
|
|
feedback.textContent = errors[field][0];
|
|
}
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.2 **Alpine.js Integration**
|
|
|
|
#### **Dashboard Component**
|
|
```javascript
|
|
// resources/js/components/dashboard/DashboardStats.js
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('dashboardStats', () => ({
|
|
stats: {},
|
|
loading: true,
|
|
|
|
async init() {
|
|
await this.loadStats();
|
|
this.setupRealTimeUpdates();
|
|
},
|
|
|
|
async loadStats() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await axios.get('/api/dashboard/stats');
|
|
this.stats = response.data;
|
|
} catch (error) {
|
|
console.error('Errore caricamento statistiche:', error);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
setupRealTimeUpdates() {
|
|
// Aggiorna ogni 30 secondi
|
|
setInterval(() => {
|
|
this.loadStats();
|
|
}, 30000);
|
|
},
|
|
|
|
formatCurrency(amount) {
|
|
return new Intl.NumberFormat('it-IT', {
|
|
style: 'currency',
|
|
currency: 'EUR'
|
|
}).format(amount);
|
|
}
|
|
}));
|
|
});
|
|
```
|
|
|
|
#### **Template Usage**
|
|
```html
|
|
<!-- resources/views/dashboard.blade.php -->
|
|
<div x-data="dashboardStats">
|
|
<div class="row" x-show="!loading">
|
|
<div class="col-md-3">
|
|
<div class="card bg-primary text-white">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Condomini Attivi</h5>
|
|
<h2 x-text="stats.active_condominiums"></h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-3">
|
|
<div class="card bg-success text-white">
|
|
<div class="card-body">
|
|
<h5 class="card-title">Fatturato Mensile</h5>
|
|
<h2 x-text="formatCurrency(stats.monthly_revenue)"></h2>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="loading" class="text-center p-4">
|
|
<div class="spinner-border" role="status">
|
|
<span class="visually-hidden">Caricamento...</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 5. **RESPONSIVE DESIGN**
|
|
|
|
### 5.1 **Breakpoints Strategy**
|
|
|
|
#### **Custom Breakpoints**
|
|
```scss
|
|
// resources/css/variables.scss
|
|
$grid-breakpoints: (
|
|
xs: 0,
|
|
sm: 576px,
|
|
md: 768px,
|
|
lg: 992px,
|
|
xl: 1200px,
|
|
xxl: 1400px
|
|
);
|
|
|
|
// Mixins personalizzati
|
|
@mixin mobile-only {
|
|
@media (max-width: 767px) {
|
|
@content;
|
|
}
|
|
}
|
|
|
|
@mixin tablet-only {
|
|
@media (min-width: 768px) and (max-width: 991px) {
|
|
@content;
|
|
}
|
|
}
|
|
|
|
@mixin desktop-only {
|
|
@media (min-width: 992px) {
|
|
@content;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 5.2 **Responsive Components**
|
|
|
|
#### **Responsive Table**
|
|
```html
|
|
<!-- resources/views/components/responsive-table.blade.php -->
|
|
<div class="table-responsive">
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
@foreach($columns as $column)
|
|
<th class="{{ $column['responsive'] ?? '' }}">
|
|
{{ $column['title'] }}
|
|
</th>
|
|
@endforeach
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{ $slot }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<style>
|
|
@media (max-width: 768px) {
|
|
.d-none-mobile {
|
|
display: none !important;
|
|
}
|
|
|
|
.table-responsive {
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.table td {
|
|
padding: 0.5rem;
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
#### **Mobile Navigation**
|
|
```html
|
|
<!-- resources/views/layouts/partials/mobile-nav.blade.php -->
|
|
<div class="mobile-nav d-lg-none">
|
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand" href="{{ route('dashboard') }}">
|
|
NetGescon
|
|
</a>
|
|
|
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mobileNav">
|
|
<span class="navbar-toggler-icon"></span>
|
|
</button>
|
|
|
|
<div class="collapse navbar-collapse" id="mobileNav">
|
|
<ul class="navbar-nav">
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="{{ route('dashboard') }}">
|
|
<i class="fas fa-home"></i> Dashboard
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="{{ route('condominiums.index') }}">
|
|
<i class="fas fa-building"></i> Condomini
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" href="{{ route('accounting.index') }}">
|
|
<i class="fas fa-euro-sign"></i> Contabilità
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
```
|
|
|
|
---
|
|
|
|
## 6. **PERFORMANCE E OTTIMIZZAZIONE**
|
|
|
|
### 6.1 **Lazy Loading**
|
|
|
|
#### **Image Lazy Loading**
|
|
```javascript
|
|
// resources/js/utils/LazyLoader.js
|
|
class LazyLoader {
|
|
constructor() {
|
|
this.images = document.querySelectorAll('img[data-src]');
|
|
this.imageObserver = new IntersectionObserver(this.handleIntersection.bind(this));
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.images.forEach(img => {
|
|
this.imageObserver.observe(img);
|
|
});
|
|
}
|
|
|
|
handleIntersection(entries) {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
img.src = img.dataset.src;
|
|
img.classList.remove('lazy');
|
|
this.imageObserver.unobserve(img);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Inizializzazione
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new LazyLoader();
|
|
});
|
|
```
|
|
|
|
#### **Component Lazy Loading**
|
|
```javascript
|
|
// resources/js/utils/ComponentLoader.js
|
|
class ComponentLoader {
|
|
static async loadComponent(componentName) {
|
|
const module = await import(`../components/${componentName}.js`);
|
|
return module.default;
|
|
}
|
|
|
|
static async loadOnDemand(selector, componentName) {
|
|
const elements = document.querySelectorAll(selector);
|
|
|
|
if (elements.length > 0) {
|
|
const Component = await this.loadComponent(componentName);
|
|
|
|
elements.forEach(el => {
|
|
new Component(el);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 6.2 **Bundle Optimization**
|
|
|
|
#### **Code Splitting**
|
|
```javascript
|
|
// resources/js/app.js
|
|
import './bootstrap';
|
|
|
|
// Core components caricati immediatamente
|
|
import './components/base/Navigation';
|
|
import './components/base/Toast';
|
|
|
|
// Lazy loading per componenti specifici
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Carica componenti solo se necessari
|
|
if (document.querySelector('[data-component="chart"]')) {
|
|
const { default: ChartComponent } = await import('./components/charts/ChartComponent');
|
|
new ChartComponent();
|
|
}
|
|
|
|
if (document.querySelector('[data-component="datatable"]')) {
|
|
const { default: DataTableComponent } = await import('./components/tables/DataTableComponent');
|
|
new DataTableComponent();
|
|
}
|
|
});
|
|
```
|
|
|
|
### 6.3 **Caching Strategy**
|
|
|
|
#### **Service Worker**
|
|
```javascript
|
|
// public/sw.js
|
|
const CACHE_NAME = 'netgescon-v1';
|
|
const urlsToCache = [
|
|
'/',
|
|
'/css/app.css',
|
|
'/js/app.js',
|
|
'/images/logo.png'
|
|
];
|
|
|
|
self.addEventListener('install', event => {
|
|
event.waitUntil(
|
|
caches.open(CACHE_NAME)
|
|
.then(cache => cache.addAll(urlsToCache))
|
|
);
|
|
});
|
|
|
|
self.addEventListener('fetch', event => {
|
|
event.respondWith(
|
|
caches.match(event.request)
|
|
.then(response => {
|
|
if (response) {
|
|
return response;
|
|
}
|
|
return fetch(event.request);
|
|
})
|
|
);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 7. **GESTIONE STATI**
|
|
|
|
### 7.1 **State Management**
|
|
|
|
#### **Simple State Manager**
|
|
```javascript
|
|
// resources/js/utils/StateManager.js
|
|
class StateManager {
|
|
constructor() {
|
|
this.state = {};
|
|
this.subscribers = {};
|
|
}
|
|
|
|
setState(key, value) {
|
|
const oldValue = this.state[key];
|
|
this.state[key] = value;
|
|
|
|
if (this.subscribers[key]) {
|
|
this.subscribers[key].forEach(callback => {
|
|
callback(value, oldValue);
|
|
});
|
|
}
|
|
}
|
|
|
|
getState(key) {
|
|
return this.state[key];
|
|
}
|
|
|
|
subscribe(key, callback) {
|
|
if (!this.subscribers[key]) {
|
|
this.subscribers[key] = [];
|
|
}
|
|
this.subscribers[key].push(callback);
|
|
}
|
|
|
|
unsubscribe(key, callback) {
|
|
if (this.subscribers[key]) {
|
|
this.subscribers[key] = this.subscribers[key].filter(cb => cb !== callback);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Istanza globale
|
|
window.StateManager = new StateManager();
|
|
```
|
|
|
|
#### **Usage Example**
|
|
```javascript
|
|
// Componente che usa lo state
|
|
class UserProfile {
|
|
constructor() {
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
// Sottoscrizione ai cambiamenti
|
|
StateManager.subscribe('user', this.updateProfile.bind(this));
|
|
|
|
// Caricamento dati utente
|
|
this.loadUserData();
|
|
}
|
|
|
|
async loadUserData() {
|
|
try {
|
|
const response = await axios.get('/api/user');
|
|
StateManager.setState('user', response.data);
|
|
} catch (error) {
|
|
console.error('Errore caricamento utente:', error);
|
|
}
|
|
}
|
|
|
|
updateProfile(userData) {
|
|
document.querySelector('#user-name').textContent = userData.name;
|
|
document.querySelector('#user-email').textContent = userData.email;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.2 **Form State**
|
|
|
|
#### **Form State Manager**
|
|
```javascript
|
|
// resources/js/components/forms/FormState.js
|
|
class FormState {
|
|
constructor(formElement) {
|
|
this.form = formElement;
|
|
this.initialData = this.getFormData();
|
|
this.currentData = { ...this.initialData };
|
|
this.isDirty = false;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.form.addEventListener('input', this.handleInput.bind(this));
|
|
this.form.addEventListener('change', this.handleChange.bind(this));
|
|
|
|
// Avviso prima di lasciare la pagina se ci sono modifiche
|
|
window.addEventListener('beforeunload', this.handleBeforeUnload.bind(this));
|
|
}
|
|
|
|
getFormData() {
|
|
const formData = new FormData(this.form);
|
|
const data = {};
|
|
|
|
for (let [key, value] of formData.entries()) {
|
|
data[key] = value;
|
|
}
|
|
|
|
return data;
|
|
}
|
|
|
|
handleInput(e) {
|
|
this.currentData[e.target.name] = e.target.value;
|
|
this.checkDirty();
|
|
}
|
|
|
|
handleChange(e) {
|
|
this.currentData[e.target.name] = e.target.value;
|
|
this.checkDirty();
|
|
}
|
|
|
|
checkDirty() {
|
|
this.isDirty = JSON.stringify(this.currentData) !== JSON.stringify(this.initialData);
|
|
this.updateUI();
|
|
}
|
|
|
|
updateUI() {
|
|
const saveButton = this.form.querySelector('[type="submit"]');
|
|
if (saveButton) {
|
|
saveButton.disabled = !this.isDirty;
|
|
}
|
|
}
|
|
|
|
handleBeforeUnload(e) {
|
|
if (this.isDirty) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
}
|
|
|
|
reset() {
|
|
this.currentData = { ...this.initialData };
|
|
this.isDirty = false;
|
|
this.updateUI();
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 8. **VALIDAZIONE FRONTEND**
|
|
|
|
### 8.1 **Validation Rules**
|
|
|
|
#### **Validator Class**
|
|
```javascript
|
|
// resources/js/utils/Validator.js
|
|
class Validator {
|
|
constructor() {
|
|
this.rules = {
|
|
required: (value) => value !== null && value !== undefined && value !== '',
|
|
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
|
minLength: (value, min) => value.length >= min,
|
|
maxLength: (value, max) => value.length <= max,
|
|
numeric: (value) => /^\d+$/.test(value),
|
|
alpha: (value) => /^[a-zA-Z]+$/.test(value),
|
|
alphanumeric: (value) => /^[a-zA-Z0-9]+$/.test(value),
|
|
phone: (value) => /^[+]?[\d\s\-\(\)]{8,}$/.test(value),
|
|
fiscal_code: (value) => /^[A-Z]{6}[0-9]{2}[A-Z][0-9]{2}[A-Z][0-9]{3}[A-Z]$/i.test(value),
|
|
vat_number: (value) => /^[0-9]{11}$/.test(value)
|
|
};
|
|
|
|
this.messages = {
|
|
required: 'Questo campo è obbligatorio',
|
|
email: 'Inserire un indirizzo email valido',
|
|
minLength: 'Minimo {min} caratteri',
|
|
maxLength: 'Massimo {max} caratteri',
|
|
numeric: 'Inserire solo numeri',
|
|
alpha: 'Inserire solo lettere',
|
|
alphanumeric: 'Inserire solo lettere e numeri',
|
|
phone: 'Inserire un numero di telefono valido',
|
|
fiscal_code: 'Inserire un codice fiscale valido',
|
|
vat_number: 'Inserire una partita IVA valida'
|
|
};
|
|
}
|
|
|
|
validate(value, rules) {
|
|
const errors = [];
|
|
|
|
rules.forEach(rule => {
|
|
const [ruleName, ...params] = rule.split(':');
|
|
const ruleFunction = this.rules[ruleName];
|
|
|
|
if (ruleFunction && !ruleFunction(value, ...params)) {
|
|
let message = this.messages[ruleName];
|
|
|
|
// Sostituisci parametri nel messaggio
|
|
params.forEach((param, index) => {
|
|
message = message.replace(`{${Object.keys(this.rules)[index]}}`, param);
|
|
});
|
|
|
|
errors.push(message);
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
|
|
validateForm(formElement) {
|
|
const errors = {};
|
|
const inputs = formElement.querySelectorAll('[data-validate]');
|
|
|
|
inputs.forEach(input => {
|
|
const rules = input.dataset.validate.split('|');
|
|
const fieldErrors = this.validate(input.value, rules);
|
|
|
|
if (fieldErrors.length > 0) {
|
|
errors[input.name] = fieldErrors;
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 8.2 **Real-time Validation**
|
|
|
|
#### **Live Validator**
|
|
```javascript
|
|
// resources/js/components/forms/LiveValidator.js
|
|
class LiveValidator {
|
|
constructor(formElement) {
|
|
this.form = formElement;
|
|
this.validator = new Validator();
|
|
this.debounceTime = 300;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
const inputs = this.form.querySelectorAll('[data-validate]');
|
|
|
|
inputs.forEach(input => {
|
|
input.addEventListener('input', this.debounce(
|
|
this.validateField.bind(this, input),
|
|
this.debounceTime
|
|
));
|
|
|
|
input.addEventListener('blur', this.validateField.bind(this, input));
|
|
});
|
|
|
|
this.form.addEventListener('submit', this.validateForm.bind(this));
|
|
}
|
|
|
|
validateField(input) {
|
|
const rules = input.dataset.validate.split('|');
|
|
const errors = this.validator.validate(input.value, rules);
|
|
|
|
this.showFieldErrors(input, errors);
|
|
}
|
|
|
|
validateForm(e) {
|
|
const errors = this.validator.validateForm(this.form);
|
|
|
|
if (Object.keys(errors).length > 0) {
|
|
e.preventDefault();
|
|
this.showFormErrors(errors);
|
|
}
|
|
}
|
|
|
|
showFieldErrors(input, errors) {
|
|
const errorContainer = input.parentNode.querySelector('.validation-errors');
|
|
|
|
if (errors.length > 0) {
|
|
input.classList.add('is-invalid');
|
|
|
|
if (errorContainer) {
|
|
errorContainer.innerHTML = errors.map(error =>
|
|
`<div class="invalid-feedback d-block">${error}</div>`
|
|
).join('');
|
|
}
|
|
} else {
|
|
input.classList.remove('is-invalid');
|
|
input.classList.add('is-valid');
|
|
|
|
if (errorContainer) {
|
|
errorContainer.innerHTML = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
showFormErrors(errors) {
|
|
Object.keys(errors).forEach(fieldName => {
|
|
const input = this.form.querySelector(`[name="${fieldName}"]`);
|
|
if (input) {
|
|
this.showFieldErrors(input, errors[fieldName]);
|
|
}
|
|
});
|
|
}
|
|
|
|
debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 9. **ESEMPI PRATICI**
|
|
|
|
### 9.1 **Condominium Form**
|
|
|
|
#### **Complete Form Implementation**
|
|
```html
|
|
<!-- resources/views/condominiums/create.blade.php -->
|
|
<form id="condominiumForm" action="{{ route('condominiums.store') }}" method="POST"
|
|
x-data="condominiumForm" @submit.prevent="submitForm">
|
|
@csrf
|
|
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<x-form.input
|
|
name="name"
|
|
label="Nome Condominio"
|
|
data-validate="required|minLength:3"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<x-form.input
|
|
name="fiscal_code"
|
|
label="Codice Fiscale"
|
|
data-validate="required|fiscal_code"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-8">
|
|
<x-form.input
|
|
name="address"
|
|
label="Indirizzo"
|
|
data-validate="required|minLength:5"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="col-md-4">
|
|
<x-form.input
|
|
name="postal_code"
|
|
label="CAP"
|
|
data-validate="required|numeric|minLength:5|maxLength:5"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<select name="city" class="form-select" data-validate="required" required>
|
|
<option value="">Seleziona città</option>
|
|
<option value="milano">Milano</option>
|
|
<option value="roma">Roma</option>
|
|
<option value="napoli">Napoli</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<x-form.input
|
|
name="phone"
|
|
label="Telefono"
|
|
data-validate="phone"
|
|
type="tel"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="d-flex justify-content-end">
|
|
<button type="button" class="btn btn-secondary me-2" @click="resetForm">
|
|
Annulla
|
|
</button>
|
|
<x-button type="submit" :loading="submitting">
|
|
Salva Condominio
|
|
</x-button>
|
|
</div>
|
|
</form>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('condominiumForm', () => ({
|
|
submitting: false,
|
|
|
|
async submitForm() {
|
|
this.submitting = true;
|
|
|
|
try {
|
|
const formData = new FormData(this.$el);
|
|
const response = await axios.post(this.$el.action, formData);
|
|
|
|
Toast.success('Condominio salvato con successo');
|
|
window.location.href = '/condominiums';
|
|
} catch (error) {
|
|
if (error.response.status === 422) {
|
|
this.showValidationErrors(error.response.data.errors);
|
|
} else {
|
|
Toast.error('Errore durante il salvataggio');
|
|
}
|
|
} finally {
|
|
this.submitting = false;
|
|
}
|
|
},
|
|
|
|
resetForm() {
|
|
this.$el.reset();
|
|
this.$el.querySelectorAll('.is-invalid').forEach(el => {
|
|
el.classList.remove('is-invalid');
|
|
});
|
|
},
|
|
|
|
showValidationErrors(errors) {
|
|
Object.keys(errors).forEach(field => {
|
|
const input = this.$el.querySelector(`[name="${field}"]`);
|
|
if (input) {
|
|
input.classList.add('is-invalid');
|
|
|
|
let feedback = input.parentNode.querySelector('.invalid-feedback');
|
|
if (!feedback) {
|
|
feedback = document.createElement('div');
|
|
feedback.className = 'invalid-feedback';
|
|
input.parentNode.appendChild(feedback);
|
|
}
|
|
|
|
feedback.textContent = errors[field][0];
|
|
}
|
|
});
|
|
}
|
|
}));
|
|
});
|
|
|
|
// Inizializza validazione live
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
new LiveValidator(document.getElementById('condominiumForm'));
|
|
});
|
|
</script>
|
|
```
|
|
|
|
### 9.2 **Dashboard Charts**
|
|
|
|
#### **Chart Component**
|
|
```html
|
|
<!-- resources/views/dashboard/charts.blade.php -->
|
|
<div class="row">
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5>Fatturato Mensile</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="revenueChart" width="400" height="200"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="col-md-6">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5>Distribuzione Condomini</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<canvas id="condominiumsChart" width="400" height="200"></canvas>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Carica dati charts
|
|
const response = await axios.get('/api/dashboard/charts');
|
|
const data = response.data;
|
|
|
|
// Revenue Chart
|
|
const revenueCtx = document.getElementById('revenueChart').getContext('2d');
|
|
new Chart(revenueCtx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: data.revenue.labels,
|
|
datasets: [{
|
|
label: 'Fatturato €',
|
|
data: data.revenue.values,
|
|
borderColor: 'rgb(75, 192, 192)',
|
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
|
tension: 0.1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
title: {
|
|
display: true,
|
|
text: 'Fatturato Mensile'
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: {
|
|
callback: function(value) {
|
|
return '€' + value.toLocaleString();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Condominiums Chart
|
|
const condominiumsCtx = document.getElementById('condominiumsChart').getContext('2d');
|
|
new Chart(condominiumsCtx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: data.condominiums.labels,
|
|
datasets: [{
|
|
data: data.condominiums.values,
|
|
backgroundColor: [
|
|
'#FF6384',
|
|
'#36A2EB',
|
|
'#FFCE56',
|
|
'#4BC0C0',
|
|
'#9966FF'
|
|
]
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom'
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 10. **BEST PRACTICES**
|
|
|
|
### 10.1 **Code Organization**
|
|
|
|
#### **Module Pattern**
|
|
```javascript
|
|
// resources/js/modules/condominium/CondominiumModule.js
|
|
const CondominiumModule = (() => {
|
|
// Private variables
|
|
let initialized = false;
|
|
let currentCondominium = null;
|
|
|
|
// Private methods
|
|
const loadCondominium = async (id) => {
|
|
try {
|
|
const response = await axios.get(`/api/condominiums/${id}`);
|
|
currentCondominium = response.data;
|
|
return currentCondominium;
|
|
} catch (error) {
|
|
console.error('Error loading condominium:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const updateUI = (condominium) => {
|
|
document.querySelector('#condominium-name').textContent = condominium.name;
|
|
document.querySelector('#condominium-address').textContent = condominium.address;
|
|
};
|
|
|
|
// Public API
|
|
return {
|
|
init() {
|
|
if (initialized) return;
|
|
|
|
this.bindEvents();
|
|
initialized = true;
|
|
},
|
|
|
|
bindEvents() {
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.matches('[data-action="load-condominium"]')) {
|
|
const id = e.target.dataset.condominiumId;
|
|
this.loadAndDisplay(id);
|
|
}
|
|
});
|
|
},
|
|
|
|
async loadAndDisplay(id) {
|
|
try {
|
|
const condominium = await loadCondominium(id);
|
|
updateUI(condominium);
|
|
} catch (error) {
|
|
Toast.error('Errore caricamento condominio');
|
|
}
|
|
},
|
|
|
|
getCurrentCondominium() {
|
|
return currentCondominium;
|
|
}
|
|
};
|
|
})();
|
|
```
|
|
|
|
### 10.2 **Error Handling**
|
|
|
|
#### **Global Error Handler**
|
|
```javascript
|
|
// resources/js/utils/ErrorHandler.js
|
|
class ErrorHandler {
|
|
static handle(error, context = '') {
|
|
console.error(`[${context}] Error:`, error);
|
|
|
|
if (error.response) {
|
|
// Server response error
|
|
this.handleServerError(error.response);
|
|
} else if (error.request) {
|
|
// Network error
|
|
this.handleNetworkError();
|
|
} else {
|
|
// Generic error
|
|
this.handleGenericError(error.message);
|
|
}
|
|
}
|
|
|
|
static handleServerError(response) {
|
|
switch (response.status) {
|
|
case 401:
|
|
Toast.error('Sessione scaduta. Effettuare nuovamente il login.');
|
|
setTimeout(() => {
|
|
window.location.href = '/login';
|
|
}, 2000);
|
|
break;
|
|
|
|
case 403:
|
|
Toast.error('Non hai i permessi per eseguire questa operazione.');
|
|
break;
|
|
|
|
case 404:
|
|
Toast.error('Risorsa non trovata.');
|
|
break;
|
|
|
|
case 422:
|
|
// Validation errors - handled by form components
|
|
break;
|
|
|
|
case 500:
|
|
Toast.error('Errore interno del server. Riprova più tardi.');
|
|
break;
|
|
|
|
default:
|
|
Toast.error(response.data.message || 'Errore sconosciuto');
|
|
}
|
|
}
|
|
|
|
static handleNetworkError() {
|
|
Toast.error('Errore di connessione. Controlla la tua connessione internet.');
|
|
}
|
|
|
|
static handleGenericError(message) {
|
|
Toast.error(message || 'Si è verificato un errore imprevisto.');
|
|
}
|
|
}
|
|
|
|
// Setup global error handling
|
|
window.addEventListener('error', (e) => {
|
|
ErrorHandler.handle(e.error, 'Global');
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (e) => {
|
|
ErrorHandler.handle(e.reason, 'Promise');
|
|
});
|
|
```
|
|
|
|
### 10.3 **Performance Tips**
|
|
|
|
#### **Optimization Checklist**
|
|
```javascript
|
|
// resources/js/utils/Performance.js
|
|
class PerformanceOptimizer {
|
|
static init() {
|
|
this.setupImageOptimization();
|
|
this.setupScrollOptimization();
|
|
this.setupResizeOptimization();
|
|
this.measurePerformance();
|
|
}
|
|
|
|
static setupImageOptimization() {
|
|
// Lazy loading images
|
|
const images = document.querySelectorAll('img[data-src]');
|
|
const imageObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
const img = entry.target;
|
|
img.src = img.dataset.src;
|
|
img.classList.remove('lazy');
|
|
imageObserver.unobserve(img);
|
|
}
|
|
});
|
|
});
|
|
|
|
images.forEach(img => imageObserver.observe(img));
|
|
}
|
|
|
|
static setupScrollOptimization() {
|
|
let ticking = false;
|
|
|
|
const handleScroll = () => {
|
|
if (!ticking) {
|
|
requestAnimationFrame(() => {
|
|
// Scroll handling logic
|
|
ticking = false;
|
|
});
|
|
ticking = true;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
}
|
|
|
|
static setupResizeOptimization() {
|
|
let resizeTimeout;
|
|
|
|
const handleResize = () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
// Resize handling logic
|
|
this.recalculateLayout();
|
|
}, 250);
|
|
};
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
}
|
|
|
|
static measurePerformance() {
|
|
// Performance monitoring
|
|
new PerformanceObserver((list) => {
|
|
list.getEntries().forEach(entry => {
|
|
if (entry.entryType === 'navigation') {
|
|
console.log('Page load time:', entry.loadEventEnd - entry.loadEventStart);
|
|
}
|
|
});
|
|
}).observe({ entryTypes: ['navigation'] });
|
|
}
|
|
|
|
static recalculateLayout() {
|
|
// Layout recalculation logic
|
|
const tables = document.querySelectorAll('.table-responsive');
|
|
tables.forEach(table => {
|
|
// Recalculate table dimensions
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialize performance optimization
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
PerformanceOptimizer.init();
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 **RIEPILOGO CAPITOLO 8**
|
|
|
|
### **✅ Completato**
|
|
- **Architettura Frontend**: Struttura modulare, componenti base
|
|
- **Stack Tecnologico**: Bootstrap 5, Alpine.js, Vite, Axios
|
|
- **Componenti UI**: Button, Form, DataTable, Modal components
|
|
- **JavaScript**: AJAX, Alpine.js, Event handling
|
|
- **Responsive Design**: Breakpoints, mobile navigation
|
|
- **Performance**: Lazy loading, code splitting, caching
|
|
- **State Management**: Semplice gestore stato
|
|
- **Validazione**: Real-time validation, regole custom
|
|
- **Esempi Pratici**: Form completo, dashboard charts
|
|
- **Best Practices**: Pattern modulari, error handling, ottimizzazione
|
|
|
|
### **🎯 Focus Principali**
|
|
- Interfaccia moderna e responsive
|
|
- Componenti riutilizzabili
|
|
- Performance ottimizzata
|
|
- Validazione robusta
|
|
- Gestione errori completa
|
|
|
|
### **🔧 Pronto per Implementazione**
|
|
- Tutti i componenti base pronti
|
|
- Esempi pratici testabili
|
|
- Best practices definite
|
|
- Struttura scalabile e mantenibile
|
|
|
|
**Capitolo 8 completato con successo! 🎨**
|