Compare commits
10 Commits
cursor/67d
...
feature/bu
| Author | SHA1 | Date | |
|---|---|---|---|
| 399e7be3a9 | |||
| 0adaadd9ed | |||
| 2405160046 | |||
| 27f3cee855 | |||
| 7a858b52af | |||
| 7a70b44931 | |||
| 18f9ec85e7 | |||
| 02f2fc506d | |||
| 59bc624fa8 | |||
| 8da8d19504 |
@@ -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)
|
||||||
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