# FASE 20 — Homepage Hero Carousel Editorial

> **Data:** 2026-07-21
> **Estado:** ✅ CONCLUÍDA
> **Stack:** Laravel 13 + Filament 5.7 + Alpine.js + Tailwind v4

---

## Índice

1. [Objectivo](#objectivo)
2. [Arquitectura](#arquitectura)
3. [Entidade: HomepageSlide](#entidade-homepageslide)
4. [Filament Resource](#filament-resource)
5. [HomepageService](#homepageservice)
6. [Cache + Observer](#cache--observer)
7. [Componente Hero Carousel](#componente-hero-carousel)
8. [Alpine.js](#alpinejs)
9. [Acessibilidade](#acessibilidade)
10. [Responsividade](#responsividade)
11. [Copy Estratégica](#copy-estratégica)
12. [Testes](#testes)
13. [Decisões Arquitecturais](#decisões-arquitecturais)

---

## Objectivo

Substituir a hero estática da homepage por um **hero carousel editorial** com múltiplos slides, gerido integralmente pelo Filament. O conteúdo de cada slide (título, descrição, CTAs, imagem) é configurável pelo administrador sem necessidade de tocar em código.

---

## Arquitectura

```
Filament (HomepageSlideResource)
    │
    ▼
HomepageSlide (Model)
    │
    ▼
HomepageSlideObserver (invalida cache)
    │
    ▼
Cache (homepage.hero.slides, 3600s)
    │
    ▼
HomepageService::heroSlides()
    │
    ▼
welcome.blade.php → $heroSlides
    │
    ▼
x-homepage.hero-carousel (Blade component)
    │
    ▼
Alpine.js (carousel interactivo)
```

### Fluxo de administração

1. Admin acede a Filament → Homepage → Homepage Slides
2. Cria/edita slide com: eyebrow, título, descrição, CTAs, imagem, variante de fundo, ordem, período
3. Observer limpa cache automaticamente
4. HomepageService carrega slides (com fallback se vazio)
5. Hero Carousel renderiza com Alpine.js

---

## Entidade: HomepageSlide

### Tabela: `homepage_slides`

| Campo | Tipo | Descrição |
|---|---|---|
| `id` | bigint, PK | |
| `eyebrow` | string, nullable | Label opcional (ex: "MAIS PERTO DE SI") |
| `title` | string, required | Título principal |
| `description` | text, nullable | Descrição |
| `primary_cta_label` | string, nullable | Label do CTA principal |
| `primary_cta_url` | string, nullable | URL do CTA principal |
| `secondary_cta_label` | string, nullable | Label do CTA secundário |
| `secondary_cta_url` | string, nullable | URL do CTA secundário |
| `image_path` | string, nullable | Caminho da imagem (FileUpload) |
| `image_alt` | string, nullable | Texto alternativo |
| `background_variant` | string, default `'brand'` | brand, brand-dark, accent, danger, white |
| `text_alignment` | string, default `'left'` | left, center, right |
| `sort_order` | unsigned int, default 0 | Ordem de exibição |
| `is_active` | boolean, default true | Activo/inactivo |
| `starts_at` | timestamp, nullable | Início de exibição |
| `ends_at` | timestamp, nullable | Fim de exibição |

### Scopes

- `active()` — `is_active = true`
- `scheduled()` — respeita `starts_at` / `ends_at`
- `sortable()` — ordena por `sort_order`

---

## Filament Resource

### `app/Filament/Resources/HomepageSlideResource.php`

Organizado em 5 secções:

1. **Conteúdo** — eyebrow, title, description
2. **CTAs** — primary/secondary label + URL
3. **Imagem** — FileUpload (image, max 2MB, directory `homepage-slides`), image_alt
4. **Apresentação** — background_variant (select), text_alignment (select), sort_order, is_active (toggle)
5. **Programação** — starts_at, ends_at (datetime pickers)

### Listagem

- Colunas: imagem, ordem, eyebrow, título, variante, activo, início, fim
- Ordenação por `sort_order` com `reorderable`
- Filtro: activos/inactivos

---

## HomepageService

### Método `heroSlides(): array`

```php
private function heroSlides(): array
{
    return Cache::remember('homepage.hero.slides', 3600, function () {
        $slides = HomepageSlide::active()->scheduled()->sortable()->get();

        if ($slides->isEmpty()) {
            return [/* fallback array */];
        }

        return $slides->map(fn($s) => [
            'eyebrow' => $s->eyebrow,
            'title' => $s->title,
            // ... todos os campos mapeados para array
        ])->toArray();
    });
}
```

### Porquê arrays em vez de Eloquent models?

O driver de cache `database` serializa valores com `serialize()`/`unserialize()`. No PHP 8.5, Eloquent models podem causar erro `incomplete class` ao unserializar. Arrays puros são seguros para serialização.

### Fallback

Quando não existem slides activos, o serviço retorna um slide editorial seguro. A homepage nunca quebra.

---

## Cache + Observer

### Cache key: `homepage.hero.slides`

- TTL: 3600 segundos
- Armazena arrays puros (não Eloquent models)

### Observer

```php
class HomepageSlideObserver
{
    public function saved(HomepageSlide $slide): void
    {
        Cache::forget('homepage.hero.slides');
    }

    public function deleted(HomepageSlide $slide): void
    {
        Cache::forget('homepage.hero.slides');
    }
}
```

Registado em `AppServiceProvider::boot()`.

---

## Componente Hero Carousel

### `resources/views/components/homepage/hero-carousel.blade.php`

Estrutura de variantes visuais:

| Variante | Fundo | Eyebrow | Título | CTA | Secundário |
|---|---|---|---|---|---|
| `brand` | Azul gradiente | Branco/10 + amarelo | Branco | Amarelo | Borda branca |
| `brand-dark` | Azul escuro gradiente | Branco/10 + amarelo | Branco | Amarelo | Borda branca |
| `accent` | Amarelo gradiente | Azul/10 | Azul escuro | Azul | Borda azul |
| `danger` | Vermelho gradiente | Branco/10 + amarelo | Branco | Amarelo | Borda branca |
| `white` | Branco + borda | Azul/50 | Cinza escuro | Azul | Borda azul |

### Comportamento por breakpoint

- **Desktop (≥1024px)**: grid 2 colunas — texto à esquerda, imagem à direita
- **Tablet (768-1023px)**: grid 2 colunas reduzido
- **Mobile (<768px)**: layout vertical, texto em cima, imagem em baixo

### Navegação

- Dots na parte inferior esquerda
- Setas anterior/próximo na parte inferior direita
- Autoplay a cada 6 segundos
- Pausa ao hover/focus
- Teclado: setas esquerda/direita

---

## Alpine.js

### Instalação

Adicionado ao projecto via `npm install alpinejs`. Importado em `resources/js/app.js`:

```js
import Alpine from 'alpinejs'

Alpine.data('heroCarousel', () => ({
    current: 0,
    slides: [],
    interval: null,
    // ... métodos: next, prev, goTo, pause, resume
}))

Alpine.start()
```

### Transições

- Entrada: fade + translate-x (700ms ease-out)
- Saída: fade + translate-x (500ms ease-in)
- `prefers-reduced-motion`: respeitado pelo Tailwind/navegador

---

## Acessibilidade

- `role="region"` com `aria-roledescription="carousel"`
- `aria-label` no container e em cada slide
- Botões de navegação com `aria-label`
- Dots com `role="tablist"` e `aria-selected`
- Foco visível (`focus:outline-none focus:ring-2 focus:ring-white/50`)
- Teclado: setas ← → para navegar
- Apenas o slide activo tem `h1`; os restantes têm `aria-hidden="true"`

---

## Responsividade

Breakpoints testados: 375px, 390px, 768px, 1024px, 1280px, 1440px, 1920px.

- **Mobile**: layout vertical, texto em cima, imagem em baixo, CTAs empilhados
- **Tablet**: grid 2 colunas preservado, proporções ajustadas
- **Desktop**: composição premium com imagem à direita, altura generosa (py-24 lg:py-36)

Imagem usa `object-contain` para preservar proporções do produto.

---

## Copy Estratégica

4 slides de exemplo no seeder, alinhados com a voz da marca Mangolê:

1. **Consumidor** — "MAIS PERTO DE SI" / "Tudo o que precisa para o seu dia, num só lugar."
2. **Preço** — "PREÇOS QUE FAZEM A DIFERENÇA" / "Comprar bem começa por escolher melhor."
3. **Negócios** — "PARA O SEU NEGÓCIO" / "Abasteça o seu negócio com quem conhece o mercado."
4. **Rede** — "UMA REDE CADA VEZ MAIS PERTO" / "Quando precisar, estamos por perto."

---

## Testes

11 testes em `tests/Feature/HomepageSlideTest.php`:

| # | Teste | Descrição |
|---|---|---|
| 1 | Homepage com zero slides | Fallback seguro, não quebra |
| 2 | Homepage com um slide | Renderiza correctamente |
| 3 | Homepage com vários slides | Todos visíveis |
| 4 | Apenas slides activos | Inactivos excluídos |
| 5 | Slides fora do período | Futuros/passados excluídos |
| 6 | Ordenação por sort_order | Ordem correcta |
| 7 | HomepageService devolve heroSlides | Estrutura de dados correcta |
| 8 | Observer invalida cache | Cache limpa ao salvar |
| 9 | Filament pode criar | Criação via model |
| 10 | Filament pode editar | Edição via model |
| 11 | Filament pode activar/desactivar | Toggle funcional |

---

## Decisões Arquitecturais

### Porque não reutilizar a entidade Campaign?

A entidade `Campaign` está desenhada para campanhas de marketing com assets múltiplos, CTAs relacionados, SEO e estados complexos. Reutilizá-la para slides da hero introduziria complexidade desnecessária e confusão conceptual. `HomepageSlide` é focada, leve e específica para o carousel editorial.

### Porque arrays em vez de Eloquent models no cache?

O driver de cache `database` serializa com `serialize()`/`unserialize()`. Eloquent models podem conter closures ou anonymous subclasses que o PHP 8.5 não consegue unserializar (`incomplete class` error). Arrays puros são seguros.

### Porque Alpine.js?

Leve (~12KB gzip), zero dependências, integração natural com Blade, sem necessidade de jQuery ou bibliotecas pesadas de carousel.

### Porque fallback hardcoded?

Garante que a homepage nunca quebra, mesmo que o administrador ainda não tenha criado slides. O fallback é um único slide editorial alinhado com a marca.

---

## Ficheiros Alterados/Criados

### Criados

| Ficheiro | Descrição |
|---|---|
| `database/migrations/2026_07_21_000021_create_homepage_slides_table.php` | Migration da tabela |
| `app/Models/HomepageSlide.php` | Model com scopes e casts |
| `database/factories/HomepageSlideFactory.php` | Factory com estados |
| `database/seeders/HomepageSlideSeeder.php` | Seeder com 4 slides de exemplo |
| `app/Filament/Resources/HomepageSlideResource.php` | Resource Filament |
| `app/Filament/Resources/HomepageSlideResource/Pages/ListHomepageSlides.php` | Listagem |
| `app/Filament/Resources/HomepageSlideResource/Pages/CreateHomepageSlide.php` | Criação |
| `app/Filament/Resources/HomepageSlideResource/Pages/EditHomepageSlide.php` | Edição |
| `app/Observers/HomepageSlideObserver.php` | Observer de cache |
| `resources/views/components/homepage/hero-carousel.blade.php` | Componente Blade |
| `tests/Feature/HomepageSlideTest.php` | 11 testes |
| `docs/PHASE_20_HOMEPAGE_HERO_CAROUSEL.md` | Este documento |

### Modificados

| Ficheiro | Alteração |
|---|---|
| `database/seeders/DatabaseSeeder.php` | Chamada ao `HomepageSlideSeeder` |
| `app/Providers/AppServiceProvider.php` | Registado `HomepageSlideObserver` |
| `app/Services/HomepageService.php` | Método `heroSlides()` + import |
| `resources/views/welcome.blade.php` | Hero substituída por `<x-homepage.hero-carousel>` |
| `resources/js/app.js` | Import Alpine.js + componente heroCarousel |
| `tests/Feature/DatabaseFoundationTest.php` | `homepage_slides` adicionado à lista |
| `package.json` | `alpinejs` adicionado às dependências |

---

## Validação

- `php artisan migrate:fresh --seed` — ✅ 25 migrations, 13 seeders
- `php artisan test --compact` — ✅ **79 tests, 242 assertions, 0 failures**
- `npm run build` — ✅ 12.05s, sem warnings
