Compare commits
11 Commits
feat/setup
...
feature/bu
| Author | SHA1 | Date | |
|---|---|---|---|
| 399e7be3a9 | |||
| 0adaadd9ed | |||
| 2405160046 | |||
| 27f3cee855 | |||
| 7a858b52af | |||
| 7a70b44931 | |||
| 18f9ec85e7 | |||
| 02f2fc506d | |||
| 59bc624fa8 | |||
| 8da8d19504 | |||
| 236a7d3ea9 |
@@ -9,6 +9,11 @@ APP_FALLBACK_LOCALE=pt_BR
|
|||||||
APP_FAKER_LOCALE=pt_BR
|
APP_FAKER_LOCALE=pt_BR
|
||||||
APP_TIMEZONE=America/Fortaleza
|
APP_TIMEZONE=America/Fortaleza
|
||||||
|
|
||||||
|
# Freeze application clock outside production (visual regression / deterministic seeds).
|
||||||
|
# Example: APP_FROZEN_NOW=2026-03-15T12:00:00-03:00
|
||||||
|
# Ignored when APP_ENV=production.
|
||||||
|
# APP_FROZEN_NOW=
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
# APP_MAINTENANCE_STORE=database
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
|||||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
@@ -141,7 +141,7 @@ jobs:
|
|||||||
- uses: shivammathur/setup-php@v2
|
- uses: shivammathur/setup-php@v2
|
||||||
with:
|
with:
|
||||||
php-version: "8.4"
|
php-version: "8.4"
|
||||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium
|
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_pgsql, bcmath, intl, sodium, gd
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
@@ -161,11 +161,15 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npx playwright install chromium --with-deps
|
- run: npx playwright install chromium --with-deps
|
||||||
- run: php artisan migrate --force
|
- run: php artisan migrate --force
|
||||||
|
- run: php artisan db:seed --class=VisualContentSeeder --force
|
||||||
|
- run: php artisan storage:link
|
||||||
|
|
||||||
- name: Build application image
|
- name: Build application image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|
||||||
- name: Run browser tests against FrankenPHP container
|
- name: Run browser tests against FrankenPHP container
|
||||||
|
env:
|
||||||
|
APP_FROZEN_NOW: "2026-03-15T12:00:00-03:00"
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name amare-web \
|
docker run -d --name amare-web \
|
||||||
-e APP_ENV=testing \
|
-e APP_ENV=testing \
|
||||||
@@ -174,6 +178,7 @@ jobs:
|
|||||||
-e APP_LOCALE=pt_BR \
|
-e APP_LOCALE=pt_BR \
|
||||||
-e APP_FALLBACK_LOCALE=pt_BR \
|
-e APP_FALLBACK_LOCALE=pt_BR \
|
||||||
-e APP_TIMEZONE=America/Fortaleza \
|
-e APP_TIMEZONE=America/Fortaleza \
|
||||||
|
-e APP_FROZEN_NOW="${APP_FROZEN_NOW}" \
|
||||||
-e DB_CONNECTION=pgsql \
|
-e DB_CONNECTION=pgsql \
|
||||||
-e DB_HOST=host.docker.internal \
|
-e DB_HOST=host.docker.internal \
|
||||||
-e DB_PORT=5432 \
|
-e DB_PORT=5432 \
|
||||||
@@ -184,6 +189,7 @@ jobs:
|
|||||||
-e CACHE_STORE=database \
|
-e CACHE_STORE=database \
|
||||||
-e QUEUE_CONNECTION=database \
|
-e QUEUE_CONNECTION=database \
|
||||||
--add-host=host.docker.internal:host-gateway \
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-v "${GITHUB_WORKSPACE}/storage/app/public:/app/storage/app/public" \
|
||||||
-p 8000:8000 \
|
-p 8000:8000 \
|
||||||
amare-app:ci
|
amare-app:ci
|
||||||
|
|
||||||
@@ -197,6 +203,23 @@ jobs:
|
|||||||
curl -fsS http://127.0.0.1:8000/up
|
curl -fsS http://127.0.0.1:8000/up
|
||||||
./vendor/bin/pest --testsuite=Browser
|
./vendor/bin/pest --testsuite=Browser
|
||||||
|
|
||||||
|
- name: Collect failure diagnostics
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts/browser
|
||||||
|
docker logs amare-web > artifacts/browser/container.log 2>&1 || true
|
||||||
|
cp -R storage/logs artifacts/browser/app-logs 2>/dev/null || true
|
||||||
|
cp -R tests/Browser/Screenshots artifacts/browser/screenshots 2>/dev/null || true
|
||||||
|
cp -R tests/.pest artifacts/browser/pest 2>/dev/null || true
|
||||||
|
|
||||||
|
- name: Upload browser failure artifacts
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: browser-failure-artifacts
|
||||||
|
path: artifacts/browser
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
container:
|
container:
|
||||||
name: container
|
name: container
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -206,7 +229,7 @@ jobs:
|
|||||||
- name: Build production image
|
- name: Build production image
|
||||||
run: docker build -t amare-app:ci .
|
run: docker build -t amare-app:ci .
|
||||||
|
|
||||||
- name: Verify container healthcheck
|
- name: Verify container healthcheck and storage link
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name amare-health \
|
docker run -d --name amare-health \
|
||||||
-e APP_ENV=production \
|
-e APP_ENV=production \
|
||||||
@@ -224,6 +247,7 @@ jobs:
|
|||||||
|
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if curl -fsS http://127.0.0.1:8000/up; then
|
if curl -fsS http://127.0.0.1:8000/up; then
|
||||||
|
docker exec amare-health test -L /app/public/storage
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ _ide_helper.php
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
.worktrees/
|
||||||
|
|||||||
32
AGENTS.md
Normal file
32
AGENTS.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
This is a Laravel 13 application for an event-planning consultancy. Application code lives in `app/`: domain rules belong in `app/Domain`, HTTP entry points in `app/Http`, and the internal Filament 5 panel in `app/Filament`. Blade views, JavaScript, and Tailwind CSS are under `resources/`; Vite publishes browser assets to `public/`. Database migrations, factories, and seeders live in `database/`. Tests are grouped into `tests/Unit`, `tests/Architecture`, `tests/Feature`, and `tests/Browser`. Treat `SPEC.md` as the product source of truth and use `openspec/` for planned changes.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
- `composer setup` installs PHP and npm dependencies, creates `.env`, migrates, and builds assets.
|
||||||
|
- `docker compose up -d` starts the local PostgreSQL service.
|
||||||
|
- `composer dev` runs Laravel, the queue listener, logs, and Vite together.
|
||||||
|
- `npm run build` creates the production frontend bundle.
|
||||||
|
- `composer quality` runs formatting checks, PHPStan level 5, dependency audit, and every test suite.
|
||||||
|
- `composer test:unit`, `composer test:feature`, or `composer test:browser` run focused suites.
|
||||||
|
|
||||||
|
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
Follow PSR-4 and Laravel conventions: PascalCase classes, camelCase methods, and snake_case database columns. Use four spaces (two in YAML, except four in Compose files), LF endings, and UTF-8 as defined by `.editorconfig`. Every project-owned PHP file must place `declare(strict_types=1);` immediately after `<?php`. Keep domain code independent of Filament and Livewire. Run `composer pint` to format and `composer phpstan` before review.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
Tests use Pest 4; browser coverage uses Pest Browser/Playwright. Name files by behavior, ending in `Test.php`, and add tests in the suite matching the changed layer. Feature tests use `RefreshDatabase`. Add architecture coverage for dependency-boundary changes. No numeric coverage threshold is enforced, but changed behavior must have regression coverage.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
History follows Conventional Commit-style subjects, for example `feat: Fase 0 — Fundação`. Use `<type>: <imperative summary>` (`feat`, `fix`, `docs`, `test`, `chore`) and keep commits focused. Pull requests should explain scope, link the relevant issue or OpenSpec requirement, list verification commands, and include screenshots for UI changes. Ensure all CI jobs pass.
|
||||||
|
|
||||||
|
## Security & Configuration
|
||||||
|
|
||||||
|
Copy `.env.example`; never commit secrets or production credentials. Development seed credentials are local-only. Validate uploads and authorization through Laravel policies, and run `composer security-audit` after dependency changes.
|
||||||
@@ -35,7 +35,8 @@ RUN install-php-extensions \
|
|||||||
opcache \
|
opcache \
|
||||||
pcntl \
|
pcntl \
|
||||||
bcmath \
|
bcmath \
|
||||||
sodium
|
sodium \
|
||||||
|
gd
|
||||||
|
|
||||||
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1000 appuser
|
RUN useradd --create-home --shell /usr/sbin/nologin --uid 1000 appuser
|
||||||
|
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -70,6 +70,26 @@ composer test:browser # E2E browser
|
|||||||
composer test # Todos os testes
|
composer test # Todos os testes
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Banco de testes (PostgreSQL)
|
||||||
|
|
||||||
|
Feature tests usam PostgreSQL conforme `phpunit.xml` (`DB_DATABASE=amare_test`).
|
||||||
|
|
||||||
|
Crie o banco de teste uma vez (com PostgreSQL local via Docker Compose):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec amare-postgres psql -U amare -d amare -c "CREATE DATABASE amare_test;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Armazenamento de mídia
|
||||||
|
|
||||||
|
Uploads de conteúdo usam o disco `public`. Crie o symlink antes de servir arquivos localmente:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan storage:link
|
||||||
|
```
|
||||||
|
|
||||||
|
Em produção, configure `FILESYSTEM_DISK=s3` no `.env`.
|
||||||
|
|
||||||
## Credenciais de desenvolvimento
|
## Credenciais de desenvolvimento
|
||||||
|
|
||||||
Após `php artisan db:seed`:
|
Após `php artisan db:seed`:
|
||||||
|
|||||||
28
SPEC.md
28
SPEC.md
@@ -2330,21 +2330,21 @@ O agente deve implementar na sequência, salvo instrução explícita.
|
|||||||
|
|
||||||
### Fase 1 — Site e CMS
|
### Fase 1 — Site e CMS
|
||||||
|
|
||||||
- [ ] `site_settings`;
|
- [x] `site_settings`;
|
||||||
- [ ] serviços;
|
- [x] serviços;
|
||||||
- [ ] portfólio e galeria;
|
- [x] portfólio e galeria;
|
||||||
- [ ] depoimentos;
|
- [x] depoimentos;
|
||||||
- [ ] home;
|
- [x] home;
|
||||||
- [ ] listagem e detalhe de serviços;
|
- [x] listagem e detalhe de serviços;
|
||||||
- [ ] listagem e detalhe de portfólio;
|
- [x] listagem e detalhe de portfólio;
|
||||||
- [ ] sobre;
|
- [x] sobre;
|
||||||
- [ ] privacidade;
|
- [x] privacidade;
|
||||||
- [ ] SEO;
|
- [x] SEO;
|
||||||
- [ ] mídia otimizada;
|
- [x] mídia otimizada;
|
||||||
- [ ] snapshots desktop/mobile;
|
- [x] snapshots desktop/mobile;
|
||||||
- [ ] testes de acessibilidade.
|
- [x] testes de acessibilidade.
|
||||||
|
|
||||||
**Critério de saída:** conteúdo gerenciável no Filament e site público aprovado visualmente.
|
**Critério de saída:** conteúdo gerenciável no Filament e site público aprovado visualmente (baselines em `tests/.pest/snapshots/`; aprovação humana do diff visual no PR).
|
||||||
|
|
||||||
### Fase 2 — Leads
|
### Fase 2 — Leads
|
||||||
|
|
||||||
|
|||||||
26
app/Application/Data/HomeContent.php
Normal file
26
app/Application/Data/HomeContent.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Data;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
final readonly class HomeContent
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Service> $featuredServices
|
||||||
|
* @param Collection<int, PortfolioCase> $featuredCases
|
||||||
|
* @param Collection<int, Testimonial> $testimonials
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public SiteSetting $settings,
|
||||||
|
public Collection $featuredServices,
|
||||||
|
public Collection $featuredCases,
|
||||||
|
public Collection $testimonials,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
103
app/Application/Data/PageMeta.php
Normal file
103
app/Application/Data/PageMeta.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Data;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
final readonly class PageMeta
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $jsonLd
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $title,
|
||||||
|
public string $description,
|
||||||
|
public string $canonical,
|
||||||
|
public string $ogType = 'website',
|
||||||
|
public ?string $ogImageUrl = null,
|
||||||
|
public ?string $ogImageAlt = null,
|
||||||
|
public ?array $jsonLd = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $jsonLd
|
||||||
|
*/
|
||||||
|
public static function forPage(
|
||||||
|
string $canonical,
|
||||||
|
SiteSetting $settings,
|
||||||
|
?string $title = null,
|
||||||
|
?string $description = null,
|
||||||
|
?string $ogImageUrl = null,
|
||||||
|
?string $ogImageAlt = null,
|
||||||
|
string $ogType = 'website',
|
||||||
|
?array $jsonLd = null,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
title: filled($title) ? (string) $title : self::defaultTitle($settings),
|
||||||
|
description: filled($description) ? (string) $description : self::defaultDescription($settings),
|
||||||
|
canonical: $canonical,
|
||||||
|
ogType: $ogType,
|
||||||
|
ogImageUrl: $ogImageUrl ?? self::defaultOgImageUrl($settings),
|
||||||
|
ogImageAlt: $ogImageAlt ?? $settings->default_og_image_alt,
|
||||||
|
jsonLd: $jsonLd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $jsonLd
|
||||||
|
*/
|
||||||
|
public static function forCase(
|
||||||
|
PortfolioCase $case,
|
||||||
|
string $canonical,
|
||||||
|
SiteSetting $settings,
|
||||||
|
?array $jsonLd = null,
|
||||||
|
): self {
|
||||||
|
$title = filled($case->meta_title) ? (string) $case->meta_title : (string) $case->title;
|
||||||
|
$description = filled($case->meta_description)
|
||||||
|
? (string) $case->meta_description
|
||||||
|
: (filled($case->summary) ? (string) $case->summary : self::defaultDescription($settings));
|
||||||
|
|
||||||
|
$ogImageUrl = filled($case->cover_image_path)
|
||||||
|
? url(Storage::disk('public')->url((string) $case->cover_image_path))
|
||||||
|
: self::defaultOgImageUrl($settings);
|
||||||
|
|
||||||
|
$ogImageAlt = filled($case->cover_image_alt)
|
||||||
|
? (string) $case->cover_image_alt
|
||||||
|
: $settings->default_og_image_alt;
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
title: $title,
|
||||||
|
description: $description,
|
||||||
|
canonical: $canonical,
|
||||||
|
ogType: 'article',
|
||||||
|
ogImageUrl: $ogImageUrl,
|
||||||
|
ogImageAlt: $ogImageAlt,
|
||||||
|
jsonLd: $jsonLd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultTitle(SiteSetting $settings): string
|
||||||
|
{
|
||||||
|
return filled($settings->default_meta_title)
|
||||||
|
? (string) $settings->default_meta_title
|
||||||
|
: (string) $settings->brand_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultDescription(SiteSetting $settings): string
|
||||||
|
{
|
||||||
|
return (string) ($settings->default_meta_description ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function defaultOgImageUrl(SiteSetting $settings): ?string
|
||||||
|
{
|
||||||
|
if (! filled($settings->default_og_image_path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url(Storage::disk('public')->url((string) $settings->default_og_image_path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Queries\Marketing;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
|
||||||
|
final class FindPublishedPortfolioCaseBySlug
|
||||||
|
{
|
||||||
|
public function __invoke(string $slug): ?PortfolioCase
|
||||||
|
{
|
||||||
|
return PortfolioCase::query()
|
||||||
|
->published()
|
||||||
|
->with(['images'])
|
||||||
|
->where('slug', $slug)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Application/Queries/Marketing/GetHomeContent.php
Normal file
36
app/Application/Queries/Marketing/GetHomeContent.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Queries\Marketing;
|
||||||
|
|
||||||
|
use App\Application\Data\HomeContent;
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
|
||||||
|
final class GetHomeContent
|
||||||
|
{
|
||||||
|
public function __invoke(): HomeContent
|
||||||
|
{
|
||||||
|
return new HomeContent(
|
||||||
|
settings: SiteSetting::instance(),
|
||||||
|
featuredServices: Service::query()
|
||||||
|
->published()
|
||||||
|
->where('is_featured', true)
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get(),
|
||||||
|
featuredCases: PortfolioCase::query()
|
||||||
|
->published()
|
||||||
|
->where('is_featured', true)
|
||||||
|
->with(['images'])
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get(),
|
||||||
|
testimonials: Testimonial::query()
|
||||||
|
->published()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Queries\Marketing;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
final class GetPublishedPortfolioCases
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, PortfolioCase>
|
||||||
|
*/
|
||||||
|
public function __invoke(): Collection
|
||||||
|
{
|
||||||
|
return PortfolioCase::query()
|
||||||
|
->published()
|
||||||
|
->with(['images'])
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Application/Queries/Marketing/GetPublishedServices.php
Normal file
22
app/Application/Queries/Marketing/GetPublishedServices.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Queries\Marketing;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
final class GetPublishedServices
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return Collection<int, Service>
|
||||||
|
*/
|
||||||
|
public function __invoke(): Collection
|
||||||
|
{
|
||||||
|
return Service::query()
|
||||||
|
->published()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Application/Queries/Marketing/GetSitemapEntries.php
Normal file
43
app/Application/Queries/Marketing/GetSitemapEntries.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Application\Queries\Marketing;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
final class GetSitemapEntries
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<array{loc: string, lastmod: string|null}>
|
||||||
|
*/
|
||||||
|
public function __invoke(): array
|
||||||
|
{
|
||||||
|
$entries = [
|
||||||
|
['loc' => route('home'), 'lastmod' => null],
|
||||||
|
['loc' => route('services.index'), 'lastmod' => null],
|
||||||
|
['loc' => route('portfolio.index'), 'lastmod' => null],
|
||||||
|
['loc' => route('about'), 'lastmod' => null],
|
||||||
|
['loc' => route('privacy'), 'lastmod' => null],
|
||||||
|
['loc' => route('contact'), 'lastmod' => null],
|
||||||
|
];
|
||||||
|
|
||||||
|
$cases = PortfolioCase::query()
|
||||||
|
->published()
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->get(['slug', 'updated_at']);
|
||||||
|
|
||||||
|
foreach ($cases as $case) {
|
||||||
|
/** @var Carbon|null $updatedAt */
|
||||||
|
$updatedAt = $case->updated_at;
|
||||||
|
|
||||||
|
$entries[] = [
|
||||||
|
'loc' => route('portfolio.show', $case->slug),
|
||||||
|
'lastmod' => $updatedAt?->toAtomString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Console/Commands/MediaGenerateVariantsCommand.php
Normal file
78
app/Console/Commands/MediaGenerateVariantsCommand.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\PortfolioImage;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use App\Support\ResponsiveImage;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
final class MediaGenerateVariantsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'media:generate-variants';
|
||||||
|
|
||||||
|
protected $description = 'Generate responsive image variants for existing public media';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$disk = PublicImageUploadRules::disk();
|
||||||
|
$paths = $this->collectPaths();
|
||||||
|
$generated = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
foreach ($paths as $path) {
|
||||||
|
if (! Storage::disk($disk)->exists($path)) {
|
||||||
|
$this->warn("Missing file: {$path}");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResponsiveImage::generate($path, $disk);
|
||||||
|
$generated++;
|
||||||
|
$this->line("Generated variants for {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Done. Generated: {$generated}. Skipped: {$skipped}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function collectPaths(): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
|
||||||
|
$settings = SiteSetting::query()->first();
|
||||||
|
if ($settings && filled($settings->default_og_image_path)) {
|
||||||
|
$paths[] = (string) $settings->default_og_image_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Service::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||||
|
$paths[] = (string) $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PortfolioCase::query()->whereNotNull('cover_image_path')->pluck('cover_image_path') as $path) {
|
||||||
|
$paths[] = (string) $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (PortfolioImage::query()->whereNotNull('path')->pluck('path') as $path) {
|
||||||
|
$paths[] = (string) $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Testimonial::query()->whereNotNull('photo_path')->pluck('photo_path') as $path) {
|
||||||
|
$paths[] = (string) $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($paths));
|
||||||
|
}
|
||||||
|
}
|
||||||
244
app/Filament/Pages/ManageSiteSettings.php
Normal file
244
app/Filament/Pages/ManageSiteSettings.php
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Concerns\CanUseDatabaseTransactions;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Actions;
|
||||||
|
use Filament\Schemas\Components\Component;
|
||||||
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
|
use Filament\Schemas\Components\Form;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Exceptions\Halt;
|
||||||
|
use Filament\Support\Facades\FilamentView;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Livewire\Attributes\Locked;
|
||||||
|
use Throwable;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property-read Schema $form
|
||||||
|
*/
|
||||||
|
class ManageSiteSettings extends Page
|
||||||
|
{
|
||||||
|
use CanUseDatabaseTransactions;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog6Tooth;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Configurações';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Configurações do site';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
#[Locked]
|
||||||
|
public SiteSetting $record;
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $user !== null && $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->record = SiteSetting::instance();
|
||||||
|
|
||||||
|
abort_unless(auth()->user()?->can('update', $this->record), 403);
|
||||||
|
|
||||||
|
$this->fillForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hydrate(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()?->can('update', $this->record), 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function fillForm(): void
|
||||||
|
{
|
||||||
|
$this->form->fill($this->record->attributesToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->beginDatabaseTransaction();
|
||||||
|
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
$this->record->update($data);
|
||||||
|
} catch (Halt $exception) {
|
||||||
|
$exception->shouldRollbackDatabaseTransaction() ?
|
||||||
|
$this->rollBackDatabaseTransaction() :
|
||||||
|
$this->commitDatabaseTransaction();
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$this->rollBackDatabaseTransaction();
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->commitDatabaseTransaction();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Configurações salvas com sucesso.')
|
||||||
|
->send();
|
||||||
|
|
||||||
|
if ($redirectUrl = $this->getRedirectUrl()) {
|
||||||
|
$this->redirect($redirectUrl, navigate: FilamentView::hasSpaMode($redirectUrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultForm(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->operation('edit')
|
||||||
|
->model($this->record)
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Marca e hero')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('brand_name')
|
||||||
|
->label('Nome da marca')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('hero_eyebrow')
|
||||||
|
->label('Eyebrow do hero')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('hero_title')
|
||||||
|
->label('Título do hero')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('hero_subtitle')
|
||||||
|
->label('Subtítulo do hero')
|
||||||
|
->required()
|
||||||
|
->rows(3),
|
||||||
|
TextInput::make('hero_cta_label')
|
||||||
|
->label('Texto do CTA')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('about_summary')
|
||||||
|
->label('Resumo institucional')
|
||||||
|
->rows(3),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('Contato')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('email')
|
||||||
|
->label('E-mail')
|
||||||
|
->email()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('phone')
|
||||||
|
->label('Telefone')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('city')
|
||||||
|
->label('Cidade')
|
||||||
|
->maxLength(255),
|
||||||
|
KeyValue::make('social_links')
|
||||||
|
->label('Redes sociais')
|
||||||
|
->keyLabel('Rede')
|
||||||
|
->valueLabel('URL')
|
||||||
|
->addActionLabel('Adicionar rede'),
|
||||||
|
])
|
||||||
|
->columns(2),
|
||||||
|
Section::make('SEO padrão')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('default_meta_title')
|
||||||
|
->label('Meta title padrão')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('default_meta_description')
|
||||||
|
->label('Meta description padrão')
|
||||||
|
->required()
|
||||||
|
->rows(3),
|
||||||
|
PublicImageUploadRules::fileUpload('default_og_image_path', 'Imagem Open Graph padrão', 'content/og'),
|
||||||
|
PublicImageUploadRules::altTextField('default_og_image_alt', 'default_og_image_path'),
|
||||||
|
]),
|
||||||
|
Section::make('Analytics')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('analytics_enabled')
|
||||||
|
->label('Analytics habilitado')
|
||||||
|
->default(false),
|
||||||
|
Textarea::make('analytics_script')
|
||||||
|
->label('Script de analytics')
|
||||||
|
->rows(4)
|
||||||
|
->visible(fn (callable $get): bool => (bool) $get('analytics_enabled')),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<Action|ActionGroup>
|
||||||
|
*/
|
||||||
|
protected function getFormActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('save')
|
||||||
|
->label('Salvar')
|
||||||
|
->submit('save')
|
||||||
|
->keyBindings(['mod+s']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
$this->getFormContentComponent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormContentComponent(): Component
|
||||||
|
{
|
||||||
|
return Form::make([EmbeddedSchema::make('form')])
|
||||||
|
->id('form')
|
||||||
|
->livewireSubmitHandler('save')
|
||||||
|
->footer([
|
||||||
|
Actions::make($this->getFormActions())
|
||||||
|
->alignment($this->getFormActionsAlignment())
|
||||||
|
->fullWidth($this->hasFullWidthFormActions())
|
||||||
|
->sticky($this->areFormActionsSticky())
|
||||||
|
->key('form-actions'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function hasFullWidthFormActions(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreatePortfolioCase extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PortfolioCaseResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditPortfolioCase extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PortfolioCaseResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\PortfolioCaseResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPortfolioCases extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PortfolioCaseResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PortfolioCases\Pages\CreatePortfolioCase;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Pages\EditPortfolioCase;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Pages\ListPortfolioCases;
|
||||||
|
use App\Filament\Resources\PortfolioCases\RelationManagers\ImagesRelationManager;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Schemas\PortfolioCaseForm;
|
||||||
|
use App\Filament\Resources\PortfolioCases\Tables\PortfolioCasesTable;
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Policies\PortfolioCasePolicy;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class PortfolioCaseResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = PortfolioCase::class;
|
||||||
|
|
||||||
|
protected static ?string $policy = PortfolioCasePolicy::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedPhoto;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Portfólio';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'caso';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'portfólio';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return PortfolioCaseForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return PortfolioCasesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
ImagesRelationManager::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListPortfolioCases::route('/'),
|
||||||
|
'create' => CreatePortfolioCase::route('/create'),
|
||||||
|
'edit' => EditPortfolioCase::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\RelationManagers;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class ImagesRelationManager extends RelationManager
|
||||||
|
{
|
||||||
|
protected static string $relationship = 'images';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Galeria';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'imagem';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'galeria';
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
PublicImageUploadRules::fileUpload('path', 'Imagem', 'content/portfolio'),
|
||||||
|
PublicImageUploadRules::altTextField('alt_text', 'path'),
|
||||||
|
TextInput::make('caption')
|
||||||
|
->label('Legenda')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->recordTitleAttribute('alt_text')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('alt_text')
|
||||||
|
->label('Texto alternativo')
|
||||||
|
->searchable(),
|
||||||
|
TextColumn::make('caption')
|
||||||
|
->label('Legenda'),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->reorderable('sort_order')
|
||||||
|
->headerActions([
|
||||||
|
CreateAction::make(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Schemas;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Forms\Components\DatePicker;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class PortfolioCaseForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
|
||||||
|
if (blank($get('slug'))) {
|
||||||
|
$set('slug', str($state)->slug()->toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
TextInput::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Resumo')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('event_type')
|
||||||
|
->label('Tipo de evento')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('city')
|
||||||
|
->label('Cidade')
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('venue')
|
||||||
|
->label('Local')
|
||||||
|
->maxLength(255),
|
||||||
|
DatePicker::make('event_date')
|
||||||
|
->label('Data do evento'),
|
||||||
|
Textarea::make('challenge')
|
||||||
|
->label('Desafio')
|
||||||
|
->required()
|
||||||
|
->rows(4),
|
||||||
|
Textarea::make('solution')
|
||||||
|
->label('Solução')
|
||||||
|
->required()
|
||||||
|
->rows(4),
|
||||||
|
Textarea::make('result')
|
||||||
|
->label('Resultado')
|
||||||
|
->rows(4),
|
||||||
|
PublicImageUploadRules::fileUpload('cover_image_path', 'Imagem de capa'),
|
||||||
|
PublicImageUploadRules::altTextField('cover_image_alt', 'cover_image_path'),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->default(false),
|
||||||
|
DateTimePicker::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->seconds(false),
|
||||||
|
TextInput::make('meta_title')
|
||||||
|
->label('Meta title')
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('meta_description')
|
||||||
|
->label('Meta description')
|
||||||
|
->rows(3),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PortfolioCases\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PortfolioCasesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('event_type')
|
||||||
|
->label('Tipo')
|
||||||
|
->searchable(),
|
||||||
|
IconColumn::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/Services/Pages/CreateService.php
Normal file
11
app/Filament/Resources/Services/Pages/CreateService.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateService extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceResource::class;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Services/Pages/EditService.php
Normal file
19
app/Filament/Resources/Services/Pages/EditService.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditService extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Services/Pages/ListServices.php
Normal file
19
app/Filament/Resources/Services/Pages/ListServices.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\ServiceResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListServices extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = ServiceResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Filament/Resources/Services/Schemas/ServiceForm.php
Normal file
58
app/Filament/Resources/Services/Schemas/ServiceForm.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Schemas;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class ServiceForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->live(onBlur: true)
|
||||||
|
->afterStateUpdated(function (?string $state, callable $set, callable $get): void {
|
||||||
|
if (blank($get('slug'))) {
|
||||||
|
$set('slug', str($state)->slug()->toString());
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
TextInput::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->required()
|
||||||
|
->maxLength(255)
|
||||||
|
->unique(ignoreRecord: true),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Resumo')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
Textarea::make('description')
|
||||||
|
->label('Descrição')
|
||||||
|
->required()
|
||||||
|
->rows(6),
|
||||||
|
PublicImageUploadRules::fileUpload('cover_image_path', 'Imagem de capa'),
|
||||||
|
PublicImageUploadRules::altTextField('cover_image_alt', 'cover_image_path'),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->default(false),
|
||||||
|
DateTimePicker::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->seconds(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Filament/Resources/Services/ServiceResource.php
Normal file
62
app/Filament/Resources/Services/ServiceResource.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Services\Pages\CreateService;
|
||||||
|
use App\Filament\Resources\Services\Pages\EditService;
|
||||||
|
use App\Filament\Resources\Services\Pages\ListServices;
|
||||||
|
use App\Filament\Resources\Services\Schemas\ServiceForm;
|
||||||
|
use App\Filament\Resources\Services\Tables\ServicesTable;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Policies\ServicePolicy;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class ServiceResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Service::class;
|
||||||
|
|
||||||
|
protected static ?string $policy = ServicePolicy::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBriefcase;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Serviços';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'serviço';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'serviços';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return ServiceForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return ServicesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListServices::route('/'),
|
||||||
|
'create' => CreateService::route('/create'),
|
||||||
|
'edit' => EditService::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Filament/Resources/Services/Tables/ServicesTable.php
Normal file
54
app/Filament/Resources/Services/Tables/ServicesTable.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Services\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class ServicesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Título')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->searchable(),
|
||||||
|
IconColumn::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateTestimonial extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TestimonialResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditTestimonial extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = TestimonialResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\TestimonialResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListTestimonials extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = TestimonialResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Schemas;
|
||||||
|
|
||||||
|
use App\Support\PublicImageUploadRules;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class TestimonialForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Textarea::make('quote')
|
||||||
|
->label('Depoimento')
|
||||||
|
->required()
|
||||||
|
->rows(4),
|
||||||
|
TextInput::make('author_name')
|
||||||
|
->label('Nome do autor')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('context')
|
||||||
|
->label('Contexto')
|
||||||
|
->maxLength(255),
|
||||||
|
PublicImageUploadRules::fileUpload('photo_path', 'Foto', 'content/testimonials'),
|
||||||
|
PublicImageUploadRules::altTextField('photo_alt', 'photo_path'),
|
||||||
|
TextInput::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->numeric()
|
||||||
|
->default(0)
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->default(false),
|
||||||
|
DateTimePicker::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->seconds(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class TestimonialsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('author_name')
|
||||||
|
->label('Autor')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('quote')
|
||||||
|
->label('Depoimento')
|
||||||
|
->limit(60),
|
||||||
|
IconColumn::make('is_featured')
|
||||||
|
->label('Destaque')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('published_at')
|
||||||
|
->label('Publicado em')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('sort_order')
|
||||||
|
->label('Ordem')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('sort_order')
|
||||||
|
->filters([
|
||||||
|
TernaryFilter::make('is_featured')
|
||||||
|
->label('Destaque'),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make()
|
||||||
|
->requiresConfirmation(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Filament/Resources/Testimonials/TestimonialResource.php
Normal file
62
app/Filament/Resources/Testimonials/TestimonialResource.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Testimonials;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Testimonials\Pages\CreateTestimonial;
|
||||||
|
use App\Filament\Resources\Testimonials\Pages\EditTestimonial;
|
||||||
|
use App\Filament\Resources\Testimonials\Pages\ListTestimonials;
|
||||||
|
use App\Filament\Resources\Testimonials\Schemas\TestimonialForm;
|
||||||
|
use App\Filament\Resources\Testimonials\Tables\TestimonialsTable;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Policies\TestimonialPolicy;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class TestimonialResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Testimonial::class;
|
||||||
|
|
||||||
|
protected static ?string $policy = TestimonialPolicy::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Depoimentos';
|
||||||
|
|
||||||
|
protected static ?string $modelLabel = 'depoimento';
|
||||||
|
|
||||||
|
protected static ?string $pluralModelLabel = 'depoimentos';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Conteúdo do site';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 4;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return TestimonialForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return TestimonialsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListTestimonials::route('/'),
|
||||||
|
'create' => CreateTestimonial::route('/create'),
|
||||||
|
'edit' => EditTestimonial::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Controllers/PublicSite/HomeController.php
Normal file
38
app/Http/Controllers/PublicSite/HomeController.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Application\Data\PageMeta;
|
||||||
|
use App\Application\Queries\Marketing\GetHomeContent;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
|
||||||
|
final class HomeController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(GetHomeContent $getHomeContent): View
|
||||||
|
{
|
||||||
|
$content = $getHomeContent();
|
||||||
|
|
||||||
|
return view('pages.home', [
|
||||||
|
'content' => $content,
|
||||||
|
'siteSettings' => $content->settings,
|
||||||
|
'pageMeta' => PageMeta::forPage(
|
||||||
|
canonical: route('home'),
|
||||||
|
settings: $content->settings,
|
||||||
|
jsonLd: [
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Organization',
|
||||||
|
'name' => $content->settings->brand_name,
|
||||||
|
'email' => $content->settings->email,
|
||||||
|
'telephone' => $content->settings->phone,
|
||||||
|
'address' => [
|
||||||
|
'@type' => 'PostalAddress',
|
||||||
|
'addressLocality' => $content->settings->city,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Controllers/PublicSite/PageController.php
Normal file
58
app/Http/Controllers/PublicSite/PageController.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Application\Data\PageMeta;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
|
||||||
|
final class PageController extends Controller
|
||||||
|
{
|
||||||
|
public function about(): View
|
||||||
|
{
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
|
||||||
|
return view('pages.about', [
|
||||||
|
'siteSettings' => $settings,
|
||||||
|
'pageMeta' => PageMeta::forPage(
|
||||||
|
canonical: route('about'),
|
||||||
|
settings: $settings,
|
||||||
|
title: 'Sobre',
|
||||||
|
description: $settings->about_summary ?: ('Conheça a '.$settings->brand_name.'.'),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function privacy(): View
|
||||||
|
{
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
|
||||||
|
return view('pages.privacy', [
|
||||||
|
'siteSettings' => $settings,
|
||||||
|
'pageMeta' => PageMeta::forPage(
|
||||||
|
canonical: route('privacy'),
|
||||||
|
settings: $settings,
|
||||||
|
title: 'Política de privacidade',
|
||||||
|
description: 'Política de privacidade da '.$settings->brand_name.'.',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function contact(): View
|
||||||
|
{
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
|
||||||
|
return view('pages.contact', [
|
||||||
|
'siteSettings' => $settings,
|
||||||
|
'pageMeta' => PageMeta::forPage(
|
||||||
|
canonical: route('contact'),
|
||||||
|
settings: $settings,
|
||||||
|
title: 'Contato',
|
||||||
|
description: 'Fale com a '.$settings->brand_name.'.',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Http/Controllers/PublicSite/PortfolioController.php
Normal file
71
app/Http/Controllers/PublicSite/PortfolioController.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Application\Data\PageMeta;
|
||||||
|
use App\Application\Queries\Marketing\FindPublishedPortfolioCaseBySlug;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
final class PortfolioController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
|
||||||
|
/** @var LengthAwarePaginator<int, PortfolioCase> $cases */
|
||||||
|
$cases = PortfolioCase::query()
|
||||||
|
->published()
|
||||||
|
->with(['images'])
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->paginate(9);
|
||||||
|
|
||||||
|
return view('pages.portfolio.index', [
|
||||||
|
'cases' => $cases,
|
||||||
|
'siteSettings' => $settings,
|
||||||
|
'pageMeta' => PageMeta::forPage(
|
||||||
|
canonical: route('portfolio.index'),
|
||||||
|
settings: $settings,
|
||||||
|
title: 'Portfólio',
|
||||||
|
description: 'Casos reais de eventos conduzidos pela '.$settings->brand_name.'.',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(string $slug, FindPublishedPortfolioCaseBySlug $findPublishedPortfolioCaseBySlug): View|Response
|
||||||
|
{
|
||||||
|
$case = $findPublishedPortfolioCaseBySlug($slug);
|
||||||
|
|
||||||
|
if ($case === null) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
$canonical = route('portfolio.show', $case->slug);
|
||||||
|
|
||||||
|
return view('pages.portfolio.show', [
|
||||||
|
'case' => $case,
|
||||||
|
'siteSettings' => $settings,
|
||||||
|
'pageMeta' => PageMeta::forCase(
|
||||||
|
case: $case,
|
||||||
|
canonical: $canonical,
|
||||||
|
settings: $settings,
|
||||||
|
jsonLd: [
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'Article',
|
||||||
|
'headline' => $case->title,
|
||||||
|
'description' => $case->summary,
|
||||||
|
'url' => $canonical,
|
||||||
|
'datePublished' => $case->published_at?->toAtomString(),
|
||||||
|
'dateModified' => $case->updated_at?->toAtomString(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Controllers/PublicSite/RobotsController.php
Normal file
25
app/Http/Controllers/PublicSite/RobotsController.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
final class RobotsController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(): Response
|
||||||
|
{
|
||||||
|
$body = implode("\n", [
|
||||||
|
'User-agent: *',
|
||||||
|
'Allow: /',
|
||||||
|
'Sitemap: '.url('/sitemap.xml'),
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response($body, 200, [
|
||||||
|
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Controllers/PublicSite/ServiceController.php
Normal file
31
app/Http/Controllers/PublicSite/ServiceController.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Application\Data\PageMeta;
|
||||||
|
use App\Application\Queries\Marketing\GetPublishedServices;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
|
||||||
|
final class ServiceController extends Controller
|
||||||
|
{
|
||||||
|
public function index(GetPublishedServices $getPublishedServices): View
|
||||||
|
{
|
||||||
|
$settings = SiteSetting::instance();
|
||||||
|
$services = $getPublishedServices();
|
||||||
|
|
||||||
|
return view('pages.services.index', [
|
||||||
|
'services' => $services,
|
||||||
|
'siteSettings' => $settings,
|
||||||
|
'pageMeta' => PageMeta::forPage(
|
||||||
|
canonical: route('services.index'),
|
||||||
|
settings: $settings,
|
||||||
|
title: 'Serviços',
|
||||||
|
description: 'Conheça os serviços de assessoria de eventos da '.$settings->brand_name.'.',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Controllers/PublicSite/SitemapController.php
Normal file
22
app/Http/Controllers/PublicSite/SitemapController.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\PublicSite;
|
||||||
|
|
||||||
|
use App\Application\Queries\Marketing\GetSitemapEntries;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
final class SitemapController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(GetSitemapEntries $getSitemapEntries): Response
|
||||||
|
{
|
||||||
|
return response()
|
||||||
|
->view('pages.sitemap', [
|
||||||
|
'entries' => $getSitemapEntries(),
|
||||||
|
], 200, [
|
||||||
|
'Content-Type' => 'application/xml',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Models/Concerns/HasPublication.php
Normal file
19
app/Models/Concerns/HasPublication.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
trait HasPublication
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('published_at');
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Models/PortfolioCase.php
Normal file
78
app/Models/PortfolioCase.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasPublication;
|
||||||
|
use App\Policies\PortfolioCasePolicy;
|
||||||
|
use Database\Factories\PortfolioCaseFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Carbon|null $published_at
|
||||||
|
* @property Carbon|null $event_date
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'summary',
|
||||||
|
'event_type',
|
||||||
|
'city',
|
||||||
|
'venue',
|
||||||
|
'event_date',
|
||||||
|
'challenge',
|
||||||
|
'solution',
|
||||||
|
'result',
|
||||||
|
'cover_image_path',
|
||||||
|
'cover_image_alt',
|
||||||
|
'is_featured',
|
||||||
|
'sort_order',
|
||||||
|
'published_at',
|
||||||
|
'meta_title',
|
||||||
|
'meta_description',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(PortfolioCasePolicy::class)]
|
||||||
|
class PortfolioCase extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PortfolioCaseFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use HasPublication;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (PortfolioCase $portfolioCase): void {
|
||||||
|
if (blank($portfolioCase->slug) && filled($portfolioCase->title)) {
|
||||||
|
$portfolioCase->slug = Str::slug($portfolioCase->title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<PortfolioImage, $this>
|
||||||
|
*/
|
||||||
|
public function images(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PortfolioImage::class)->orderBy('sort_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'event_date' => 'date',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Models/PortfolioImage.php
Normal file
37
app/Models/PortfolioImage.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'portfolio_case_id',
|
||||||
|
'path',
|
||||||
|
'alt_text',
|
||||||
|
'caption',
|
||||||
|
'sort_order',
|
||||||
|
])]
|
||||||
|
class PortfolioImage extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<PortfolioCase, $this>
|
||||||
|
*/
|
||||||
|
public function portfolioCase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(PortfolioCase::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Models/Service.php
Normal file
60
app/Models/Service.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasPublication;
|
||||||
|
use App\Policies\ServicePolicy;
|
||||||
|
use Database\Factories\ServiceFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Carbon|null $published_at
|
||||||
|
* @property bool $is_featured
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'title',
|
||||||
|
'slug',
|
||||||
|
'summary',
|
||||||
|
'description',
|
||||||
|
'cover_image_path',
|
||||||
|
'cover_image_alt',
|
||||||
|
'sort_order',
|
||||||
|
'is_featured',
|
||||||
|
'published_at',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(ServicePolicy::class)]
|
||||||
|
class Service extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<ServiceFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use HasPublication;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (Service $service): void {
|
||||||
|
if (blank($service->slug) && filled($service->title)) {
|
||||||
|
$service->slug = Str::slug($service->title);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Models/SiteSetting.php
Normal file
68
app/Models/SiteSetting.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Policies\SiteSettingPolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property array<string, string|null> $social_links
|
||||||
|
* @property bool $analytics_enabled
|
||||||
|
* @property string|null $default_og_image_path
|
||||||
|
* @property string|null $default_og_image_alt
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'brand_name',
|
||||||
|
'hero_eyebrow',
|
||||||
|
'hero_title',
|
||||||
|
'hero_subtitle',
|
||||||
|
'hero_cta_label',
|
||||||
|
'about_summary',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'city',
|
||||||
|
'social_links',
|
||||||
|
'default_meta_title',
|
||||||
|
'default_meta_description',
|
||||||
|
'default_og_image_path',
|
||||||
|
'default_og_image_alt',
|
||||||
|
'analytics_enabled',
|
||||||
|
'analytics_script',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(SiteSettingPolicy::class)]
|
||||||
|
class SiteSetting extends Model
|
||||||
|
{
|
||||||
|
public static function instance(): self
|
||||||
|
{
|
||||||
|
return static::query()->firstOrCreate([], [
|
||||||
|
'brand_name' => 'Amare Assessoria',
|
||||||
|
'hero_eyebrow' => 'Assessoria de eventos',
|
||||||
|
'hero_title' => 'Celebrações com propósito',
|
||||||
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos.',
|
||||||
|
'hero_cta_label' => 'Solicitar orçamento',
|
||||||
|
'about_summary' => 'Assessoria boutique em Fortaleza.',
|
||||||
|
'email' => 'contato@amare.local',
|
||||||
|
'phone' => '(85) 99999-9999',
|
||||||
|
'city' => 'Fortaleza, CE',
|
||||||
|
'social_links' => [],
|
||||||
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
||||||
|
'analytics_enabled' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'social_links' => 'array',
|
||||||
|
'analytics_enabled' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Models/Testimonial.php
Normal file
49
app/Models/Testimonial.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Concerns\HasPublication;
|
||||||
|
use App\Policies\TestimonialPolicy;
|
||||||
|
use Database\Factories\TestimonialFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\UsePolicy;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property Carbon|null $published_at
|
||||||
|
* @property bool $is_featured
|
||||||
|
*/
|
||||||
|
#[Fillable([
|
||||||
|
'quote',
|
||||||
|
'author_name',
|
||||||
|
'context',
|
||||||
|
'photo_path',
|
||||||
|
'photo_alt',
|
||||||
|
'sort_order',
|
||||||
|
'is_featured',
|
||||||
|
'published_at',
|
||||||
|
])]
|
||||||
|
#[UsePolicy(TestimonialPolicy::class)]
|
||||||
|
class Testimonial extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<TestimonialFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
use HasPublication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string|class-string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_featured' => 'boolean',
|
||||||
|
'published_at' => 'datetime',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Policies/PortfolioCasePolicy.php
Normal file
46
app/Policies/PortfolioCasePolicy.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PortfolioCasePolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, PortfolioCase $portfolioCase): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Policies/ServicePolicy.php
Normal file
46
app/Policies/ServicePolicy.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class ServicePolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, Service $service): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Policies/SiteSettingPolicy.php
Normal file
36
app/Policies/SiteSettingPolicy.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class SiteSettingPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, SiteSetting $siteSetting): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, SiteSetting $siteSetting): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, SiteSetting $siteSetting): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Policies/TestimonialPolicy.php
Normal file
46
app/Policies/TestimonialPolicy.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class TestimonialPolicy
|
||||||
|
{
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restore(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forceDelete(User $user, Testimonial $testimonial): bool
|
||||||
|
{
|
||||||
|
return $user->isAdmin();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Application\Data\PageMeta;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\View\View as ViewInstance;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -19,6 +26,38 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
$this->freezeClockWhenConfigured();
|
||||||
|
|
||||||
|
View::composer('layouts.public', function (ViewInstance $view): void {
|
||||||
|
$settings = $view->offsetExists('siteSettings')
|
||||||
|
? $view->offsetGet('siteSettings')
|
||||||
|
: SiteSetting::instance();
|
||||||
|
|
||||||
|
if (! $view->offsetExists('siteSettings')) {
|
||||||
|
$view->with('siteSettings', $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $view->offsetExists('pageMeta')) {
|
||||||
|
$view->with('pageMeta', PageMeta::forPage(
|
||||||
|
canonical: url()->current(),
|
||||||
|
settings: $settings,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function freezeClockWhenConfigured(): void
|
||||||
|
{
|
||||||
|
if ($this->app->environment('production')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$frozenNow = config('app.frozen_now');
|
||||||
|
|
||||||
|
if (! filled($frozenNow)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CarbonImmutable::setTestNow(CarbonImmutable::parse((string) $frozenNow));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
app/Support/PublicImageUploadRules.php
Normal file
91
app/Support/PublicImageUploadRules.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\BaseFileUpload;
|
||||||
|
use Filament\Forms\Components\FileUpload;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Illuminate\Validation\Rules\File;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
|
|
||||||
|
final class PublicImageUploadRules
|
||||||
|
{
|
||||||
|
/** @var list<string> */
|
||||||
|
public const ALLOWED_MIMES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
public const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp'];
|
||||||
|
|
||||||
|
public const MAX_SIZE_KILOBYTES = 10240;
|
||||||
|
|
||||||
|
public static function fileUpload(string $name, string $label, string $directory = 'content'): FileUpload
|
||||||
|
{
|
||||||
|
return FileUpload::make($name)
|
||||||
|
->label($label)
|
||||||
|
->disk(self::disk())
|
||||||
|
->directory($directory)
|
||||||
|
->acceptedFileTypes(self::ALLOWED_MIMES)
|
||||||
|
->maxSize(self::MAX_SIZE_KILOBYTES)
|
||||||
|
->rules(self::validationRules())
|
||||||
|
->validationMessages(self::validationMessages())
|
||||||
|
->getUploadedFileNameForStorageUsing(
|
||||||
|
fn ($file): string => (string) str()->uuid().'.'.$file->getClientOriginalExtension(),
|
||||||
|
)
|
||||||
|
->saveUploadedFileUsing(function (BaseFileUpload $component, TemporaryUploadedFile $file): ?string {
|
||||||
|
$path = $component->saveUploadedFile($file);
|
||||||
|
|
||||||
|
if (filled($path)) {
|
||||||
|
ResponsiveImage::generate((string) $path, $component->getDiskName());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
})
|
||||||
|
->deleteUploadedFileUsing(function (BaseFileUpload $component, string $file): void {
|
||||||
|
ResponsiveImage::delete($file, $component->getDiskName());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function altTextField(string $name, string $imageField, string $label = 'Texto alternativo'): TextInput
|
||||||
|
{
|
||||||
|
return TextInput::make($name)
|
||||||
|
->label($label)
|
||||||
|
->required(fn (Get $get): bool => filled($get($imageField)))
|
||||||
|
->validationMessages([
|
||||||
|
'required' => 'O texto alternativo é obrigatório quando uma imagem é enviada.',
|
||||||
|
])
|
||||||
|
->maxLength(255);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<File|string>
|
||||||
|
*/
|
||||||
|
public static function validationRules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
File::types(self::ALLOWED_EXTENSIONS)->max(self::MAX_SIZE_KILOBYTES),
|
||||||
|
'extensions:'.implode(',', self::ALLOWED_EXTENSIONS),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function validationMessages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => 'A imagem enviada é inválida.',
|
||||||
|
'mimes' => 'A imagem deve ser um arquivo JPG, JPEG, PNG ou WEBP.',
|
||||||
|
'mimetypes' => 'A imagem deve ser um arquivo JPG, JPEG, PNG ou WEBP.',
|
||||||
|
'extensions' => 'A imagem deve ser um arquivo JPG, JPEG, PNG ou WEBP.',
|
||||||
|
'max' => 'A imagem não pode ter mais de 10 MB.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function disk(): string
|
||||||
|
{
|
||||||
|
return config('filesystems.default') === 's3' ? 's3' : 'public';
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/Support/ResponsiveImage.php
Normal file
151
app/Support/ResponsiveImage.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Intervention\Image\Drivers\Gd\Driver;
|
||||||
|
use Intervention\Image\ImageManager;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class ResponsiveImage
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
public const WIDTHS = [480, 960, 1440];
|
||||||
|
|
||||||
|
public static function generate(string $path, ?string $disk = null): void
|
||||||
|
{
|
||||||
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
|
if (! $filesystem->exists($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manager = new ImageManager(new Driver);
|
||||||
|
$contents = $filesystem->get($path);
|
||||||
|
|
||||||
|
if ($contents === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
foreach (self::WIDTHS as $width) {
|
||||||
|
$variantPath = self::variantPath($path, $width);
|
||||||
|
$variant = $manager->read($contents);
|
||||||
|
|
||||||
|
if ($variant->width() > $width) {
|
||||||
|
$variant->scale(width: $width);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = match ($extension) {
|
||||||
|
'png' => $variant->toPng(),
|
||||||
|
'webp' => $variant->toWebp(quality: 82),
|
||||||
|
default => $variant->toJpeg(quality: 82),
|
||||||
|
};
|
||||||
|
|
||||||
|
$filesystem->put($variantPath, (string) $encoded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deleteVariants(string $path, ?string $disk = null): void
|
||||||
|
{
|
||||||
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
|
foreach (self::WIDTHS as $width) {
|
||||||
|
$variantPath = self::variantPath($path, $width);
|
||||||
|
|
||||||
|
if ($filesystem->exists($variantPath)) {
|
||||||
|
$filesystem->delete($variantPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function delete(string $path, ?string $disk = null): void
|
||||||
|
{
|
||||||
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
|
self::deleteVariants($path, $disk);
|
||||||
|
|
||||||
|
if ($filesystem->exists($path)) {
|
||||||
|
$filesystem->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function replace(string $previousPath, string $newPath, ?string $disk = null): void
|
||||||
|
{
|
||||||
|
if ($previousPath !== '' && $previousPath !== $newPath) {
|
||||||
|
self::delete($previousPath, $disk);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::generate($newPath, $disk);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function variantPath(string $path, int $width): string
|
||||||
|
{
|
||||||
|
$directory = trim(dirname($path), '.');
|
||||||
|
$filename = pathinfo($path, PATHINFO_FILENAME);
|
||||||
|
$extension = pathinfo($path, PATHINFO_EXTENSION);
|
||||||
|
$variantName = $filename.'-'.$width.($extension !== '' ? '.'.$extension : '');
|
||||||
|
|
||||||
|
return $directory === '' ? $variantName : $directory.'/'.$variantName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{path: string, width: int}>
|
||||||
|
*/
|
||||||
|
public static function availableVariants(string $path, ?string $disk = null): array
|
||||||
|
{
|
||||||
|
$filesystem = self::filesystem($disk);
|
||||||
|
$variants = [];
|
||||||
|
|
||||||
|
foreach (self::WIDTHS as $width) {
|
||||||
|
$variantPath = self::variantPath($path, $width);
|
||||||
|
|
||||||
|
if ($filesystem->exists($variantPath)) {
|
||||||
|
$variants[] = [
|
||||||
|
'path' => $variantPath,
|
||||||
|
'width' => $width,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $variants;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{width: int, height: int}|null
|
||||||
|
*/
|
||||||
|
public static function dimensions(string $path, ?string $disk = null): ?array
|
||||||
|
{
|
||||||
|
$filesystem = self::filesystem($disk);
|
||||||
|
|
||||||
|
if (! $filesystem->exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$contents = $filesystem->get($path);
|
||||||
|
|
||||||
|
if ($contents === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = (new ImageManager(new Driver))->read($contents);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'width' => $image->width(),
|
||||||
|
'height' => $image->height(),
|
||||||
|
];
|
||||||
|
} catch (Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function filesystem(?string $disk): Filesystem
|
||||||
|
{
|
||||||
|
return Storage::disk($disk ?? PublicImageUploadRules::disk());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
"filament/filament": "^5.0",
|
"filament/filament": "^5.0",
|
||||||
|
"intervention/image": "^3.0",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/tinker": "^3.0"
|
"laravel/tinker": "^3.0"
|
||||||
},
|
},
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
"vendor/bin/pint --test"
|
"vendor/bin/pint --test"
|
||||||
],
|
],
|
||||||
"phpstan": [
|
"phpstan": [
|
||||||
"vendor/bin/phpstan analyse --memory-limit=1G"
|
"vendor/bin/phpstan analyse --memory-limit=1G --debug"
|
||||||
],
|
],
|
||||||
"security-audit": [
|
"security-audit": [
|
||||||
"composer audit --no-interaction"
|
"composer audit --no-interaction"
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
"@test"
|
"@test"
|
||||||
],
|
],
|
||||||
"visual:update": [
|
"visual:update": [
|
||||||
"@php artisan test --testsuite=Browser -- --update-snapshots"
|
"@php artisan test --testsuite=Browser --update-snapshots"
|
||||||
],
|
],
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
|
|||||||
146
composer.lock
generated
146
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "f86a5740384eb06ae059db6a1d6b3d7f",
|
"content-hash": "b7cd97df13085158d9ecb6ad877a600f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -2026,6 +2026,150 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-07-17T13:53:03+00:00"
|
"time": "2026-07-17T13:53:03+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "intervention/gif",
|
||||||
|
"version": "4.2.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Intervention/gif.git",
|
||||||
|
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c",
|
||||||
|
"reference": "c3598a16ebe7690cd55640c44144a9df383ea73c",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
|
||||||
|
"slevomat/coding-standard": "~8.0",
|
||||||
|
"squizlabs/php_codesniffer": "^3.8"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Intervention\\Gif\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Oliver Vogel",
|
||||||
|
"email": "oliver@intervention.io",
|
||||||
|
"homepage": "https://intervention.io/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Native PHP GIF Encoder/Decoder",
|
||||||
|
"homepage": "https://github.com/intervention/gif",
|
||||||
|
"keywords": [
|
||||||
|
"animation",
|
||||||
|
"gd",
|
||||||
|
"gif",
|
||||||
|
"image"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Intervention/gif/issues",
|
||||||
|
"source": "https://github.com/Intervention/gif/tree/4.2.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://paypal.me/interventionio",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Intervention",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://ko-fi.com/interventionphp",
|
||||||
|
"type": "ko_fi"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-01-04T09:27:23+00:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "intervention/image",
|
||||||
|
"version": "3.11.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Intervention/image.git",
|
||||||
|
"reference": "cf04c8dd245697f701057c13d4bfe140d584e738"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Intervention/image/zipball/cf04c8dd245697f701057c13d4bfe140d584e738",
|
||||||
|
"reference": "cf04c8dd245697f701057c13d4bfe140d584e738",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"intervention/gif": "^4.2",
|
||||||
|
"php": "^8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "^1.6",
|
||||||
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpunit/phpunit": "^10.0 || ^11.0 || ^12.0",
|
||||||
|
"slevomat/coding-standard": "~8.0",
|
||||||
|
"squizlabs/php_codesniffer": "^4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-exif": "Recommended to be able to read EXIF data properly."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Intervention\\Image\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Oliver Vogel",
|
||||||
|
"email": "oliver@intervention.io",
|
||||||
|
"homepage": "https://intervention.io"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP Image Processing",
|
||||||
|
"homepage": "https://image.intervention.io",
|
||||||
|
"keywords": [
|
||||||
|
"gd",
|
||||||
|
"image",
|
||||||
|
"imagick",
|
||||||
|
"resize",
|
||||||
|
"thumbnail",
|
||||||
|
"watermark"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Intervention/image/issues",
|
||||||
|
"source": "https://github.com/Intervention/image/tree/3.11.8"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://paypal.me/interventionio",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/Intervention",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://ko-fi.com/interventionphp",
|
||||||
|
"type": "ko_fi"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-05-01T08:20:10+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "kirschbaum-development/eloquent-power-joins",
|
"name": "kirschbaum-development/eloquent-power-joins",
|
||||||
"version": "4.3.3",
|
"version": "4.3.3",
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ return [
|
|||||||
|
|
||||||
'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
|
'timezone' => env('APP_TIMEZONE', 'America/Fortaleza'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Frozen Clock (non-production)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When set outside production, the application clock is frozen for
|
||||||
|
| deterministic rendering (visual regression / seeded content).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'frozen_now' => env('APP_FROZEN_NOW'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Locale Configuration
|
| Application Locale Configuration
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ return [
|
|||||||
'public' => [
|
'public' => [
|
||||||
'driver' => 'local',
|
'driver' => 'local',
|
||||||
'root' => storage_path('app/public'),
|
'root' => storage_path('app/public'),
|
||||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
// Same-origin path so Pest Browser / any host:port can load media.
|
||||||
|
// Absolute URLs for OG tags should be built with url(...).
|
||||||
|
'url' => '/storage',
|
||||||
'visibility' => 'public',
|
'visibility' => 'public',
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
'report' => false,
|
'report' => false,
|
||||||
|
|||||||
51
database/factories/PortfolioCaseFactory.php
Normal file
51
database/factories/PortfolioCaseFactory.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<PortfolioCase>
|
||||||
|
*/
|
||||||
|
class PortfolioCaseFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = PortfolioCase::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$title = fake()->unique()->words(4, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => str($title)->slug()->toString(),
|
||||||
|
'summary' => fake()->sentence(),
|
||||||
|
'event_type' => 'Casamento',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => fake()->company(),
|
||||||
|
'event_date' => fake()->date(),
|
||||||
|
'challenge' => fake()->paragraph(),
|
||||||
|
'solution' => fake()->paragraph(),
|
||||||
|
'result' => fake()->paragraph(),
|
||||||
|
'cover_image_path' => 'content/fixture-cover.jpg',
|
||||||
|
'cover_image_alt' => 'Capa do caso',
|
||||||
|
'is_featured' => false,
|
||||||
|
'sort_order' => 0,
|
||||||
|
'published_at' => null,
|
||||||
|
'meta_title' => null,
|
||||||
|
'meta_description' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function published(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
database/factories/ServiceFactory.php
Normal file
50
database/factories/ServiceFactory.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Service;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Service>
|
||||||
|
*/
|
||||||
|
class ServiceFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Service::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$title = fake()->unique()->words(3, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'slug' => str($title)->slug()->toString(),
|
||||||
|
'summary' => fake()->sentence(),
|
||||||
|
'description' => fake()->paragraphs(2, true),
|
||||||
|
'cover_image_path' => null,
|
||||||
|
'cover_image_alt' => null,
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function published(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function featured(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'is_featured' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
database/factories/TestimonialFactory.php
Normal file
47
database/factories/TestimonialFactory.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Testimonial>
|
||||||
|
*/
|
||||||
|
class TestimonialFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Testimonial::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quote' => fake()->paragraph(),
|
||||||
|
'author_name' => fake()->name(),
|
||||||
|
'context' => fake()->optional()->jobTitle(),
|
||||||
|
'photo_path' => null,
|
||||||
|
'photo_alt' => null,
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_featured' => false,
|
||||||
|
'published_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function published(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function featured(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'is_featured' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('site_settings', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('brand_name');
|
||||||
|
$table->string('hero_eyebrow')->nullable();
|
||||||
|
$table->string('hero_title');
|
||||||
|
$table->text('hero_subtitle');
|
||||||
|
$table->string('hero_cta_label');
|
||||||
|
$table->text('about_summary')->nullable();
|
||||||
|
$table->string('email');
|
||||||
|
$table->string('phone');
|
||||||
|
$table->string('city')->nullable();
|
||||||
|
$table->jsonb('social_links')->default('[]');
|
||||||
|
$table->string('default_meta_title');
|
||||||
|
$table->text('default_meta_description');
|
||||||
|
$table->string('default_og_image_path')->nullable();
|
||||||
|
$table->string('default_og_image_alt')->nullable();
|
||||||
|
$table->boolean('analytics_enabled')->default(false);
|
||||||
|
$table->text('analytics_script')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('site_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('services', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('summary');
|
||||||
|
$table->text('description');
|
||||||
|
$table->string('cover_image_path')->nullable();
|
||||||
|
$table->string('cover_image_alt')->nullable();
|
||||||
|
$table->integer('sort_order')->default(0)->index();
|
||||||
|
$table->boolean('is_featured')->default(false)->index();
|
||||||
|
$table->timestamp('published_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('services');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('portfolio_cases', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('summary');
|
||||||
|
$table->string('event_type');
|
||||||
|
$table->string('city')->nullable();
|
||||||
|
$table->string('venue')->nullable();
|
||||||
|
$table->date('event_date')->nullable();
|
||||||
|
$table->text('challenge');
|
||||||
|
$table->text('solution');
|
||||||
|
$table->text('result')->nullable();
|
||||||
|
$table->string('cover_image_path');
|
||||||
|
$table->string('cover_image_alt');
|
||||||
|
$table->boolean('is_featured')->default(false)->index();
|
||||||
|
$table->integer('sort_order')->default(0)->index();
|
||||||
|
$table->timestamp('published_at')->nullable()->index();
|
||||||
|
$table->string('meta_title')->nullable();
|
||||||
|
$table->text('meta_description')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('portfolio_cases');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('portfolio_images', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('portfolio_case_id')->constrained('portfolio_cases')->cascadeOnDelete();
|
||||||
|
$table->string('path');
|
||||||
|
$table->string('alt_text');
|
||||||
|
$table->string('caption')->nullable();
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['portfolio_case_id', 'sort_order']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('portfolio_images');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('testimonials', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->text('quote');
|
||||||
|
$table->string('author_name');
|
||||||
|
$table->string('context')->nullable();
|
||||||
|
$table->string('photo_path')->nullable();
|
||||||
|
$table->string('photo_alt')->nullable();
|
||||||
|
$table->integer('sort_order')->default(0);
|
||||||
|
$table->boolean('is_featured')->default(false)->index();
|
||||||
|
$table->timestamp('published_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('testimonials');
|
||||||
|
}
|
||||||
|
};
|
||||||
214
database/seeders/ContentSeeder.php
Normal file
214
database/seeders/ContentSeeder.php
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\PortfolioImage;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class ContentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
private const SEED_TIMESTAMP = '2026-01-15 10:00:00';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->seedSiteSettings();
|
||||||
|
$this->seedServices();
|
||||||
|
$this->seedPortfolioCases();
|
||||||
|
$this->seedTestimonials();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedSiteSettings(): void
|
||||||
|
{
|
||||||
|
SiteSetting::query()->updateOrCreate([], [
|
||||||
|
'brand_name' => 'Amare Assessoria',
|
||||||
|
'hero_eyebrow' => 'Assessoria de eventos',
|
||||||
|
'hero_title' => 'Celebrações com propósito',
|
||||||
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos em Fortaleza.',
|
||||||
|
'hero_cta_label' => 'Solicitar orçamento',
|
||||||
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis.',
|
||||||
|
'email' => 'contato@amare.local',
|
||||||
|
'phone' => '(85) 99999-9999',
|
||||||
|
'city' => 'Fortaleza, CE',
|
||||||
|
'social_links' => [
|
||||||
|
'instagram' => 'https://instagram.com/amare',
|
||||||
|
],
|
||||||
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
||||||
|
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'content/og/og-default.jpg'),
|
||||||
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
|
'analytics_enabled' => false,
|
||||||
|
'analytics_script' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedServices(): void
|
||||||
|
{
|
||||||
|
$services = [
|
||||||
|
[
|
||||||
|
'title' => 'Casamentos',
|
||||||
|
'slug' => 'casamentos',
|
||||||
|
'summary' => 'Planejamento completo do grande dia.',
|
||||||
|
'description' => 'Do briefing ao último brinde, cuidamos de cada detalhe do casamento.',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Eventos corporativos',
|
||||||
|
'slug' => 'eventos-corporativos',
|
||||||
|
'summary' => 'Experiências alinhadas à marca.',
|
||||||
|
'description' => 'Lançamentos, convenções e encontros corporativos com operação impecável.',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Celebrações intimistas',
|
||||||
|
'slug' => 'celebracoes-intimistas',
|
||||||
|
'summary' => 'Encontros especiais em escala reduzida.',
|
||||||
|
'description' => 'Aniversários, mini weddings e jantares com curadoria editorial.',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($services as $service) {
|
||||||
|
Service::query()->updateOrCreate(
|
||||||
|
['slug' => $service['slug']],
|
||||||
|
[
|
||||||
|
...$service,
|
||||||
|
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'content/services/'.$service['slug'].'.jpg'),
|
||||||
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedPortfolioCases(): void
|
||||||
|
{
|
||||||
|
$cases = [
|
||||||
|
[
|
||||||
|
'title' => 'Casamento Ana e Lucas',
|
||||||
|
'slug' => 'casamento-ana-lucas',
|
||||||
|
'summary' => 'Cerimônia ao ar livre em Fortaleza.',
|
||||||
|
'event_type' => 'Casamento',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => 'Espaço Jardim Atlântico',
|
||||||
|
'event_date' => '2025-11-20',
|
||||||
|
'challenge' => 'Integrar cerimônia e recepção em áreas distintas.',
|
||||||
|
'solution' => 'Operação sincronizada com sinalização e timing detalhado.',
|
||||||
|
'result' => 'Experiência fluida para 180 convidados.',
|
||||||
|
'sort_order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Lançamento Verano',
|
||||||
|
'slug' => 'lancamento-verano',
|
||||||
|
'summary' => 'Evento corporativo de lançamento de coleção.',
|
||||||
|
'event_type' => 'Corporativo',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => 'Centro de Convenções',
|
||||||
|
'event_date' => '2025-09-10',
|
||||||
|
'challenge' => 'Ativar marca em ambiente multiestação.',
|
||||||
|
'solution' => 'Fluxo de convidados e fornecedores com cronograma minuto a minuto.',
|
||||||
|
'result' => 'Alta percepção de marca e cobertura de imprensa.',
|
||||||
|
'sort_order' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Mini wedding Marina',
|
||||||
|
'slug' => 'mini-wedding-marina',
|
||||||
|
'summary' => 'Celebração intimista à beira-mar.',
|
||||||
|
'event_type' => 'Mini wedding',
|
||||||
|
'city' => 'Caucaia',
|
||||||
|
'venue' => 'Pousada da Praia',
|
||||||
|
'event_date' => '2025-06-02',
|
||||||
|
'challenge' => 'Clima e logística em área externa.',
|
||||||
|
'solution' => 'Plano B estruturado e fornecedores locais alinhados.',
|
||||||
|
'result' => 'Cerimônia emocionante com 40 convidados.',
|
||||||
|
'sort_order' => 3,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cases as $caseData) {
|
||||||
|
$case = PortfolioCase::query()->updateOrCreate(
|
||||||
|
['slug' => $caseData['slug']],
|
||||||
|
[
|
||||||
|
...$caseData,
|
||||||
|
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'content/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
|
'is_featured' => true,
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
PortfolioImage::query()->where('portfolio_case_id', $case->id)->delete();
|
||||||
|
|
||||||
|
foreach ([1, 2] as $index) {
|
||||||
|
PortfolioImage::query()->create([
|
||||||
|
'portfolio_case_id' => $case->id,
|
||||||
|
'path' => $this->copyFixture('gallery.jpg', 'content/portfolio/'.$caseData['slug'].'-gallery-'.$index.'.jpg'),
|
||||||
|
'alt_text' => 'Galeria '.$caseData['title'].' '.$index,
|
||||||
|
'caption' => $index === 1 ? 'Detalhe da decoração' : null,
|
||||||
|
'sort_order' => $index,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTestimonials(): void
|
||||||
|
{
|
||||||
|
$testimonials = [
|
||||||
|
[
|
||||||
|
'quote' => 'A Amare transformou nosso casamento em uma experiência inesquecível.',
|
||||||
|
'author_name' => 'Ana Souza',
|
||||||
|
'context' => 'Noiva',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Profissionalismo do início ao fim no lançamento da nossa coleção.',
|
||||||
|
'author_name' => 'Marcos Lima',
|
||||||
|
'context' => 'Diretor de marketing',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quote' => 'Cuidaram de cada detalhe com sensibilidade e precisão.',
|
||||||
|
'author_name' => 'Marina Costa',
|
||||||
|
'context' => 'Anfitriã',
|
||||||
|
'sort_order' => 3,
|
||||||
|
'is_featured' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($testimonials as $testimonial) {
|
||||||
|
Testimonial::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'author_name' => $testimonial['author_name'],
|
||||||
|
'quote' => $testimonial['quote'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
...$testimonial,
|
||||||
|
'photo_path' => $this->copyFixture('testimonial.jpg', 'content/testimonials/'.str($testimonial['author_name'])->slug().'.jpg'),
|
||||||
|
'photo_alt' => 'Foto de '.$testimonial['author_name'],
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
|
{
|
||||||
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
|
Storage::disk('public')->put($destination, File::get($source));
|
||||||
|
|
||||||
|
return $destination;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,5 +35,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->call(ContentSeeder::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
172
database/seeders/VisualContentSeeder.php
Normal file
172
database/seeders/VisualContentSeeder.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\PortfolioCase;
|
||||||
|
use App\Models\PortfolioImage;
|
||||||
|
use App\Models\Service;
|
||||||
|
use App\Models\SiteSetting;
|
||||||
|
use App\Models\Testimonial;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic content for visual regression. Keep separate from ContentSeeder demo data.
|
||||||
|
*/
|
||||||
|
class VisualContentSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public const FROZEN_NOW = '2026-03-15T12:00:00-03:00';
|
||||||
|
|
||||||
|
private const SEED_TIMESTAMP = '2026-03-15 12:00:00';
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow(Carbon::parse(self::FROZEN_NOW));
|
||||||
|
|
||||||
|
$this->seedSiteSettings();
|
||||||
|
$this->seedServices();
|
||||||
|
$this->seedPortfolioCases();
|
||||||
|
$this->seedTestimonials();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedSiteSettings(): void
|
||||||
|
{
|
||||||
|
SiteSetting::query()->updateOrCreate([], [
|
||||||
|
'brand_name' => 'Amare Assessoria',
|
||||||
|
'hero_eyebrow' => 'Assessoria de eventos',
|
||||||
|
'hero_title' => 'Celebrações com propósito',
|
||||||
|
'hero_subtitle' => 'Planejamento completo para casamentos e eventos corporativos em Fortaleza.',
|
||||||
|
'hero_cta_label' => 'Solicitar orçamento',
|
||||||
|
'about_summary' => 'Assessoria boutique especializada em experiências memoráveis.',
|
||||||
|
'email' => 'contato@amare.local',
|
||||||
|
'phone' => '(85) 99999-9999',
|
||||||
|
'city' => 'Fortaleza, CE',
|
||||||
|
'social_links' => [
|
||||||
|
'instagram' => 'https://instagram.com/amare',
|
||||||
|
],
|
||||||
|
'default_meta_title' => 'Amare Assessoria de Eventos',
|
||||||
|
'default_meta_description' => 'Assessoria premium para casamentos e eventos corporativos.',
|
||||||
|
'default_og_image_path' => $this->copyFixture('og-default.jpg', 'visual/og/og-default.jpg'),
|
||||||
|
'default_og_image_alt' => 'Identidade visual da Amare Assessoria de Eventos',
|
||||||
|
'analytics_enabled' => false,
|
||||||
|
'analytics_script' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedServices(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
[
|
||||||
|
'title' => 'Casamentos',
|
||||||
|
'slug' => 'casamentos',
|
||||||
|
'summary' => 'Planejamento completo do grande dia.',
|
||||||
|
'description' => 'Do briefing ao último brinde, cuidamos de cada detalhe do casamento.',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Eventos corporativos',
|
||||||
|
'slug' => 'eventos-corporativos',
|
||||||
|
'summary' => 'Experiências alinhadas à marca.',
|
||||||
|
'description' => 'Lançamentos, convenções e encontros corporativos com operação impecável.',
|
||||||
|
'sort_order' => 2,
|
||||||
|
'is_featured' => true,
|
||||||
|
],
|
||||||
|
] as $service) {
|
||||||
|
Service::query()->updateOrCreate(
|
||||||
|
['slug' => $service['slug']],
|
||||||
|
[
|
||||||
|
...$service,
|
||||||
|
'cover_image_path' => $this->copyFixture('service-cover.jpg', 'visual/services/'.$service['slug'].'.jpg'),
|
||||||
|
'cover_image_alt' => 'Capa do serviço '.$service['title'],
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedPortfolioCases(): void
|
||||||
|
{
|
||||||
|
$cases = [
|
||||||
|
[
|
||||||
|
'title' => 'Casamento Ana e Lucas',
|
||||||
|
'slug' => 'casamento-ana-lucas',
|
||||||
|
'summary' => 'Cerimônia ao ar livre em Fortaleza.',
|
||||||
|
'event_type' => 'Casamento',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => 'Espaço Jardim Atlântico',
|
||||||
|
'event_date' => '2025-11-20',
|
||||||
|
'challenge' => 'Integrar cerimônia e recepção em áreas distintas.',
|
||||||
|
'solution' => 'Operação sincronizada com sinalização e timing detalhado.',
|
||||||
|
'result' => 'Experiência fluida para 180 convidados.',
|
||||||
|
'sort_order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'title' => 'Lançamento Verano',
|
||||||
|
'slug' => 'lancamento-verano',
|
||||||
|
'summary' => 'Evento corporativo de lançamento de coleção.',
|
||||||
|
'event_type' => 'Corporativo',
|
||||||
|
'city' => 'Fortaleza',
|
||||||
|
'venue' => 'Centro de Convenções',
|
||||||
|
'event_date' => '2025-09-10',
|
||||||
|
'challenge' => 'Ativar marca em ambiente multiestação.',
|
||||||
|
'solution' => 'Fluxo de convidados e fornecedores com cronograma minuto a minuto.',
|
||||||
|
'result' => 'Alta percepção de marca e cobertura de imprensa.',
|
||||||
|
'sort_order' => 2,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($cases as $caseData) {
|
||||||
|
$case = PortfolioCase::query()->updateOrCreate(
|
||||||
|
['slug' => $caseData['slug']],
|
||||||
|
[
|
||||||
|
...$caseData,
|
||||||
|
'cover_image_path' => $this->copyFixture('portfolio-cover.jpg', 'visual/portfolio/'.$caseData['slug'].'-cover.jpg'),
|
||||||
|
'cover_image_alt' => 'Capa do caso '.$caseData['title'],
|
||||||
|
'is_featured' => true,
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
PortfolioImage::query()->where('portfolio_case_id', $case->id)->delete();
|
||||||
|
|
||||||
|
PortfolioImage::query()->create([
|
||||||
|
'portfolio_case_id' => $case->id,
|
||||||
|
'path' => $this->copyFixture('gallery.jpg', 'visual/portfolio/'.$caseData['slug'].'-gallery-1.jpg'),
|
||||||
|
'alt_text' => 'Galeria '.$caseData['title'].' 1',
|
||||||
|
'caption' => 'Detalhe da decoração',
|
||||||
|
'sort_order' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTestimonials(): void
|
||||||
|
{
|
||||||
|
Testimonial::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'author_name' => 'Ana Souza',
|
||||||
|
'quote' => 'A Amare transformou nosso casamento em uma experiência inesquecível.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'context' => 'Noiva',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_featured' => true,
|
||||||
|
'photo_path' => $this->copyFixture('testimonial.jpg', 'visual/testimonials/ana-souza.jpg'),
|
||||||
|
'photo_alt' => 'Foto de Ana Souza',
|
||||||
|
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function copyFixture(string $fixtureName, string $destination): string
|
||||||
|
{
|
||||||
|
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||||
|
Storage::disk('public')->put($destination, File::get($source));
|
||||||
|
|
||||||
|
return $destination;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
docker/entrypoint.sh
Normal file → Executable file
3
docker/entrypoint.sh
Normal file → Executable file
@@ -8,6 +8,9 @@ fi
|
|||||||
|
|
||||||
mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache
|
mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache
|
||||||
|
|
||||||
|
# Idempotent public disk symlink for serving uploaded media from the container.
|
||||||
|
php artisan storage:link --force --no-interaction
|
||||||
|
|
||||||
php artisan package:discover --ansi
|
php artisan package:discover --ansi
|
||||||
php artisan config:cache
|
php artisan config:cache
|
||||||
php artisan route:cache
|
php artisan route:cache
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-29
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Fase 0 entregou Laravel 13, Filament 5 em `/admin`, design tokens, autenticação interna e pipeline CI. O repositório possui apenas home placeholder em [routes/web.php](../../routes/web.php). Esta change implementa a **metade CMS** da Fase 1 (SPEC §18): persistência e gestão Filament de conteúdo, sem rotas públicas.
|
||||||
|
|
||||||
|
Padrões existentes a seguir:
|
||||||
|
|
||||||
|
- Resources Filament com form/table separados ([app/Filament/Resources/Users/](../../app/Filament/Resources/Users/))
|
||||||
|
- Policies admin-only ([app/Policies/UserPolicy.php](../../app/Policies/UserPolicy.php))
|
||||||
|
- `declare(strict_types=1);` em todo PHP próprio
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Assessora admin gerencia settings, serviços, portfólio e depoimentos no Filament.
|
||||||
|
- Dados persistidos conforme SPEC §8.2, prontos para consumo pela change `build-public-site`.
|
||||||
|
- Feature tests locais em PostgreSQL (jsonb, constraints reais).
|
||||||
|
- Seed determinístico parcial para desenvolvimento e testes futuros.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Rotas públicas, home, SEO, sitemap, snapshots visuais, briefing.
|
||||||
|
- Actions de publicação com auditoria (`PublishPortfolioCase` etc.) — adiadas até Fase 5.
|
||||||
|
- Variantes responsivas de imagem, page builder, categorias dinâmicas.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Publicação via `published_at` único
|
||||||
|
|
||||||
|
**Decisão:** `published_at` nullable timestamp é a única fonte de verdade. Scope `published()` no model (`whereNotNull('published_at')`). Sem coluna `is_published`.
|
||||||
|
|
||||||
|
**Alternativa rejeitada:** boolean `is_published` + `published_at` — viola SPEC §1.1 (não persistir status derivável).
|
||||||
|
|
||||||
|
### 2. Mídia em disco `public` local, S3 em produção
|
||||||
|
|
||||||
|
**Decisão:** uploads de capa/galeria/foto usam disco Laravel configurável via `FILESYSTEM_DISK`. Local: `public` + `php artisan storage:link`. Produção: `s3`. Path gerado com UUID/hash, nunca nome original.
|
||||||
|
|
||||||
|
**Alternativa rejeitada:** blobs no PostgreSQL — proibido por SPEC §6.4.
|
||||||
|
|
||||||
|
### 3. Validação de mídia centralizada
|
||||||
|
|
||||||
|
**Decisão:** classe em `app/Support/` (ex.: `PublicImageUploadRules`) com allowlist MIME (`image/jpeg`, `image/png`, `image/webp`), extensões correspondentes, limite 10 MB, alt text obrigatório quando imagem presente. Reutilizada nos Resources Filament.
|
||||||
|
|
||||||
|
### 4. Sem Actions de domínio nesta fase
|
||||||
|
|
||||||
|
**Decisão:** Filament Resources salvam diretamente via Eloquent. Actions (`PublishPortfolioCase`, `UnpublishPortfolioCase`) só serão criadas quando auditoria (Fase 5) exigir efeito colateral transacional.
|
||||||
|
|
||||||
|
**Rationale:** SPEC §9.5 — não criar Action para CRUD sem regra adicional.
|
||||||
|
|
||||||
|
### 5. Auditoria de publicação adiada
|
||||||
|
|
||||||
|
**Decisão:** publicar/despublicar **não** grava `audit_logs` nesta change.
|
||||||
|
|
||||||
|
**Desvio documentado:** ADM-02 lista publicação como auditável, mas tabela `audit_logs` é Fase 5. Retomar em change de hardening.
|
||||||
|
|
||||||
|
### 6. Autorização admin-only para CMS
|
||||||
|
|
||||||
|
**Decisão:** Policies espelham `UserPolicy` — `$user->isAdmin()` para view/create/update/delete em conteúdo do site. Assistant recebe 403 em todos os Resources de conteúdo.
|
||||||
|
|
||||||
|
**Referência:** WEB-02 — "admin pode gerenciar; assistant não pode".
|
||||||
|
|
||||||
|
### 7. Site settings como singleton Filament Page
|
||||||
|
|
||||||
|
**Decisão:** model `SiteSetting` com método `instance()` (primeiro registro ou create default). Filament `ManageSiteSettings` page (não Resource de lista) — evita CRUD genérico de chave/valor (SPEC §WEB-06).
|
||||||
|
|
||||||
|
### 8. Slug automático a partir do título
|
||||||
|
|
||||||
|
**Decisão:** slug gerado no model (observer ou mutator) com `Str::slug()`, unique constraint no banco. Admin pode editar slug no form.
|
||||||
|
|
||||||
|
### 9. Navegação Filament
|
||||||
|
|
||||||
|
**Decisão:** grupo `Conteúdo do site` com sort order conforme SPEC §5.2:
|
||||||
|
|
||||||
|
1. Configurações (singleton page)
|
||||||
|
2. Serviços
|
||||||
|
3. Portfólio
|
||||||
|
4. Depoimentos
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| Risco | Mitigação |
|
||||||
|
|---|---|
|
||||||
|
| Uploads locais sem S3 em dev | `Storage::fake()` nos testes; documentar `storage:link` no README |
|
||||||
|
| Galeria ordenada complexa no Filament | Relation manager com `sort_order` e reorderable |
|
||||||
|
| jsonb `social_links` difere SQLite | phpunit.xml já migrado para PostgreSQL |
|
||||||
|
| Conteúdo criado sem rotas públicas | seed + testes Filament validam CMS; rotas vêm em `build-public-site` |
|
||||||
|
| Auditoria ausente em publicação | desvio explícito; retomar Fase 5 |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Rodar migrations em ordem: `site_settings` → `services` → `portfolio_cases` + `portfolio_images` → `testimonials`.
|
||||||
|
2. Executar seed de conteúdo após migrations.
|
||||||
|
3. Sem rollback de dados em produção (greenfield); `migrate:rollback` suportado em dev.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Nenhuma bloqueante. Fontes tipográficas finais da home ficam para `build-public-site`.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
A Fase 0 — Fundação está concluída e arquivada; o próximo passo do MVP é permitir que a assessora gerencie conteúdo do site no Filament antes de expor rotas públicas. Sem modelos, migrations e Resources internos para configurações, serviços, portfólio e depoimentos, a Fase 1 não pode avançar e o site permanece estático.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Criar tabelas e models conforme SPEC §8.2: `site_settings`, `services`, `portfolio_cases`, `portfolio_images`, `testimonials`.
|
||||||
|
- Implementar CMS Filament no grupo **Conteúdo do site** (SPEC §5.2) para WEB-02, WEB-03, WEB-04 e WEB-06.
|
||||||
|
- Introduzir regras compartilhadas de upload de mídia pública (allowlist MIME, limite de tamanho, path aleatório, alt text obrigatório).
|
||||||
|
- Policies admin-only para conteúdo do site; assistant não gerencia CMS (WEB-02).
|
||||||
|
- Factories e seed parcial de demonstração (3 serviços, 3 casos, 3 depoimentos, settings).
|
||||||
|
- Alinhar `phpunit.xml` para feature tests locais em PostgreSQL (delta de `quality-gates`).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Conforme [SPEC.md §4.2](../../SPEC.md) e a divisão da Fase 1:
|
||||||
|
|
||||||
|
- Rotas públicas, home editorial (WEB-01), páginas institucionais (WEB-07), briefing (WEB-05).
|
||||||
|
- SEO, sitemap, robots, snapshots visuais e testes de acessibilidade do site público.
|
||||||
|
- Page builder, CRUD de categorias, auditoria de publicação (`audit_logs` — Fase 5).
|
||||||
|
- Variantes responsivas de imagem (adiadas para change `build-public-site`).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `site-settings`: singleton tipado de configurações globais do site (WEB-06).
|
||||||
|
- `service-catalog`: CRUD de serviços publicáveis com slug único (WEB-02).
|
||||||
|
- `portfolio-cases`: casos de portfólio com galeria ordenada (WEB-03).
|
||||||
|
- `testimonials`: depoimentos publicáveis (WEB-04).
|
||||||
|
- `content-media`: validação e armazenamento compartilhado de imagens públicas (SPEC §6.4, §12.4).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `quality-gates`: feature tests locais MUST usar PostgreSQL, alinhados ao CI (SPEC §14.2, §20).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Cria**: migrations, models, factories, Policies, Filament Resources/Pages em `app/Filament/`, seeders, testes feature em `tests/Feature/Marketing/`.
|
||||||
|
- **Altera**: `phpunit.xml`, `DatabaseSeeder`, navegação do painel Filament.
|
||||||
|
- **Depende de**: specs arquivadas da fundação (`internal-authentication`, `design-tokens`, `quality-gates`).
|
||||||
|
- **Sem impacto** em rotas públicas existentes além do placeholder `/`.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public content images are validated and stored securely
|
||||||
|
|
||||||
|
The system SHALL validate public content uploads (service covers, portfolio covers/gallery, testimonial photos, site OG image) per SPEC §6.4 and §12.4. Validation MUST enforce MIME allowlist (jpeg, png, webp), matching extensions, maximum size of 10 MB, and mandatory alt text when an image is uploaded.
|
||||||
|
|
||||||
|
#### Scenario: Invalid MIME is rejected
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a file with disallowed MIME type
|
||||||
|
- **THEN** validation MUST fail with a pt-BR error message
|
||||||
|
|
||||||
|
#### Scenario: Oversized file is rejected
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads an image exceeding 10 MB
|
||||||
|
- **THEN** validation MUST fail
|
||||||
|
|
||||||
|
#### Scenario: Storage path is not derived from original filename
|
||||||
|
|
||||||
|
- **WHEN** an image is stored
|
||||||
|
- **THEN** the physical path MUST be generated (UUID/hash based)
|
||||||
|
- **AND** MUST NOT use the original upload filename as the storage key
|
||||||
|
|
||||||
|
#### Scenario: Alt text required with image
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a cover or gallery image without alt text
|
||||||
|
- **THEN** validation MUST fail
|
||||||
|
|
||||||
|
#### Scenario: Images are not stored in PostgreSQL
|
||||||
|
|
||||||
|
- **WHEN** content with images is persisted
|
||||||
|
- **THEN** only the filesystem path MUST be stored in the database
|
||||||
|
- **AND** binary image data MUST NOT be written to PostgreSQL columns
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Portfolio cases support editorial content and ordered gallery
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage portfolio cases (SPEC WEB-03) with title, unique slug, summary, event type, optional city/venue/event date, challenge, solution, optional result, cover image with alt text, featured flag, sort order, `published_at`, and optional SEO meta fields. Each case MUST support an ordered gallery via `portfolio_images` (path, alt text, optional caption, sort order).
|
||||||
|
|
||||||
|
#### Scenario: Draft case is not publicly visible
|
||||||
|
|
||||||
|
- **WHEN** a portfolio case has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published case meets acceptance criteria
|
||||||
|
|
||||||
|
- **WHEN** an admin fills required fields and sets `published_at`
|
||||||
|
- **THEN** the case MUST be included in the `published()` scope
|
||||||
|
- **AND** cover and gallery images MUST have alt text when present
|
||||||
|
|
||||||
|
#### Scenario: Gallery images maintain order
|
||||||
|
|
||||||
|
- **WHEN** an admin reorders gallery images in Filament
|
||||||
|
- **THEN** `sort_order` MUST reflect the chosen order per `portfolio_case_id`
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage portfolio
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access portfolio cases
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Cascade delete removes gallery images
|
||||||
|
|
||||||
|
- **WHEN** a portfolio case is deleted
|
||||||
|
- **THEN** associated `portfolio_images` records MUST be removed (FK cascade)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Local feature tests use PostgreSQL
|
||||||
|
|
||||||
|
The system SHALL configure `phpunit.xml` so that the Feature test suite uses PostgreSQL with the same connection parameters as CI (`DB_CONNECTION=pgsql`, host, port, database `amare_test`, credentials). Local feature tests MUST NOT default to SQLite `:memory:`.
|
||||||
|
|
||||||
|
#### Scenario: Developer runs feature tests locally
|
||||||
|
|
||||||
|
- **WHEN** a developer runs `composer test:feature` with PostgreSQL available
|
||||||
|
- **THEN** tests MUST execute against PostgreSQL
|
||||||
|
- **AND** MUST NOT silently fall back to SQLite
|
||||||
|
|
||||||
|
#### Scenario: Feature tests exercise PostgreSQL-specific types
|
||||||
|
|
||||||
|
- **WHEN** feature tests persist records with jsonb columns (e.g., `social_links`)
|
||||||
|
- **THEN** migrations and constraints MUST be validated against PostgreSQL semantics
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Services are managed in Filament with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to create, update, and delete services (SPEC WEB-02). Each service MUST have title, unique slug, summary, description, optional cover image with alt text, sort order, featured flag, and `published_at`.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished service is not publicly visible
|
||||||
|
|
||||||
|
- **WHEN** a service has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it from public queries
|
||||||
|
|
||||||
|
#### Scenario: Published service is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` on a service with required fields
|
||||||
|
- **THEN** the service MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Slug uniqueness is enforced
|
||||||
|
|
||||||
|
- **WHEN** an admin attempts to save two services with the same slug
|
||||||
|
- **THEN** validation or database constraint MUST reject the duplicate
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage services
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the services Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Delete requires confirmation
|
||||||
|
|
||||||
|
- **WHEN** an admin deletes a service in Filament
|
||||||
|
- **THEN** the UI MUST require explicit confirmation before deletion
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Site settings singleton is manageable by admin only
|
||||||
|
|
||||||
|
The system SHALL persist site-wide settings in a `site_settings` table as a typed singleton (SPEC WEB-06, §8.2). Fields MUST include brand name, hero copy (eyebrow, title, subtitle, CTA label), about summary, contact email/phone/city, social links (jsonb), default meta title/description, default OG image path and alt text, and optional analytics fields disabled by default.
|
||||||
|
|
||||||
|
#### Scenario: Admin updates site settings
|
||||||
|
|
||||||
|
- **WHEN** an admin saves the site settings form in Filament
|
||||||
|
- **THEN** the singleton record is updated
|
||||||
|
- **AND** labels and validation messages are in pt-BR
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot access site settings
|
||||||
|
|
||||||
|
- **WHEN** an assistant navigates to site settings in Filament
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Default OG image requires alt text
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a default OG image without alt text
|
||||||
|
- **THEN** validation MUST fail with a pt-BR error message
|
||||||
|
- **AND** alt text MUST remain optional when no default OG image is present
|
||||||
|
|
||||||
|
#### Scenario: Singleton avoids generic key-value store
|
||||||
|
|
||||||
|
- **WHEN** site settings are stored
|
||||||
|
- **THEN** the system MUST use typed columns on `site_settings`
|
||||||
|
- **AND** MUST NOT introduce a generic key/value configuration table
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Testimonials are managed with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text, author name, optional context, optional photo with alt text, sort order, featured flag, and `published_at`.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished testimonial is excluded
|
||||||
|
|
||||||
|
- **WHEN** a testimonial has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published testimonial is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` with required quote and author name
|
||||||
|
- **THEN** the testimonial MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage testimonials
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the testimonials Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Featured testimonials are filterable
|
||||||
|
|
||||||
|
- **WHEN** content is queried with featured filter
|
||||||
|
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
## 1. Pré-requisitos e alinhamento de testes
|
||||||
|
|
||||||
|
- [x] 1.1 Confirmar `phpunit.xml` com PostgreSQL (`amare_test`) e documentar criação do banco de teste no README
|
||||||
|
- [x] 1.2 Registrar Policies de conteúdo no `AppServiceProvider` ou descoberta automática
|
||||||
|
- [x] 1.3 Criar diretório `tests/Feature/Marketing/` para testes desta change
|
||||||
|
|
||||||
|
## 2. Content media (content-media)
|
||||||
|
|
||||||
|
- [x] 2.1 Criar `app/Support/PublicImageUploadRules.php` com allowlist MIME/extensão, limite 10 MB e regra de alt text obrigatório
|
||||||
|
- [x] 2.2 Escrever teste unitário ou feature validando rejeição de MIME inválido e arquivo oversize
|
||||||
|
- [x] 2.3 Documentar `php artisan storage:link` e disco `public` no README
|
||||||
|
- [x] 2.4 Reforçar uploads Filament com validação compartilhada de MIME, extensão e limite de 10 MB, incluindo mensagens em pt-BR e testes regressivos
|
||||||
|
|
||||||
|
## 3. Site settings (site-settings / WEB-06)
|
||||||
|
|
||||||
|
- [x] 3.1 Criar migration `site_settings` conforme SPEC §8.2 (incluindo `social_links` jsonb)
|
||||||
|
- [x] 3.2 Criar model `SiteSetting` com `instance()` singleton e casts (jsonb, timestamps)
|
||||||
|
- [x] 3.3 Criar `SiteSettingPolicy` admin-only
|
||||||
|
- [x] 3.4 Criar Filament Page `ManageSiteSettings` no grupo **Conteúdo do site** com form tipado (hero, contato, meta, OG image, analytics desabilitados por padrão)
|
||||||
|
- [x] 3.5 Escrever feature tests: admin salva settings; assistant recebe 403
|
||||||
|
- [x] 3.6 Adicionar alt text condicional à imagem OG padrão na migration, model, formulário, seed determinístico e testes
|
||||||
|
|
||||||
|
## 4. Service catalog (service-catalog / WEB-02)
|
||||||
|
|
||||||
|
- [x] 4.1 Criar migration `services` com índices em `slug`, `sort_order`, `is_featured`, `published_at`
|
||||||
|
- [x] 4.2 Criar model `Service` com scope `published()`, slug único e fillable conforme SPEC
|
||||||
|
- [x] 4.3 Criar `ServiceFactory` e `ServicePolicy` admin-only
|
||||||
|
- [x] 4.4 Criar `ServiceResource` Filament (form, table, confirmação de exclusão, upload de capa com alt text via PublicImageUploadRules)
|
||||||
|
- [x] 4.5 Escrever feature tests: slug único, publicação via `published_at`, assistant 403
|
||||||
|
|
||||||
|
## 5. Portfolio cases (portfolio-cases / WEB-03)
|
||||||
|
|
||||||
|
- [x] 5.1 Criar migrations `portfolio_cases` e `portfolio_images` (FK cascade, índice composto `portfolio_case_id, sort_order`)
|
||||||
|
- [x] 5.2 Criar models `PortfolioCase` e `PortfolioImage` com relação hasMany ordenada e scope `published()`
|
||||||
|
- [x] 5.3 Criar factories e `PortfolioCasePolicy` admin-only
|
||||||
|
- [x] 5.4 Criar `PortfolioCaseResource` com relation manager de galeria (reorderable, alt text, caption opcional)
|
||||||
|
- [x] 5.5 Escrever feature tests: publicação, ordem da galeria, cascade delete, assistant 403
|
||||||
|
|
||||||
|
## 6. Testimonials (testimonials / WEB-04)
|
||||||
|
|
||||||
|
- [x] 6.1 Criar migration `testimonials` conforme SPEC §8.2
|
||||||
|
- [x] 6.2 Criar model `Testimonial` com scope `published()` e `TestimonialFactory`
|
||||||
|
- [x] 6.3 Criar `TestimonialPolicy` e `TestimonialResource` Filament
|
||||||
|
- [x] 6.4 Escrever feature tests: publicação, featured filter, assistant 403
|
||||||
|
|
||||||
|
## 7. Seed de conteúdo de demonstração
|
||||||
|
|
||||||
|
- [x] 7.1 Criar `ContentSeeder` ou estender `DatabaseSeeder` com settings padrão, 3 serviços, 3 casos (com galeria), 3 depoimentos
|
||||||
|
- [x] 7.2 Adicionar imagens fixture versionadas em `tests/fixtures/images/` e referenciá-las no seed
|
||||||
|
- [x] 7.3 Garantir datas e conteúdo determinísticos para testes visuais futuros (SPEC §17.3)
|
||||||
|
|
||||||
|
## 8. Verificação final
|
||||||
|
|
||||||
|
- [x] 8.1 Executar `composer pint:check` e `composer phpstan`
|
||||||
|
- [x] 8.2 Executar `composer test:feature` contra PostgreSQL local
|
||||||
|
- [x] 8.3 Executar `composer quality` e corrigir falhas
|
||||||
|
- [x] 8.4 Reportar conclusão no formato SPEC §24 (requisitos WEB-02/03/04/06, alterações, testes, comandos, aceite, pendências)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-28
|
||||||
124
openspec/changes/archive/2026-07-29-setup-foundation/design.md
Normal file
124
openspec/changes/archive/2026-07-29-setup-foundation/design.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Repositório greenfield: contém [SPEC.md](../../../SPEC.md) (especificação normativa aprovada), scaffold OpenSpec e um commit inicial. Não existe aplicação Laravel, banco, CI ou contêiner.
|
||||||
|
|
||||||
|
Versões-alvo confirmadas no Packagist (jul/2026): Laravel 13.23, Filament 5.7, Livewire 4.3, Pest 4.7, Larastan 3.10, Pint 1.29. Ambiente local: PHP 8.5.8, Composer 2.9.5, Node 22, Docker 29 + Compose v5.3.
|
||||||
|
|
||||||
|
ADRs ADR-001..ADR-010 do SPEC §21 são aceitas e serão registradas em `docs/adr/` sem reabertura de decisões.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Entregar esqueleto Laravel funcional com Filament autenticado, Livewire/Tailwind configurados e Postgres local.
|
||||||
|
- Estabelecer gates de qualidade (Pint, Larastan, Pest, arch tests) e scripts Composer padronizados.
|
||||||
|
- Produzir imagem Docker FrankenPHP reproduzível com healthcheck e processos web/queue/scheduler.
|
||||||
|
- CI verde nos 5 jobs bloqueantes antes de avançar para Fase 1.
|
||||||
|
- Design tokens mínimos e layout público placeholder para validar pipeline visual futuro.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Implementar requisitos funcionais das Fases 1–5 (site, CRM, eventos, financeiro, documentos).
|
||||||
|
- Deploy em staging ou produção (adiado).
|
||||||
|
- FrankenPHP worker mode, Redis, S3 em produção, integração de e-mail transacional.
|
||||||
|
- Qualquer item listado em SPEC §4.2 "Fora do MVP".
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Bootstrap via `composer create-project` em diretório temporário
|
||||||
|
|
||||||
|
**Decisão:** Instalar Laravel 13 com `composer create-project laravel/laravel` em diretório temporário e mover arquivos para a raiz, preservando `SPEC.md`, `openspec/` e `.codex/`.
|
||||||
|
|
||||||
|
**Alternativas:** Instalar na raiz (conflita com arquivos existentes); copiar skeleton manualmente (mais erro-prone).
|
||||||
|
|
||||||
|
**Rationale:** Padrão Laravel garante estrutura correta; evita sobrescrever artefatos de spec.
|
||||||
|
|
||||||
|
### 2. PostgreSQL 17 via Docker Compose local
|
||||||
|
|
||||||
|
**Decisão:** Serviço `postgres:17` no Compose com volume nomeado, healthcheck e credenciais em `.env.example`.
|
||||||
|
|
||||||
|
**Alternativas:** SQLite local (proibido pelo SPEC §14.2 para integração); Postgres instalado no host (sem `psql` no ambiente).
|
||||||
|
|
||||||
|
**Rationale:** Alinha dev local com CI; evita diferenças SQLite/Postgres.
|
||||||
|
|
||||||
|
### 3. Sessão, cache e fila em `database`
|
||||||
|
|
||||||
|
**Decisão:** `SESSION_DRIVER=database`, `CACHE_STORE=database`, `QUEUE_CONNECTION=database`.
|
||||||
|
|
||||||
|
**Alternativas:** Redis (fora do MVP, ADR-008); file/cookie session (menos alinhado com deploy containerizado).
|
||||||
|
|
||||||
|
**Rationale:** ADR-008; sem dependência extra; migrations padrão Laravel cobrem tabelas.
|
||||||
|
|
||||||
|
### 4. Papéis via enum `UserRole` na coluna `users.role`
|
||||||
|
|
||||||
|
**Decisão:** Enum PHP `UserRole: admin|assistant` + coluna `is_active` boolean. Filament `canAccessPanel()` nega inativos.
|
||||||
|
|
||||||
|
**Alternativas:** Spatie Permission (proibido pelo SPEC §3.4); flags booleanas separadas.
|
||||||
|
|
||||||
|
**Rationale:** YAGNI; atende ADM-01 e §12.2 sem complexidade.
|
||||||
|
|
||||||
|
### 5. Design tokens como CSS custom properties + Tailwind theme extension
|
||||||
|
|
||||||
|
**Decisão:** Arquivo `resources/css/tokens.css` com custom properties; `tailwind.config.js` referencia tokens via `theme.extend`.
|
||||||
|
|
||||||
|
**Alternativas:** SCSS variables espalhadas; JSON tokens com build step extra.
|
||||||
|
|
||||||
|
**Rationale:** Centraliza SPEC §6.3; Tailwind consome nativamente; sem dependência extra.
|
||||||
|
|
||||||
|
### 6. Isolamento visual: layout público separado do painel Filament
|
||||||
|
|
||||||
|
**Decisão:** Layout Blade público em `resources/views/layouts/public.blade.php` com tokens próprios; Filament usa tema padrão do painel.
|
||||||
|
|
||||||
|
**Alternativas:** Tema Filament customizado para site (mistura concerns); component library compartilhada prematura.
|
||||||
|
|
||||||
|
**Rationale:** Mitiga conflito Tailwind 4 / Filament 5 / tema público premium.
|
||||||
|
|
||||||
|
### 7. FrankenPHP regular mode, multi-stage Dockerfile
|
||||||
|
|
||||||
|
**Decisão:** Dockerfile com stages `composer`, `frontend`, `runtime` (FrankenPHP). PHP fixado conforme suporte Laravel 13 no momento da instalação (8.4 ou 8.5). `config:cache` apenas no entrypoint/deploy, nunca em stage sem env final.
|
||||||
|
|
||||||
|
**Alternativas:** Nginx + PHP-FPM (SPEC exige FrankenPHP); worker mode (ADR-006 proíbe no MVP).
|
||||||
|
|
||||||
|
**Rationale:** ADR-006; imagem única para web/queue/scheduler (§15.2).
|
||||||
|
|
||||||
|
### 8. CI em GitHub Actions com PostgreSQL service container
|
||||||
|
|
||||||
|
**Decisão:** Workflow `.github/workflows/ci.yml` com jobs `static`, `unit`, `feature`, `browser`, `container`. Feature tests usam Postgres service; browser job builda imagem e roda Pest Browser com locale `pt-BR`, TZ `America/Fortaleza`, animações desabilitadas.
|
||||||
|
|
||||||
|
**Alternativas:** GitLab CI; CircleCI (remote já é GitHub).
|
||||||
|
|
||||||
|
**Rationale:** Remote `manoel-freitas/amore-site`; SPEC §14.1.
|
||||||
|
|
||||||
|
### 9. Estrutura de diretórios modular preparada, não populada
|
||||||
|
|
||||||
|
**Decisão:** Criar apenas diretórios quando primeiro arquivo for adicionado (SPEC §9.4). Na Fase 0, garantir `tests/Architecture/` e namespace base; não criar `app/Application/`, `app/Domain/` vazios.
|
||||||
|
|
||||||
|
**Rationale:** YAGNI; arch tests validam boundary quando Domain existir na Fase 2+.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| Risco | Mitigação |
|
||||||
|
|---|---|
|
||||||
|
| Conflito Tailwind 4 + Filament 5 + tema público | Layouts separados; Vite entries distintos se necessário |
|
||||||
|
| Snapshots visuais instáveis no CI | Imagem Linux fixa, fontes instaladas, relógio congelado, seed determinístico (preparação na Fase 1) |
|
||||||
|
| `config:cache` congela env incorreto | Cache apenas no entrypoint com env final do deploy |
|
||||||
|
| PHP 8.5 muito novo para alguma extensão | Fixar versão PHP no Dockerfile conforme matriz Laravel 13; testar build no job `container` |
|
||||||
|
| Filament panel + Livewire 4 coexistência | Seguir docs oficiais de instalação Filament 5; testes feature de login |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Bootstrap Laravel em diretório temp → mover para raiz.
|
||||||
|
2. Configurar `.env` / `.env.example` com Postgres e locale.
|
||||||
|
3. Instalar Filament, Pest, Larastan, Pint; configurar scripts Composer.
|
||||||
|
4. Adicionar Compose, Dockerfile, CI workflow.
|
||||||
|
5. Seed admin local; documentar credenciais apenas para dev.
|
||||||
|
6. Validar `composer quality` e jobs CI no PR.
|
||||||
|
|
||||||
|
**Rollback:** Reverter commit da Fase 0; repo volta ao estado spec-only.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- **Staging target:** VPS próprio, Fly.io, Render ou Railway — decisão adiada; change futura para deploy.
|
||||||
|
- **Registry de imagem:** GitHub Container Registry vs Docker Hub — definir na change de deploy.
|
||||||
|
- **Provedor S3-compatible:** necessário na Fase 1+ para mídia pública; local usa `storage/app/public` ou MinIO opcional no Compose.
|
||||||
|
- **Provedor de e-mail:** necessário na Fase 2 (notificação de leads); Fase 0 usa `log` driver ou Mailpit no Compose opcional.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
O repositório contém apenas a especificação normativa ([SPEC.md](../../SPEC.md)) e o scaffold OpenSpec, sem aplicação Laravel executável. Nenhuma fase funcional (site, CRM, eventos, financeiro) pode ser implementada com segurança sem esqueleto de projeto, gates de qualidade e imagem de contêiner reproduzível. A Fase 0 — Fundação (SPEC §18) é o pré-requisito obrigatório para validar o produto.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Bootstrap de aplicação **Laravel 13** com **Filament 5** (`/admin`), **Livewire 4**, **Tailwind/Vite** e locale `pt-BR` / timezone `America/Fortaleza`.
|
||||||
|
- **PostgreSQL** local via Docker Compose; sessão, cache e fila em `database` (ADR-008).
|
||||||
|
- Papéis internos `admin` e `assistant` via enum `UserRole` (SPEC §3.4, ADM-01); usuário inativo bloqueado no painel.
|
||||||
|
- Ferramentas de qualidade: **Pint**, **Larastan**, **Pest 4**, **Pest Browser**, testes de arquitetura (SPEC §13.6).
|
||||||
|
- Scripts Composer padronizados: `test:unit`, `test:feature`, `test:browser`, `test`, `quality` (SPEC §13.9).
|
||||||
|
- **Dockerfile** multi-stage com **FrankenPHP** em modo regular (ADR-006); processos web, queue e scheduler na mesma imagem.
|
||||||
|
- Rota pública **`GET /up`** para healthcheck (SPEC §5.1, §15.5).
|
||||||
|
- **Design tokens** mínimos centralizados para o site público (SPEC §6.3).
|
||||||
|
- **Seed de admin** local documentado (SPEC §17.2 parcial — apenas usuário admin).
|
||||||
|
- **Pipeline CI** com jobs bloqueantes: `static`, `unit`, `feature`, `browser`, `container` (SPEC §14.1).
|
||||||
|
- Índice de **ADRs aceitas** (ADR-001..ADR-010) em `docs/adr/`.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Conforme [SPEC.md §4.2](../../SPEC.md), **não** fazem parte desta change:
|
||||||
|
|
||||||
|
- Site público, CMS, briefing, CRM, eventos, fornecedores, financeiro, documentos, dashboard operacional.
|
||||||
|
- Deploy em staging ou produção (adiado; critério de saída limitado a CI verde + build de imagem + healthcheck validado no contêiner).
|
||||||
|
- Microserviços, Redis, API pública, multi-tenancy, pagamentos online, portal do cliente.
|
||||||
|
- FrankenPHP worker mode (ADR-006).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `internal-authentication`: login no painel Filament, papéis `admin`/`assistant`, bloqueio de usuário inativo, reset de senha (SPEC §3.4, ADM-01, §12.1–12.2).
|
||||||
|
- `health-check`: endpoint `GET /up` sem autenticação para verificação de disponibilidade (SPEC §5.1, §15.5).
|
||||||
|
- `design-tokens`: tokens visuais centralizados para o site público (SPEC §6.3, §6.5).
|
||||||
|
- `quality-gates`: comandos Composer, testes de arquitetura e pipeline CI bloqueante (SPEC §13.6, §13.9, §14.1–14.2).
|
||||||
|
- `container-runtime`: imagem Docker multi-stage FrankenPHP com processos web/queue/scheduler (SPEC §15.1–15.4).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- _(nenhuma — repositório sem specs existentes em `openspec/specs/`)_
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Cria**: árvore Laravel completa (`app/`, `config/`, `database/`, `resources/`, `routes/`, `tests/`), `docker/`, `Dockerfile`, `docker-compose.yml`, `.github/workflows/`, `docs/adr/`.
|
||||||
|
- **Dependências novas**: Laravel 13, Filament 5, Livewire 4, Pest 4, Larastan, Pint, FrankenPHP.
|
||||||
|
- **Infraestrutura**: PostgreSQL em Compose local; CI em GitHub Actions contra o remote `manoel-freitas/amore-site`.
|
||||||
|
- **Sem impacto** em capabilities existentes (greenfield).
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Production image uses multi-stage FrankenPHP build
|
||||||
|
|
||||||
|
The system SHALL provide a multi-stage Dockerfile that builds Composer dependencies, frontend assets, and a FrankenPHP runtime image serving `public/`.
|
||||||
|
|
||||||
|
#### Scenario: Image builds reproducibly in CI
|
||||||
|
|
||||||
|
- **WHEN** the `container` CI job builds the Docker image from a clean checkout
|
||||||
|
- **THEN** the build completes successfully and produces a runnable image
|
||||||
|
|
||||||
|
### Requirement: FrankenPHP runs in regular mode only
|
||||||
|
|
||||||
|
The system MUST NOT enable FrankenPHP worker mode in the MVP. The runtime SHALL use FrankenPHP in regular mode (ADR-006).
|
||||||
|
|
||||||
|
#### Scenario: Runtime configuration is regular mode
|
||||||
|
|
||||||
|
- **WHEN** the production image starts the web process
|
||||||
|
- **THEN** FrankenPHP serves requests in regular mode without worker persistence
|
||||||
|
|
||||||
|
### Requirement: Runtime image runs as non-root when supported
|
||||||
|
|
||||||
|
The system SHALL configure the production runtime to run as a non-root user when the base image supports it.
|
||||||
|
|
||||||
|
#### Scenario: Container process is non-root
|
||||||
|
|
||||||
|
- **WHEN** the web container is running in production configuration
|
||||||
|
- **THEN** the primary process MUST NOT run as root
|
||||||
|
|
||||||
|
### Requirement: Same image supports web queue and scheduler processes
|
||||||
|
|
||||||
|
The system SHALL use the same application image for web, queue worker, and scheduler processes with distinct commands (SPEC §15.2).
|
||||||
|
|
||||||
|
#### Scenario: Queue worker starts from application image
|
||||||
|
|
||||||
|
- **WHEN** the queue process is started with `php artisan queue:work`
|
||||||
|
- **THEN** it uses the same built image as the web process
|
||||||
|
|
||||||
|
### Requirement: Production image contains no secrets in layers
|
||||||
|
|
||||||
|
The system MUST NOT embed secrets, credentials, or private keys in Docker image layers.
|
||||||
|
|
||||||
|
#### Scenario: Image inspection finds no embedded secrets
|
||||||
|
|
||||||
|
- **WHEN** the image is built in CI
|
||||||
|
- **THEN** build arguments and layers MUST NOT contain production secrets or `.env` values
|
||||||
|
|
||||||
|
### Requirement: Container healthcheck validates application availability
|
||||||
|
|
||||||
|
The system SHALL define a container healthcheck that verifies application availability via the `/up` endpoint or equivalent boot check.
|
||||||
|
|
||||||
|
#### Scenario: Unhealthy container is detected
|
||||||
|
|
||||||
|
- **WHEN** the application inside the container fails to respond healthy on `/up`
|
||||||
|
- **THEN** the container healthcheck MUST report unhealthy status
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Design tokens are centralized for the public site
|
||||||
|
|
||||||
|
The system SHALL define minimum design tokens in a single source consumed by the public site layout and components. Tokens MUST cover typography families, font scale, spacing, border radius, container width, background/text/border/accent/state colors, shadows, and transition duration/easing.
|
||||||
|
|
||||||
|
#### Scenario: Public layout uses shared tokens
|
||||||
|
|
||||||
|
- **WHEN** a public page is rendered
|
||||||
|
- **THEN** visual properties MUST be derived from the centralized token definitions rather than arbitrary inline values
|
||||||
|
|
||||||
|
### Requirement: Public site respects reduced motion preference
|
||||||
|
|
||||||
|
The system SHALL honor `prefers-reduced-motion` by disabling or minimizing non-essential animations and transitions on the public site.
|
||||||
|
|
||||||
|
#### Scenario: User prefers reduced motion
|
||||||
|
|
||||||
|
- **WHEN** a visitor has `prefers-reduced-motion: reduce` enabled
|
||||||
|
- **THEN** the public site MUST NOT play non-essential motion effects
|
||||||
|
|
||||||
|
### Requirement: Public site meets baseline accessibility contrast
|
||||||
|
|
||||||
|
The system SHALL use color combinations on the public site that meet WCAG AA contrast requirements for text and interactive elements defined in the token palette.
|
||||||
|
|
||||||
|
#### Scenario: Primary text is readable
|
||||||
|
|
||||||
|
- **WHEN** primary body text is rendered on its background color
|
||||||
|
- **THEN** the contrast ratio MUST meet WCAG AA minimums
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public health endpoint responds without authentication
|
||||||
|
|
||||||
|
The system SHALL expose `GET /up` as a public healthcheck endpoint that does not require authentication.
|
||||||
|
|
||||||
|
#### Scenario: Application is healthy
|
||||||
|
|
||||||
|
- **WHEN** a client sends `GET /up` while the application is running normally
|
||||||
|
- **THEN** the system responds with HTTP 200 in a timely manner
|
||||||
|
|
||||||
|
#### Scenario: Health endpoint exposes no secrets
|
||||||
|
|
||||||
|
- **WHEN** a client sends `GET /up`
|
||||||
|
- **THEN** the response MUST NOT include credentials, tokens, stack traces, or environment secrets
|
||||||
|
|
||||||
|
### Requirement: Health endpoint reflects application failure
|
||||||
|
|
||||||
|
The system SHALL return a failure status when the application cannot initialize properly.
|
||||||
|
|
||||||
|
#### Scenario: Application cannot boot
|
||||||
|
|
||||||
|
- **WHEN** the application fails to boot due to misconfiguration or missing dependencies
|
||||||
|
- **THEN** the health endpoint MUST NOT return HTTP 200
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Internal users authenticate via Filament panel
|
||||||
|
|
||||||
|
The system SHALL provide authenticated access to the internal panel at `/admin` using Laravel's session-based authentication integrated with Filament 5.
|
||||||
|
|
||||||
|
#### Scenario: Active admin logs in successfully
|
||||||
|
|
||||||
|
- **WHEN** an active user with role `admin` submits valid credentials on the login page
|
||||||
|
- **THEN** the system authenticates the user and redirects to the Filament dashboard
|
||||||
|
|
||||||
|
#### Scenario: Active assistant logs in successfully
|
||||||
|
|
||||||
|
- **WHEN** an active user with role `assistant` submits valid credentials on the login page
|
||||||
|
- **THEN** the system authenticates the user and redirects to the Filament dashboard
|
||||||
|
|
||||||
|
#### Scenario: Inactive user is denied panel access
|
||||||
|
|
||||||
|
- **WHEN** a user with `is_active` set to false submits valid credentials
|
||||||
|
- **THEN** the system MUST NOT grant access to the Filament panel
|
||||||
|
|
||||||
|
### Requirement: User roles are limited to admin and assistant
|
||||||
|
|
||||||
|
The system SHALL store user roles using the `UserRole` enum with exactly two cases: `admin` and `assistant`. The system MUST NOT implement a granular permission system in the MVP.
|
||||||
|
|
||||||
|
#### Scenario: User is created with a valid role
|
||||||
|
|
||||||
|
- **WHEN** an administrator creates a user with role `admin` or `assistant`
|
||||||
|
- **THEN** the role is persisted and enforced on subsequent authorization checks
|
||||||
|
|
||||||
|
### Requirement: Email addresses are unique per user
|
||||||
|
|
||||||
|
The system SHALL enforce a unique constraint on user email addresses.
|
||||||
|
|
||||||
|
#### Scenario: Duplicate email rejected
|
||||||
|
|
||||||
|
- **WHEN** a user is created or updated with an email already assigned to another user
|
||||||
|
- **THEN** the system MUST reject the operation with a validation error
|
||||||
|
|
||||||
|
### Requirement: Password reset is available for internal users
|
||||||
|
|
||||||
|
The system SHALL support secure password reset for internal users using Laravel's built-in reset flow.
|
||||||
|
|
||||||
|
#### Scenario: User requests password reset
|
||||||
|
|
||||||
|
- **WHEN** a user submits a registered email on the password reset form
|
||||||
|
- **THEN** the system sends a reset link without revealing whether the email exists
|
||||||
|
|
||||||
|
### Requirement: Only admin manages internal users
|
||||||
|
|
||||||
|
The system SHALL restrict user management (create, update, deactivate) to users with role `admin`. Users with role `assistant` MUST NOT manage other users.
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot access user management
|
||||||
|
|
||||||
|
- **WHEN** an authenticated assistant attempts to access user management in the panel
|
||||||
|
- **THEN** the system MUST deny access via authorization policy
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Standardized Composer test scripts exist
|
||||||
|
|
||||||
|
The system SHALL expose Composer scripts equivalent to `test:unit`, `test:feature`, `test:browser`, `test`, and `quality` with the composition defined in SPEC §13.9.
|
||||||
|
|
||||||
|
#### Scenario: Developer runs full quality gate locally
|
||||||
|
|
||||||
|
- **WHEN** a developer runs `composer quality`
|
||||||
|
- **THEN** the command executes static analysis, audits, and the applicable test suites
|
||||||
|
|
||||||
|
### Requirement: Architecture tests enforce domain boundaries
|
||||||
|
|
||||||
|
The system SHALL include Pest architecture tests that verify `App\Domain` uses strict types and does not depend on `App\Filament` or `App\Livewire`.
|
||||||
|
|
||||||
|
#### Scenario: Domain layer violates boundary
|
||||||
|
|
||||||
|
- **WHEN** code in `App\Domain` imports from `App\Filament` or `App\Livewire`
|
||||||
|
- **THEN** the architecture test suite MUST fail
|
||||||
|
|
||||||
|
### Requirement: CI pipeline blocks merge on five jobs
|
||||||
|
|
||||||
|
The system SHALL run a CI pipeline with blocking jobs named `static`, `unit`, `feature`, `browser`, and `container` as defined in SPEC §14.1.
|
||||||
|
|
||||||
|
#### Scenario: Static analysis fails on pull request
|
||||||
|
|
||||||
|
- **WHEN** a pull request introduces a Pint, PHPStan/Larastan, or Composer audit failure
|
||||||
|
- **THEN** the `static` job MUST fail and block merge
|
||||||
|
|
||||||
|
#### Scenario: Feature tests use PostgreSQL
|
||||||
|
|
||||||
|
- **WHEN** the `feature` CI job runs integration tests
|
||||||
|
- **THEN** the job MUST use PostgreSQL and MUST NOT substitute SQLite
|
||||||
|
|
||||||
|
### Requirement: Browser tests run against FrankenPHP-served application
|
||||||
|
|
||||||
|
The system SHALL execute browser tests using Pest Browser/Playwright against an application served by FrankenPHP in CI.
|
||||||
|
|
||||||
|
#### Scenario: Browser job validates served application
|
||||||
|
|
||||||
|
- **WHEN** the `browser` CI job runs
|
||||||
|
- **THEN** tests execute against the built application artifact or equivalent production-like image
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
## 1. Bootstrap do projeto
|
||||||
|
|
||||||
|
- [x] 1.1 Criar app Laravel 13 via `composer create-project` em diretório temporário e mover para raiz preservando `SPEC.md`, `openspec/` e `.codex/`
|
||||||
|
- [x] 1.2 Configurar `.env` e `.env.example` com `APP_LOCALE=pt_BR`, `APP_TIMEZONE=America/Fortaleza`, `DB_CONNECTION=pgsql`
|
||||||
|
- [x] 1.3 Adicionar `declare(strict_types=1);` como convenção documentada e habilitar strict types nos arquivos PHP criados nesta fase
|
||||||
|
- [x] 1.4 Criar índice de ADRs aceitas em `docs/adr/` referenciando ADR-001..ADR-010 do SPEC §21
|
||||||
|
|
||||||
|
## 2. Banco de dados e Docker Compose local
|
||||||
|
|
||||||
|
- [x] 2.1 Adicionar `docker-compose.yml` com serviço PostgreSQL 17, volume nomeado e healthcheck
|
||||||
|
- [x] 2.2 Configurar conexão PostgreSQL no Laravel e validar `php artisan migrate` em banco limpo
|
||||||
|
- [x] 2.3 Configurar `SESSION_DRIVER=database`, `CACHE_STORE=database`, `QUEUE_CONNECTION=database`
|
||||||
|
- [x] 2.4 Documentar comandos locais (`docker compose up`, `artisan migrate`) no README
|
||||||
|
|
||||||
|
## 3. Filament, autenticação e papéis
|
||||||
|
|
||||||
|
- [x] 3.1 Instalar Filament 5 com painel em `/admin` e autenticação habilitada
|
||||||
|
- [x] 3.2 Criar migration adicionando `role` (varchar indexed) e `is_active` (boolean default true) em `users`
|
||||||
|
- [x] 3.3 Implementar enum `UserRole` (`admin`, `assistant`) e integrar ao model `User`
|
||||||
|
- [x] 3.4 Implementar `canAccessPanel()` negando usuários inativos
|
||||||
|
- [x] 3.5 Criar `UserResource` restrito a admin via Policy
|
||||||
|
- [x] 3.6 Criar `DatabaseSeeder` com usuário admin local (credenciais documentadas apenas para dev)
|
||||||
|
- [x] 3.7 Escrever feature tests: login admin, login assistant, bloqueio de inativo, assistant sem acesso a usuários
|
||||||
|
|
||||||
|
## 4. Livewire, Tailwind, Vite e design tokens
|
||||||
|
|
||||||
|
- [x] 4.1 Instalar Livewire 4 e configurar Vite + Tailwind para site público
|
||||||
|
- [x] 4.2 Criar `resources/css/tokens.css` com custom properties (tipografia, escala, espaçamento, raio, container, cores, sombras, transições)
|
||||||
|
- [x] 4.3 Estender `tailwind.config.js` para consumir tokens centralizados
|
||||||
|
- [x] 4.4 Criar layout público mínimo (`resources/views/layouts/public.blade.php`) com `prefers-reduced-motion` e contraste AA
|
||||||
|
- [x] 4.5 Criar rota `/` com página placeholder usando layout público e tokens
|
||||||
|
- [x] 4.6 Escrever teste feature validando renderização da home sem erro
|
||||||
|
|
||||||
|
## 5. Healthcheck
|
||||||
|
|
||||||
|
- [x] 5.1 Garantir rota `GET /up` respondendo HTTP 200 sem autenticação
|
||||||
|
- [x] 5.2 Validar que resposta não expõe segredos ou stack traces
|
||||||
|
- [x] 5.3 Escrever feature test para endpoint `/up`
|
||||||
|
|
||||||
|
## 6. Qualidade: Pint, Larastan, Pest, arch tests e scripts Composer
|
||||||
|
|
||||||
|
- [x] 6.1 Instalar e configurar Laravel Pint com script `composer pint` / check no CI
|
||||||
|
- [x] 6.2 Instalar Larastan/PHPStan com nível definido e script no CI job `static`
|
||||||
|
- [x] 6.3 Instalar Pest 4 e Pest Browser; configurar `phpunit.xml` / `Pest.php`
|
||||||
|
- [x] 6.4 Criar testes de arquitetura: `App\Domain` strict types, sem dependência de Filament/Livewire
|
||||||
|
- [x] 6.5 Adicionar scripts Composer: `test:unit`, `test:feature`, `test:browser`, `test`, `quality` conforme SPEC §13.9
|
||||||
|
- [x] 6.6 Configurar `composer audit` no job `static`
|
||||||
|
|
||||||
|
## 7. Docker multi-stage FrankenPHP
|
||||||
|
|
||||||
|
- [x] 7.1 Criar Dockerfile multi-stage (composer → frontend → runtime FrankenPHP regular mode)
|
||||||
|
- [x] 7.2 Fixar versão PHP compatível com Laravel 13; usuário non-root quando suportado
|
||||||
|
- [x] 7.3 Definir comandos para processos web, queue (`queue:work`) e scheduler (`schedule:work`)
|
||||||
|
- [x] 7.4 Adicionar healthcheck do contêiner apontando para `/up`
|
||||||
|
- [x] 7.5 Garantir que nenhum secret ou `.env` de produção entra em layer da imagem
|
||||||
|
- [x] 7.6 Validar build local e no CI
|
||||||
|
|
||||||
|
## 8. CI GitHub Actions
|
||||||
|
|
||||||
|
- [x] 8.1 Criar workflow `.github/workflows/ci.yml` com jobs `static`, `unit`, `feature`, `browser`, `container`
|
||||||
|
- [x] 8.2 Job `feature`: PostgreSQL service container (nunca SQLite)
|
||||||
|
- [x] 8.3 Job `browser`: build de imagem, servir via FrankenPHP, rodar Pest Browser com locale `pt_BR` e TZ `America/Fortaleza`
|
||||||
|
- [x] 8.4 Job `container`: build da imagem final + healthcheck
|
||||||
|
- [x] 8.5 Configurar cache seguro de Composer e npm nos jobs
|
||||||
|
|
||||||
|
## 9. Verificação final e critério de saída
|
||||||
|
|
||||||
|
- [x] 9.1 Executar `composer quality` localmente e corrigir falhas
|
||||||
|
- [x] 9.2 Executar `composer test:browser` (smoke mínimo: home + login)
|
||||||
|
- [x] 9.3 Confirmar critério de saída da Fase 0: pipeline CI verde + build de imagem + healthcheck validado no contêiner
|
||||||
|
- [x] 9.4 Reportar conclusão no formato SPEC §24 (requisito, alterações, testes, comandos, aceite, pendências)
|
||||||
2
openspec/changes/build-content-cms/.openspec.yaml
Normal file
2
openspec/changes/build-content-cms/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-29
|
||||||
97
openspec/changes/build-content-cms/design.md
Normal file
97
openspec/changes/build-content-cms/design.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Fase 0 entregou Laravel 13, Filament 5 em `/admin`, design tokens, autenticação interna e pipeline CI. O repositório possui apenas home placeholder em [routes/web.php](../../routes/web.php). Esta change implementa a **metade CMS** da Fase 1 (SPEC §18): persistência e gestão Filament de conteúdo, sem rotas públicas.
|
||||||
|
|
||||||
|
Padrões existentes a seguir:
|
||||||
|
|
||||||
|
- Resources Filament com form/table separados ([app/Filament/Resources/Users/](../../app/Filament/Resources/Users/))
|
||||||
|
- Policies admin-only ([app/Policies/UserPolicy.php](../../app/Policies/UserPolicy.php))
|
||||||
|
- `declare(strict_types=1);` em todo PHP próprio
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Assessora admin gerencia settings, serviços, portfólio e depoimentos no Filament.
|
||||||
|
- Dados persistidos conforme SPEC §8.2, prontos para consumo pela change `build-public-site`.
|
||||||
|
- Feature tests locais em PostgreSQL (jsonb, constraints reais).
|
||||||
|
- Seed determinístico parcial para desenvolvimento e testes futuros.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Rotas públicas, home, SEO, sitemap, snapshots visuais, briefing.
|
||||||
|
- Actions de publicação com auditoria (`PublishPortfolioCase` etc.) — adiadas até Fase 5.
|
||||||
|
- Variantes responsivas de imagem, page builder, categorias dinâmicas.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Publicação via `published_at` único
|
||||||
|
|
||||||
|
**Decisão:** `published_at` nullable timestamp é a única fonte de verdade. Scope `published()` no model (`whereNotNull('published_at')`). Sem coluna `is_published`.
|
||||||
|
|
||||||
|
**Alternativa rejeitada:** boolean `is_published` + `published_at` — viola SPEC §1.1 (não persistir status derivável).
|
||||||
|
|
||||||
|
### 2. Mídia em disco `public` local, S3 em produção
|
||||||
|
|
||||||
|
**Decisão:** uploads de capa/galeria/foto usam disco Laravel configurável via `FILESYSTEM_DISK`. Local: `public` + `php artisan storage:link`. Produção: `s3`. Path gerado com UUID/hash, nunca nome original.
|
||||||
|
|
||||||
|
**Alternativa rejeitada:** blobs no PostgreSQL — proibido por SPEC §6.4.
|
||||||
|
|
||||||
|
### 3. Validação de mídia centralizada
|
||||||
|
|
||||||
|
**Decisão:** classe em `app/Support/` (ex.: `PublicImageUploadRules`) com allowlist MIME (`image/jpeg`, `image/png`, `image/webp`), extensões correspondentes, limite 10 MB, alt text obrigatório quando imagem presente. Reutilizada nos Resources Filament.
|
||||||
|
|
||||||
|
### 4. Sem Actions de domínio nesta fase
|
||||||
|
|
||||||
|
**Decisão:** Filament Resources salvam diretamente via Eloquent. Actions (`PublishPortfolioCase`, `UnpublishPortfolioCase`) só serão criadas quando auditoria (Fase 5) exigir efeito colateral transacional.
|
||||||
|
|
||||||
|
**Rationale:** SPEC §9.5 — não criar Action para CRUD sem regra adicional.
|
||||||
|
|
||||||
|
### 5. Auditoria de publicação adiada
|
||||||
|
|
||||||
|
**Decisão:** publicar/despublicar **não** grava `audit_logs` nesta change.
|
||||||
|
|
||||||
|
**Desvio documentado:** ADM-02 lista publicação como auditável, mas tabela `audit_logs` é Fase 5. Retomar em change de hardening.
|
||||||
|
|
||||||
|
### 6. Autorização admin-only para CMS
|
||||||
|
|
||||||
|
**Decisão:** Policies espelham `UserPolicy` — `$user->isAdmin()` para view/create/update/delete em conteúdo do site. Assistant recebe 403 em todos os Resources de conteúdo.
|
||||||
|
|
||||||
|
**Referência:** WEB-02 — "admin pode gerenciar; assistant não pode".
|
||||||
|
|
||||||
|
### 7. Site settings como singleton Filament Page
|
||||||
|
|
||||||
|
**Decisão:** model `SiteSetting` com método `instance()` (primeiro registro ou create default). Filament `ManageSiteSettings` page (não Resource de lista) — evita CRUD genérico de chave/valor (SPEC §WEB-06).
|
||||||
|
|
||||||
|
### 8. Slug automático a partir do título
|
||||||
|
|
||||||
|
**Decisão:** slug gerado no model (observer ou mutator) com `Str::slug()`, unique constraint no banco. Admin pode editar slug no form.
|
||||||
|
|
||||||
|
### 9. Navegação Filament
|
||||||
|
|
||||||
|
**Decisão:** grupo `Conteúdo do site` com sort order conforme SPEC §5.2:
|
||||||
|
|
||||||
|
1. Configurações (singleton page)
|
||||||
|
2. Serviços
|
||||||
|
3. Portfólio
|
||||||
|
4. Depoimentos
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
| Risco | Mitigação |
|
||||||
|
|---|---|
|
||||||
|
| Uploads locais sem S3 em dev | `Storage::fake()` nos testes; documentar `storage:link` no README |
|
||||||
|
| Galeria ordenada complexa no Filament | Relation manager com `sort_order` e reorderable |
|
||||||
|
| jsonb `social_links` difere SQLite | phpunit.xml já migrado para PostgreSQL |
|
||||||
|
| Conteúdo criado sem rotas públicas | seed + testes Filament validam CMS; rotas vêm em `build-public-site` |
|
||||||
|
| Auditoria ausente em publicação | desvio explícito; retomar Fase 5 |
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Rodar migrations em ordem: `site_settings` → `services` → `portfolio_cases` + `portfolio_images` → `testimonials`.
|
||||||
|
2. Executar seed de conteúdo após migrations.
|
||||||
|
3. Sem rollback de dados em produção (greenfield); `migrate:rollback` suportado em dev.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Nenhuma bloqueante. Fontes tipográficas finais da home ficam para `build-public-site`.
|
||||||
42
openspec/changes/build-content-cms/proposal.md
Normal file
42
openspec/changes/build-content-cms/proposal.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
A Fase 0 — Fundação está concluída e arquivada; o próximo passo do MVP é permitir que a assessora gerencie conteúdo do site no Filament antes de expor rotas públicas. Sem modelos, migrations e Resources internos para configurações, serviços, portfólio e depoimentos, a Fase 1 não pode avançar e o site permanece estático.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Criar tabelas e models conforme SPEC §8.2: `site_settings`, `services`, `portfolio_cases`, `portfolio_images`, `testimonials`.
|
||||||
|
- Implementar CMS Filament no grupo **Conteúdo do site** (SPEC §5.2) para WEB-02, WEB-03, WEB-04 e WEB-06.
|
||||||
|
- Introduzir regras compartilhadas de upload de mídia pública (allowlist MIME, limite de tamanho, path aleatório, alt text obrigatório).
|
||||||
|
- Policies admin-only para conteúdo do site; assistant não gerencia CMS (WEB-02).
|
||||||
|
- Factories e seed parcial de demonstração (3 serviços, 3 casos, 3 depoimentos, settings).
|
||||||
|
- Alinhar `phpunit.xml` para feature tests locais em PostgreSQL (delta de `quality-gates`).
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
Conforme [SPEC.md §4.2](../../SPEC.md) e a divisão da Fase 1:
|
||||||
|
|
||||||
|
- Rotas públicas, home editorial (WEB-01), páginas institucionais (WEB-07), briefing (WEB-05).
|
||||||
|
- SEO, sitemap, robots, snapshots visuais e testes de acessibilidade do site público.
|
||||||
|
- Page builder, CRUD de categorias, auditoria de publicação (`audit_logs` — Fase 5).
|
||||||
|
- Variantes responsivas de imagem (adiadas para change `build-public-site`).
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `site-settings`: singleton tipado de configurações globais do site (WEB-06).
|
||||||
|
- `service-catalog`: CRUD de serviços publicáveis com slug único (WEB-02).
|
||||||
|
- `portfolio-cases`: casos de portfólio com galeria ordenada (WEB-03).
|
||||||
|
- `testimonials`: depoimentos publicáveis (WEB-04).
|
||||||
|
- `content-media`: validação e armazenamento compartilhado de imagens públicas (SPEC §6.4, §12.4).
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `quality-gates`: feature tests locais MUST usar PostgreSQL, alinhados ao CI (SPEC §14.2, §20).
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Cria**: migrations, models, factories, Policies, Filament Resources/Pages em `app/Filament/`, seeders, testes feature em `tests/Feature/Marketing/`.
|
||||||
|
- **Altera**: `phpunit.xml`, `DatabaseSeeder`, navegação do painel Filament.
|
||||||
|
- **Depende de**: specs arquivadas da fundação (`internal-authentication`, `design-tokens`, `quality-gates`).
|
||||||
|
- **Sem impacto** em rotas públicas existentes além do placeholder `/`.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Public content images are validated and stored securely
|
||||||
|
|
||||||
|
The system SHALL validate public content uploads (service covers, portfolio covers/gallery, testimonial photos, site OG image) per SPEC §6.4 and §12.4. Validation MUST enforce MIME allowlist (jpeg, png, webp), matching extensions, maximum size of 10 MB, and mandatory alt text when an image is uploaded.
|
||||||
|
|
||||||
|
#### Scenario: Invalid MIME is rejected
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a file with disallowed MIME type
|
||||||
|
- **THEN** validation MUST fail with a pt-BR error message
|
||||||
|
|
||||||
|
#### Scenario: Oversized file is rejected
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads an image exceeding 10 MB
|
||||||
|
- **THEN** validation MUST fail
|
||||||
|
|
||||||
|
#### Scenario: Storage path is not derived from original filename
|
||||||
|
|
||||||
|
- **WHEN** an image is stored
|
||||||
|
- **THEN** the physical path MUST be generated (UUID/hash based)
|
||||||
|
- **AND** MUST NOT use the original upload filename as the storage key
|
||||||
|
|
||||||
|
#### Scenario: Alt text required with image
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a cover or gallery image without alt text
|
||||||
|
- **THEN** validation MUST fail
|
||||||
|
|
||||||
|
#### Scenario: Images are not stored in PostgreSQL
|
||||||
|
|
||||||
|
- **WHEN** content with images is persisted
|
||||||
|
- **THEN** only the filesystem path MUST be stored in the database
|
||||||
|
- **AND** binary image data MUST NOT be written to PostgreSQL columns
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Portfolio cases support editorial content and ordered gallery
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage portfolio cases (SPEC WEB-03) with title, unique slug, summary, event type, optional city/venue/event date, challenge, solution, optional result, cover image with alt text, featured flag, sort order, `published_at`, and optional SEO meta fields. Each case MUST support an ordered gallery via `portfolio_images` (path, alt text, optional caption, sort order).
|
||||||
|
|
||||||
|
#### Scenario: Draft case is not publicly visible
|
||||||
|
|
||||||
|
- **WHEN** a portfolio case has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published case meets acceptance criteria
|
||||||
|
|
||||||
|
- **WHEN** an admin fills required fields and sets `published_at`
|
||||||
|
- **THEN** the case MUST be included in the `published()` scope
|
||||||
|
- **AND** cover and gallery images MUST have alt text when present
|
||||||
|
|
||||||
|
#### Scenario: Gallery images maintain order
|
||||||
|
|
||||||
|
- **WHEN** an admin reorders gallery images in Filament
|
||||||
|
- **THEN** `sort_order` MUST reflect the chosen order per `portfolio_case_id`
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage portfolio
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access portfolio cases
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Cascade delete removes gallery images
|
||||||
|
|
||||||
|
- **WHEN** a portfolio case is deleted
|
||||||
|
- **THEN** associated `portfolio_images` records MUST be removed (FK cascade)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Local feature tests use PostgreSQL
|
||||||
|
|
||||||
|
The system SHALL configure `phpunit.xml` so that the Feature test suite uses PostgreSQL with the same connection parameters as CI (`DB_CONNECTION=pgsql`, host, port, database `amare_test`, credentials). Local feature tests MUST NOT default to SQLite `:memory:`.
|
||||||
|
|
||||||
|
#### Scenario: Developer runs feature tests locally
|
||||||
|
|
||||||
|
- **WHEN** a developer runs `composer test:feature` with PostgreSQL available
|
||||||
|
- **THEN** tests MUST execute against PostgreSQL
|
||||||
|
- **AND** MUST NOT silently fall back to SQLite
|
||||||
|
|
||||||
|
#### Scenario: Feature tests exercise PostgreSQL-specific types
|
||||||
|
|
||||||
|
- **WHEN** feature tests persist records with jsonb columns (e.g., `social_links`)
|
||||||
|
- **THEN** migrations and constraints MUST be validated against PostgreSQL semantics
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Services are managed in Filament with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to create, update, and delete services (SPEC WEB-02). Each service MUST have title, unique slug, summary, description, optional cover image with alt text, sort order, featured flag, and `published_at`.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished service is not publicly visible
|
||||||
|
|
||||||
|
- **WHEN** a service has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it from public queries
|
||||||
|
|
||||||
|
#### Scenario: Published service is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` on a service with required fields
|
||||||
|
- **THEN** the service MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Slug uniqueness is enforced
|
||||||
|
|
||||||
|
- **WHEN** an admin attempts to save two services with the same slug
|
||||||
|
- **THEN** validation or database constraint MUST reject the duplicate
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage services
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the services Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Delete requires confirmation
|
||||||
|
|
||||||
|
- **WHEN** an admin deletes a service in Filament
|
||||||
|
- **THEN** the UI MUST require explicit confirmation before deletion
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Site settings singleton is manageable by admin only
|
||||||
|
|
||||||
|
The system SHALL persist site-wide settings in a `site_settings` table as a typed singleton (SPEC WEB-06, §8.2). Fields MUST include brand name, hero copy (eyebrow, title, subtitle, CTA label), about summary, contact email/phone/city, social links (jsonb), default meta title/description, default OG image path and alt text, and optional analytics fields disabled by default.
|
||||||
|
|
||||||
|
#### Scenario: Admin updates site settings
|
||||||
|
|
||||||
|
- **WHEN** an admin saves the site settings form in Filament
|
||||||
|
- **THEN** the singleton record is updated
|
||||||
|
- **AND** labels and validation messages are in pt-BR
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot access site settings
|
||||||
|
|
||||||
|
- **WHEN** an assistant navigates to site settings in Filament
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Default OG image requires alt text
|
||||||
|
|
||||||
|
- **WHEN** an admin uploads a default OG image without alt text
|
||||||
|
- **THEN** validation MUST fail with a pt-BR error message
|
||||||
|
- **AND** alt text MUST remain optional when no default OG image is present
|
||||||
|
|
||||||
|
#### Scenario: Singleton avoids generic key-value store
|
||||||
|
|
||||||
|
- **WHEN** site settings are stored
|
||||||
|
- **THEN** the system MUST use typed columns on `site_settings`
|
||||||
|
- **AND** MUST NOT introduce a generic key/value configuration table
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Testimonials are managed with publication control
|
||||||
|
|
||||||
|
The system SHALL allow admins to manage testimonials (SPEC WEB-04) with quote text, author name, optional context, optional photo with alt text, sort order, featured flag, and `published_at`.
|
||||||
|
|
||||||
|
#### Scenario: Unpublished testimonial is excluded
|
||||||
|
|
||||||
|
- **WHEN** a testimonial has `published_at` null
|
||||||
|
- **THEN** the `published()` scope MUST exclude it
|
||||||
|
|
||||||
|
#### Scenario: Published testimonial is queryable
|
||||||
|
|
||||||
|
- **WHEN** an admin sets `published_at` with required quote and author name
|
||||||
|
- **THEN** the testimonial MUST be included in the `published()` scope
|
||||||
|
|
||||||
|
#### Scenario: Assistant cannot manage testimonials
|
||||||
|
|
||||||
|
- **WHEN** an assistant attempts to access the testimonials Resource
|
||||||
|
- **THEN** access MUST be denied with HTTP 403
|
||||||
|
|
||||||
|
#### Scenario: Featured testimonials are filterable
|
||||||
|
|
||||||
|
- **WHEN** content is queried with featured filter
|
||||||
|
- **THEN** records with `is_featured` true MUST be retrievable independently of sort order
|
||||||
57
openspec/changes/build-content-cms/tasks.md
Normal file
57
openspec/changes/build-content-cms/tasks.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
## 1. Pré-requisitos e alinhamento de testes
|
||||||
|
|
||||||
|
- [x] 1.1 Confirmar `phpunit.xml` com PostgreSQL (`amare_test`) e documentar criação do banco de teste no README
|
||||||
|
- [x] 1.2 Registrar Policies de conteúdo no `AppServiceProvider` ou descoberta automática
|
||||||
|
- [x] 1.3 Criar diretório `tests/Feature/Marketing/` para testes desta change
|
||||||
|
|
||||||
|
## 2. Content media (content-media)
|
||||||
|
|
||||||
|
- [x] 2.1 Criar `app/Support/PublicImageUploadRules.php` com allowlist MIME/extensão, limite 10 MB e regra de alt text obrigatório
|
||||||
|
- [x] 2.2 Escrever teste unitário ou feature validando rejeição de MIME inválido e arquivo oversize
|
||||||
|
- [x] 2.3 Documentar `php artisan storage:link` e disco `public` no README
|
||||||
|
- [x] 2.4 Reforçar uploads Filament com validação compartilhada de MIME, extensão e limite de 10 MB, incluindo mensagens em pt-BR e testes regressivos
|
||||||
|
|
||||||
|
## 3. Site settings (site-settings / WEB-06)
|
||||||
|
|
||||||
|
- [x] 3.1 Criar migration `site_settings` conforme SPEC §8.2 (incluindo `social_links` jsonb)
|
||||||
|
- [x] 3.2 Criar model `SiteSetting` com `instance()` singleton e casts (jsonb, timestamps)
|
||||||
|
- [x] 3.3 Criar `SiteSettingPolicy` admin-only
|
||||||
|
- [x] 3.4 Criar Filament Page `ManageSiteSettings` no grupo **Conteúdo do site** com form tipado (hero, contato, meta, OG image, analytics desabilitados por padrão)
|
||||||
|
- [x] 3.5 Escrever feature tests: admin salva settings; assistant recebe 403
|
||||||
|
- [x] 3.6 Adicionar alt text condicional à imagem OG padrão na migration, model, formulário, seed determinístico e testes
|
||||||
|
|
||||||
|
## 4. Service catalog (service-catalog / WEB-02)
|
||||||
|
|
||||||
|
- [x] 4.1 Criar migration `services` com índices em `slug`, `sort_order`, `is_featured`, `published_at`
|
||||||
|
- [x] 4.2 Criar model `Service` com scope `published()`, slug único e fillable conforme SPEC
|
||||||
|
- [x] 4.3 Criar `ServiceFactory` e `ServicePolicy` admin-only
|
||||||
|
- [x] 4.4 Criar `ServiceResource` Filament (form, table, confirmação de exclusão, upload de capa com alt text via PublicImageUploadRules)
|
||||||
|
- [x] 4.5 Escrever feature tests: slug único, publicação via `published_at`, assistant 403
|
||||||
|
|
||||||
|
## 5. Portfolio cases (portfolio-cases / WEB-03)
|
||||||
|
|
||||||
|
- [x] 5.1 Criar migrations `portfolio_cases` e `portfolio_images` (FK cascade, índice composto `portfolio_case_id, sort_order`)
|
||||||
|
- [x] 5.2 Criar models `PortfolioCase` e `PortfolioImage` com relação hasMany ordenada e scope `published()`
|
||||||
|
- [x] 5.3 Criar factories e `PortfolioCasePolicy` admin-only
|
||||||
|
- [x] 5.4 Criar `PortfolioCaseResource` com relation manager de galeria (reorderable, alt text, caption opcional)
|
||||||
|
- [x] 5.5 Escrever feature tests: publicação, ordem da galeria, cascade delete, assistant 403
|
||||||
|
|
||||||
|
## 6. Testimonials (testimonials / WEB-04)
|
||||||
|
|
||||||
|
- [x] 6.1 Criar migration `testimonials` conforme SPEC §8.2
|
||||||
|
- [x] 6.2 Criar model `Testimonial` com scope `published()` e `TestimonialFactory`
|
||||||
|
- [x] 6.3 Criar `TestimonialPolicy` e `TestimonialResource` Filament
|
||||||
|
- [x] 6.4 Escrever feature tests: publicação, featured filter, assistant 403
|
||||||
|
|
||||||
|
## 7. Seed de conteúdo de demonstração
|
||||||
|
|
||||||
|
- [x] 7.1 Criar `ContentSeeder` ou estender `DatabaseSeeder` com settings padrão, 3 serviços, 3 casos (com galeria), 3 depoimentos
|
||||||
|
- [x] 7.2 Adicionar imagens fixture versionadas em `tests/fixtures/images/` e referenciá-las no seed
|
||||||
|
- [x] 7.3 Garantir datas e conteúdo determinísticos para testes visuais futuros (SPEC §17.3)
|
||||||
|
|
||||||
|
## 8. Verificação final
|
||||||
|
|
||||||
|
- [x] 8.1 Executar `composer pint:check` e `composer phpstan`
|
||||||
|
- [x] 8.2 Executar `composer test:feature` contra PostgreSQL local
|
||||||
|
- [x] 8.3 Executar `composer quality` e corrigir falhas
|
||||||
|
- [x] 8.4 Reportar conclusão no formato SPEC §24 (requisitos WEB-02/03/04/06, alterações, testes, comandos, aceite, pendências)
|
||||||
2
openspec/changes/build-public-site/.openspec.yaml
Normal file
2
openspec/changes/build-public-site/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-29
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user