Compare commits
1 Commits
246410803d
...
fix/dokplo
| Author | SHA1 | Date | |
|---|---|---|---|
| 12b34bf98c |
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
composer pint:check && composer phpstan
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Abort push when the test database is unreachable, so broken code never
|
||||
# reaches CI. Test DB settings come from phpunit.xml.
|
||||
php -r '
|
||||
$xml = @simplexml_load_file("phpunit.xml");
|
||||
if ($xml === false) {
|
||||
fwrite(STDERR, "phpunit.xml not found — aborting pre-push.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$defaults = ["DB_HOST" => "127.0.0.1", "DB_PORT" => "5432", "DB_DATABASE" => "amare_test", "DB_USERNAME" => "amare", "DB_PASSWORD" => "secret"];
|
||||
$env = [];
|
||||
foreach ($xml->php->env as $node) {
|
||||
$name = (string) $node["name"];
|
||||
if (isset($defaults[$name])) {
|
||||
$env[$name] = (string) $node["value"];
|
||||
}
|
||||
}
|
||||
$config = array_merge($defaults, $env);
|
||||
|
||||
$dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $config["DB_HOST"], $config["DB_PORT"], $config["DB_DATABASE"]);
|
||||
try {
|
||||
new PDO($dsn, $config["DB_USERNAME"], $config["DB_PASSWORD"], [PDO::ATTR_TIMEOUT => 3]);
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "\033[31mPostgreSQL is unreachable on {$config["DB_HOST"]}:{$config["DB_PORT"]} (db: {$config["DB_DATABASE"]}).\033[0m\n");
|
||||
fwrite(STDERR, "Start it with: docker compose up -d\n");
|
||||
fwrite(STDERR, "Then retry the push.\n");
|
||||
exit(1);
|
||||
}
|
||||
' || exit 1
|
||||
|
||||
composer test:unit && composer test:feature
|
||||
@@ -12,6 +12,5 @@ related_targets: ["resources/views/pages/services/index.blade.php","resources/vi
|
||||
- **Action, proof, and constraints:** ação principal é enviar briefing inicial. Prova disponível: cinco depoimentos reais de casamentos, com nomes e datas; publicação depende de autorização final. Não inventar cases corporativos, credenciais ou resultados. Fotografias permanecem ilustrativas e marcadas até chegada de acervo autorizado.
|
||||
- **Chosen direction:** “Dossiê Editorial do Evento”. Home funciona como capa e índice; serviços viram capítulos, portfólio vira cadernos de caso, sobre vira perfil editorial e contato vira ficha de briefing. Sistema visual global segue Heritage Editorial em DESIGN.md.
|
||||
- **Memorable moment:** coração facetado atua como selo editorial enquanto índice discreto acompanha capítulos e deixa serviço, método e próximo passo visíveis sem transformar página em dashboard.
|
||||
- **Motion thesis (Dossiê vivo):** Home recebe abertura autoral (selo → título → recorte de imagem → CTAs, ≤800ms). Índice lateral no desktop e linha de progresso no mobile acompanham capítulos. Serviços, portfólio e sobre usam abertura curta e revelações discretas; contato, privacidade e erros permanecem quase estáticos. Sem parallax, loops, bounce ou scroll-jacking. `prefers-reduced-motion` entrega estado final imediato.
|
||||
- **Responsive and interaction:** spreads assimétricos no desktop; sequência linear no mobile. CTAs recorrentes, navegação clara, formulário com loading, erro e sucesso, foco visível e suporte a movimento reduzido.
|
||||
- **Unresolved:** logo transparente ou vetorial; fotografias autorizadas; WhatsApp, e-mail e Instagram oficiais; textos jurídicos; autorização dos depoimentos; provas reais de eventos corporativos.
|
||||
|
||||
@@ -15,13 +15,6 @@ This is a Laravel 13 application for an event-planning consultancy. Application
|
||||
|
||||
Feature and browser tests require the `amare_test` PostgreSQL database configured in `phpunit.xml`.
|
||||
|
||||
## Git Hooks (husky)
|
||||
|
||||
Hooks live in `.husky/` and auto-install on any plain `npm install` via the `prepare` script. Note `composer setup` runs `npm install --ignore-scripts`, which skips hook installation — after setup, run `npm install` once (or `npx husky`) to activate hooks.
|
||||
|
||||
- `pre-commit`: runs `composer pint:check` and `composer phpstan`.
|
||||
- `pre-push`: gates on the `amare_test` database (settings parsed from `phpunit.xml`), blocks the push with a `docker compose up -d` hint when Postgres is unreachable, then runs `composer test:unit` and `composer test:feature`. Browser tests are CI-only (FrankenPHP container).
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -26,7 +26,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureLivewireTemporaryUploads();
|
||||
$this->freezeClockWhenConfigured();
|
||||
|
||||
View::composer('layouts.public', function (ViewInstance $view): void {
|
||||
@@ -47,22 +46,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep Livewire/Filament temp uploads on the local disk.
|
||||
*
|
||||
* When FILESYSTEM_DISK=r2, Livewire would otherwise use the S3 driver and
|
||||
* browser-PUT straight to R2 (CORS). Final media still uses the r2 disk via
|
||||
* PublicImageUploadRules. Explicit LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK wins.
|
||||
*/
|
||||
private function configureLivewireTemporaryUploads(): void
|
||||
{
|
||||
if (filled(config('livewire.temporary_file_upload.disk'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
config(['livewire.temporary_file_upload.disk' => 'local']);
|
||||
}
|
||||
|
||||
private function freezeClockWhenConfigured(): void
|
||||
{
|
||||
if ($this->app->environment('production')) {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -22,7 +23,7 @@ class ContentSeeder extends Seeder
|
||||
$this->seedSiteSettings();
|
||||
$this->seedServices();
|
||||
$this->seedPortfolioCases();
|
||||
$this->call(TestimonialsSeeder::class);
|
||||
$this->seedTestimonials();
|
||||
}
|
||||
|
||||
private function seedSiteSettings(): void
|
||||
@@ -169,6 +170,67 @@ class ContentSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
private function seedTestimonials(): void
|
||||
{
|
||||
// Real couples from depoimentos.md. Production publication still requires
|
||||
// explicit couple authorization before setting published_at outside local/demo seeds.
|
||||
$testimonials = [
|
||||
[
|
||||
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||
'author_name' => 'Jeniffer e Maick',
|
||||
'context' => 'Casamento · 06/12/2025',
|
||||
'sort_order' => 1,
|
||||
'is_featured' => true,
|
||||
],
|
||||
[
|
||||
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||
'author_name' => 'Quesia e Jhonata',
|
||||
'context' => 'Casamento · 21/12/2025',
|
||||
'sort_order' => 2,
|
||||
'is_featured' => true,
|
||||
],
|
||||
[
|
||||
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
||||
'author_name' => 'Milena e Weslley',
|
||||
'context' => 'Casamento · 13/02/2026',
|
||||
'sort_order' => 3,
|
||||
'is_featured' => false,
|
||||
],
|
||||
[
|
||||
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
||||
'author_name' => 'Raquel e Pedro',
|
||||
'context' => 'Casamento · 09/05/2026',
|
||||
'sort_order' => 4,
|
||||
'is_featured' => false,
|
||||
],
|
||||
[
|
||||
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
||||
'author_name' => 'Victoria e Pedro',
|
||||
'context' => 'Casamento · 24/06/2026',
|
||||
'sort_order' => 5,
|
||||
'is_featured' => false,
|
||||
],
|
||||
];
|
||||
|
||||
$keepAuthors = array_column($testimonials, 'author_name');
|
||||
|
||||
Testimonial::query()
|
||||
->whereNotIn('author_name', $keepAuthors)
|
||||
->delete();
|
||||
|
||||
foreach ($testimonials as $testimonial) {
|
||||
Testimonial::query()->updateOrCreate(
|
||||
['author_name' => $testimonial['author_name']],
|
||||
[
|
||||
...$testimonial,
|
||||
'photo_path' => null,
|
||||
'photo_alt' => null,
|
||||
'published_at' => Carbon::parse(self::SEED_TIMESTAMP),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function copyFixture(string $fixtureName, string $destination): string
|
||||
{
|
||||
$source = base_path('tests/fixtures/images/'.$fixtureName);
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TestimonialsSeeder extends Seeder
|
||||
{
|
||||
private const PUBLISHED_AT = '2026-08-05 00:00:00';
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$testimonials = [
|
||||
[
|
||||
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||
'author_name' => 'Jeniffer e Maick',
|
||||
'context' => 'Casamento · 06/12/2025',
|
||||
'sort_order' => 1,
|
||||
'is_featured' => true,
|
||||
],
|
||||
[
|
||||
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||
'author_name' => 'Quesia e Jhonata',
|
||||
'context' => 'Casamento · 21/12/2025',
|
||||
'sort_order' => 2,
|
||||
'is_featured' => true,
|
||||
],
|
||||
[
|
||||
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
||||
'author_name' => 'Milena e Weslley',
|
||||
'context' => 'Casamento · 13/02/2026',
|
||||
'sort_order' => 3,
|
||||
'is_featured' => false,
|
||||
],
|
||||
[
|
||||
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
||||
'author_name' => 'Raquel e Pedro',
|
||||
'context' => 'Casamento · 09/05/2026',
|
||||
'sort_order' => 4,
|
||||
'is_featured' => false,
|
||||
],
|
||||
[
|
||||
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
||||
'author_name' => 'Victoria e Pedro',
|
||||
'context' => 'Casamento · 24/06/2026',
|
||||
'sort_order' => 5,
|
||||
'is_featured' => false,
|
||||
],
|
||||
];
|
||||
|
||||
DB::transaction(function () use ($testimonials): void {
|
||||
foreach ($testimonials as $testimonial) {
|
||||
Testimonial::query()->updateOrCreate(
|
||||
['author_name' => $testimonial['author_name']],
|
||||
[
|
||||
'quote' => $testimonial['quote'],
|
||||
'context' => $testimonial['context'],
|
||||
'sort_order' => $testimonial['sort_order'],
|
||||
'is_featured' => $testimonial['is_featured'],
|
||||
'published_at' => self::PUBLISHED_AT,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
# IMAGE_TAG=staging|production|<git-sha>
|
||||
# PostgreSQL is a separate Dokploy database service (not defined here).
|
||||
# Traefik/Dokploy domains should target service `web` port 8000.
|
||||
# App services join dokploy-network so they can resolve Dokploy-managed
|
||||
# Postgres internal hosts (e.g. amare-stg-pez43e).
|
||||
|
||||
services:
|
||||
migrate:
|
||||
@@ -15,8 +13,6 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
command: ["php", "artisan", "migrate", "--force", "--no-interaction"]
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
web:
|
||||
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||
@@ -35,8 +31,6 @@ services:
|
||||
timeout: 5s
|
||||
start_period: 40s
|
||||
retries: 3
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
queue:
|
||||
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||
@@ -50,8 +44,6 @@ services:
|
||||
command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--max-time=3600"]
|
||||
stop_grace_period: 60s
|
||||
stop_signal: SIGTERM
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
scheduler:
|
||||
image: ${APP_IMAGE}:${IMAGE_TAG}
|
||||
@@ -65,9 +57,3 @@ services:
|
||||
command: ["php", "artisan", "schedule:work"]
|
||||
stop_grace_period: 30s
|
||||
stop_signal: SIGTERM
|
||||
networks:
|
||||
- dokploy-network
|
||||
|
||||
networks:
|
||||
dokploy-network:
|
||||
external: true
|
||||
|
||||
@@ -50,8 +50,6 @@ IMAGE_TAG=staging # or production
|
||||
|
||||
Point Dokploy domain(s) at service **`web`**, port **`8000`**. Do not publish PostgreSQL or host ports for app processes.
|
||||
|
||||
Compose services must join the external Docker network `dokploy-network` (declared in `docker-compose.deploy.yml`) so they can resolve the Dokploy-managed Postgres internal host (e.g. `amare-stg-pez43e`). Set `DB_HOST` to that **Internal Host** from the Dokploy database UI — not a public hostname.
|
||||
|
||||
Source can be GitHub (so Dokploy clones the compose file) or Raw paste of `docker-compose.deploy.yml`. Prefer GitHub + fixed compose path so updates stay in sync with `main`.
|
||||
|
||||
## Required Laravel env (Dokploy only)
|
||||
@@ -70,7 +68,7 @@ APP_FALLBACK_LOCALE=pt_BR
|
||||
APP_TIMEZONE=America/Fortaleza
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=<dokploy-postgres-internal-host> # Internal Host from Dokploy UI (requires dokploy-network)
|
||||
DB_HOST=<dokploy-postgres-host>
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=amare_staging
|
||||
DB_USERNAME=...
|
||||
@@ -100,26 +98,6 @@ LOG_LEVEL=warning
|
||||
|
||||
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs.
|
||||
|
||||
Livewire temporary uploads default to the **local** disk in `AppServiceProvider` (even when `FILESYSTEM_DISK=r2`), so Filament does not browser-PUT to R2. Final media still lands on R2 via `PublicImageUploadRules`. Optional override: `LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK` in Dokploy `.env` (`env_file` accepts any key — does not need a compose `environment:` entry).
|
||||
|
||||
### R2 CORS (public/media reads from JS)
|
||||
|
||||
Upload path does not need R2 CORS with the local temp-disk default. Still useful if the browser fetches R2 URLs cross-origin from JS. Origin must match `APP_URL` exactly; include `AllowedHeaders`:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://hellomanoel.com"],
|
||||
"AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag", "Content-Type"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Also enable public access / custom domain for `R2_URL` so `<img>` URLs work after save.
|
||||
|
||||
## GitHub Actions secrets
|
||||
|
||||
Repository secrets used by workflows:
|
||||
@@ -159,67 +137,27 @@ HTTP 404 from `compose.deploy` usually means the compose id is wrong (Applicatio
|
||||
|
||||
Private repos on GitHub Free do not get Environment required reviewers; human approval is the explicit `workflow_dispatch` + confirmation string. GitHub Pro Environment reviewers are optional later.
|
||||
|
||||
## First admin and authorized production seeding
|
||||
## First admin (no `db:seed` in production)
|
||||
|
||||
Seed credentials are local-only. For staging/production:
|
||||
|
||||
FrankenPHP sets `XDG_CONFIG_HOME=/config` (Caddy). PsySH/tinker then tries `/config/psysh`, which `appuser` cannot write — you get `Writing to directory /config/psysh is not allowed.` Override that env for the one-shot command:
|
||||
|
||||
```bash
|
||||
# From Dokploy → staging/production → Open terminal on `web` (or one-off run)
|
||||
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
||||
\$user = \\App\\Models\\User::query()->updateOrCreate(
|
||||
php artisan tinker --execute="
|
||||
\\App\\Models\\User::query()->updateOrCreate(
|
||||
['email' => 'admin@example.com'],
|
||||
[
|
||||
'name' => 'Admin',
|
||||
'password' => 'use-a-strong-password',
|
||||
'password' => bcrypt('use-a-strong-password'),
|
||||
'role' => 'admin',
|
||||
'is_active' => true,
|
||||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
\$user->forceFill(['email_verified_at' => now()])->save();
|
||||
echo \$user->email.PHP_EOL;
|
||||
"
|
||||
```
|
||||
|
||||
Plain password is enough: `User` casts `password` to `hashed` (and skips re-hash when value already hashed). `email_verified_at` is not mass-assignable — use `forceFill` as above.
|
||||
|
||||
Confirm:
|
||||
|
||||
```bash
|
||||
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="echo \\App\\Models\\User::query()->where('email', 'admin@example.com')->exists() ? 'ok' : 'missing';"
|
||||
```
|
||||
|
||||
Never reuse `admin@amare.local` / `password`.
|
||||
|
||||
### Load authorized testimonials after migrations
|
||||
|
||||
After migrations, manually load the five authorized testimonials in **staging**, then repeat in **production**. From Dokploy, open a terminal on the environment's `web` service (or run an equivalent one-off process):
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class='Database\Seeders\TestimonialsSeeder' --force --no-interaction
|
||||
```
|
||||
|
||||
This seeder is safe to rerun: it overwrites canonical source-owned fields, preserves curated photo fields, and leaves unrelated testimonials unchanged. The five records are published with the approved deterministic timestamp. Upsert keys on `author_name` (no unique DB constraint); keep one row per couple before/after running.
|
||||
|
||||
Optional verification:
|
||||
|
||||
```bash
|
||||
XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
|
||||
\$expected = collect(['Jeniffer e Maick', 'Quesia e Jhonata', 'Milena e Weslley', 'Raquel e Pedro', 'Victoria e Pedro']);
|
||||
\$rows = \\App\\Models\\Testimonial::query()->whereIn('author_name', \$expected)->get(['author_name', 'published_at'])->groupBy('author_name');
|
||||
\$valid = \$expected->every(function (string \$author) use (\$rows): bool {
|
||||
\$matches = \$rows->get(\$author, collect());
|
||||
return \$matches->count() === 1
|
||||
&& \$matches->first()->published_at?->format('Y-m-d H:i:s') === '2026-08-05 00:00:00';
|
||||
});
|
||||
echo (\$valid ? 'ok' : 'invalid').PHP_EOL;
|
||||
"
|
||||
```
|
||||
|
||||
Expected output: `ok`.
|
||||
|
||||
Do not run `DatabaseSeeder` or `ContentSeeder` in staging or production: they include local credentials and/or broad demo-content effects. Deployment workflows intentionally remain migrate-only; loading these testimonials is a deliberate manual operation in each environment.
|
||||
Adjust field names if the model evolves. Never reuse `admin@amare.local` / `password`.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
|
||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -7,7 +7,6 @@
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"playwright": "^1.62.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
@@ -891,22 +890,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"husky": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/typicode"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"prepare": "husky"
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"playwright": "^1.62.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
|
||||
@@ -61,10 +61,6 @@
|
||||
font-family: var(--amare-font-serif);
|
||||
}
|
||||
|
||||
[id$='-heading'] {
|
||||
scroll-margin-top: 5.5rem;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
summary:focus-visible,
|
||||
@@ -102,137 +98,4 @@
|
||||
body.menu-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Dossiê vivo — content visible by default; enhance only when opted in */
|
||||
[data-chapter-index] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-chapter-progress] {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-chapter-index] a[aria-current="true"] {
|
||||
color: var(--amare-color-accent-deep);
|
||||
}
|
||||
|
||||
[data-chapter-index] a[aria-current="true"]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.35em;
|
||||
bottom: 0.35em;
|
||||
width: 1px;
|
||||
background: var(--amare-color-accent);
|
||||
}
|
||||
|
||||
[data-chapter-progress] > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: var(--chapter-progress, 0%);
|
||||
background: var(--amare-color-accent);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
[data-chapter-index] {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: max(1rem, calc((100vw - var(--amare-container-max)) / 2 - 7.5rem));
|
||||
z-index: 30;
|
||||
max-width: 6.5rem;
|
||||
translate: 0 -50%;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
[data-chapter-index] a {
|
||||
position: relative;
|
||||
padding-left: 0.75rem;
|
||||
font-size: var(--amare-text-xs);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amare-color-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--amare-duration-fast) var(--amare-ease-standard);
|
||||
}
|
||||
|
||||
[data-chapter-index] a:hover {
|
||||
color: var(--amare-color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
[data-chapter-progress] {
|
||||
display: block;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 45;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* Transform/clip only — never fade text opacity (axe + WCAG mid-transition). */
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="title"] {
|
||||
transform: translateY(0.4rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="media"] {
|
||||
clip-path: inset(4% 0 0 0);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat] {
|
||||
transform: none;
|
||||
clip-path: inset(0 0 0 0);
|
||||
transition:
|
||||
transform var(--amare-duration-slow) var(--amare-ease-arrival),
|
||||
clip-path var(--amare-duration-focal) var(--amare-ease-arrival);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="seal"] {
|
||||
transition-delay: 0ms;
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="title"] {
|
||||
transition-delay: 80ms;
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="media"] {
|
||||
transition-delay: 120ms;
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="dossie-hero"].is-active [data-motion-beat="cta"] {
|
||||
transition-delay: 220ms;
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal]:not(.is-revealed) {
|
||||
transform: translateY(0.5rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-reveal].is-revealed {
|
||||
transform: none;
|
||||
transition: transform var(--amare-duration-slow) var(--amare-ease-arrival);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
|
||||
transform: translateY(0.4rem);
|
||||
}
|
||||
|
||||
html[data-motion="enhance"] [data-motion="page-open"].is-active {
|
||||
transform: none;
|
||||
transition: transform var(--amare-duration-normal) var(--amare-ease-arrival);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,9 +56,7 @@
|
||||
--amare-duration-fast: 150ms;
|
||||
--amare-duration-normal: 250ms;
|
||||
--amare-duration-slow: 400ms;
|
||||
--amare-duration-focal: 720ms;
|
||||
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--amare-ease-arrival: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -66,7 +64,6 @@
|
||||
--amare-duration-fast: 0.01ms;
|
||||
--amare-duration-normal: 0.01ms;
|
||||
--amare-duration-slow: 0.01ms;
|
||||
--amare-duration-focal: 0.01ms;
|
||||
}
|
||||
|
||||
*,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import './motion.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const menuButton = document.querySelector('[data-menu-button]');
|
||||
const navigation = document.querySelector('[data-main-nav]');
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
const MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return window.matchMedia(MOTION_QUERY).matches;
|
||||
}
|
||||
|
||||
function enableEnhancement() {
|
||||
if (prefersReducedMotion()) {
|
||||
document.documentElement.removeAttribute('data-motion');
|
||||
return false;
|
||||
}
|
||||
|
||||
document.documentElement.dataset.motion = 'enhance';
|
||||
return true;
|
||||
}
|
||||
|
||||
function activateHero(enhance) {
|
||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||
if (!hero) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activate = () => hero.classList.add('is-active');
|
||||
|
||||
if (!enhance) {
|
||||
activate();
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(activate);
|
||||
});
|
||||
}
|
||||
|
||||
function activatePageOpen(enhance) {
|
||||
document.querySelectorAll('[data-motion="page-open"]').forEach((node) => {
|
||||
if (!enhance) {
|
||||
node.classList.add('is-active');
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => node.classList.add('is-active'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observeReveals(enhance) {
|
||||
const nodes = Array.from(document.querySelectorAll('[data-reveal]'));
|
||||
if (nodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enhance || typeof IntersectionObserver !== 'function') {
|
||||
nodes.forEach((node) => node.classList.add('is-revealed'));
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
entry.target.classList.add('is-revealed');
|
||||
observer.unobserve(entry.target);
|
||||
});
|
||||
},
|
||||
{
|
||||
rootMargin: '0px 0px -12% 0px',
|
||||
threshold: 0.2,
|
||||
},
|
||||
);
|
||||
|
||||
nodes.forEach((node) => observer.observe(node));
|
||||
}
|
||||
|
||||
function setupChapterIndex() {
|
||||
const index = document.querySelector('[data-chapter-index]');
|
||||
const progress = document.querySelector('[data-chapter-progress] span');
|
||||
const chapters = Array.from(document.querySelectorAll('[data-chapter]'));
|
||||
|
||||
if (!index || chapters.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const links = Array.from(index.querySelectorAll('a[href^="#"]'));
|
||||
|
||||
const setActive = (id) => {
|
||||
links.forEach((link) => {
|
||||
const isCurrent = link.getAttribute('href') === `#${id}`;
|
||||
if (isCurrent) {
|
||||
link.setAttribute('aria-current', 'true');
|
||||
} else {
|
||||
link.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateProgress = (ratio) => {
|
||||
if (!progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = Math.min(1, Math.max(0, ratio));
|
||||
progress.style.setProperty('--chapter-progress', `${(clamped * 100).toFixed(2)}%`);
|
||||
};
|
||||
|
||||
const sync = () => {
|
||||
const marker = window.scrollY + Math.min(window.innerHeight * 0.35, 280);
|
||||
let current = chapters[0];
|
||||
|
||||
chapters.forEach((chapter) => {
|
||||
if (chapter.offsetTop <= marker) {
|
||||
current = chapter;
|
||||
}
|
||||
});
|
||||
|
||||
const heading = current.querySelector('[id$="-heading"]') || document.getElementById(`${current.dataset.chapter}-heading`);
|
||||
const headingId = heading?.id
|
||||
|| current.getAttribute('aria-labelledby')
|
||||
|| `${current.dataset.chapter}-heading`;
|
||||
|
||||
setActive(headingId);
|
||||
|
||||
const doc = document.documentElement;
|
||||
const max = Math.max(1, doc.scrollHeight - window.innerHeight);
|
||||
updateProgress(window.scrollY / max);
|
||||
};
|
||||
|
||||
setActive(chapters[0].getAttribute('aria-labelledby') || 'hero-heading');
|
||||
sync();
|
||||
|
||||
window.addEventListener('scroll', sync, { passive: true });
|
||||
window.addEventListener('resize', sync);
|
||||
}
|
||||
|
||||
function bootMotion() {
|
||||
const enhance = enableEnhancement();
|
||||
activateHero(enhance);
|
||||
activatePageOpen(enhance);
|
||||
observeReveals(enhance);
|
||||
setupChapterIndex();
|
||||
|
||||
window.matchMedia(MOTION_QUERY).addEventListener('change', (event) => {
|
||||
if (event.matches) {
|
||||
document.documentElement.removeAttribute('data-motion');
|
||||
document.querySelectorAll('[data-motion="dossie-hero"], [data-motion="page-open"]').forEach((node) => {
|
||||
node.classList.add('is-active');
|
||||
});
|
||||
document.querySelectorAll('[data-reveal]').forEach((node) => {
|
||||
node.classList.add('is-revealed');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.dataset.motion = 'enhance';
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootMotion, { once: true });
|
||||
} else {
|
||||
bootMotion();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
@props([
|
||||
'chapters' => [],
|
||||
])
|
||||
|
||||
@if (count($chapters) > 0)
|
||||
<nav data-chapter-index aria-label="Índice de capítulos">
|
||||
@foreach ($chapters as $chapter)
|
||||
<a href="#{{ $chapter['id'] }}">{{ $chapter['label'] }}</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div data-chapter-progress aria-hidden="true">
|
||||
<span></span>
|
||||
</div>
|
||||
@endif
|
||||
@@ -2,8 +2,8 @@
|
||||
'settings',
|
||||
])
|
||||
|
||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20" data-chapter="final-cta">
|
||||
<div class="container-amare space-y-6 text-center" data-reveal>
|
||||
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20">
|
||||
<div class="container-amare space-y-6 text-center">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p>
|
||||
<h2 id="final-cta-heading" class="text-3xl font-medium text-amare-text md:text-4xl">Todo grande encontro começa com uma boa conversa.</h2>
|
||||
<p class="mx-auto max-w-2xl text-amare-muted">
|
||||
|
||||
@@ -2,22 +2,14 @@
|
||||
'settings',
|
||||
])
|
||||
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
class="border-b border-amare-border bg-amare-bg"
|
||||
data-chapter="hero"
|
||||
data-motion="dossie-hero"
|
||||
>
|
||||
<section aria-labelledby="hero-heading" class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare grid gap-12 py-20 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:items-center md:py-28">
|
||||
<div class="space-y-8">
|
||||
<div data-motion-beat="seal" class="flex items-center gap-4">
|
||||
<x-brand.logo mark variant="on-light" class="h-8 w-auto" alt="" />
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (filled($settings->hero_eyebrow))
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
|
||||
@endif
|
||||
|
||||
<h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl">
|
||||
<h1 id="hero-heading" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl">
|
||||
{{ $settings->hero_title }}
|
||||
</h1>
|
||||
|
||||
@@ -25,7 +17,7 @@
|
||||
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
|
||||
@endif
|
||||
|
||||
<div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<a
|
||||
href="{{ route('contact') }}"
|
||||
data-testid="home-primary-cta"
|
||||
@@ -50,7 +42,7 @@
|
||||
</div>
|
||||
|
||||
@if (filled($settings->default_og_image_path))
|
||||
<div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
|
||||
<div class="min-h-72 bg-amare-bg-deep">
|
||||
<x-media.image
|
||||
:path="$settings->default_og_image_path"
|
||||
:alt="$settings->default_og_image_alt ?: $settings->brand_name"
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
$body = $settings->manifesto_body ?: 'A Amare combina sensibilidade e precisão para criar encontros coerentes com cada cliente, marca e ocasião — sem fórmulas prontas, excessos ou ruído.';
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20" data-chapter="manifesto">
|
||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20">
|
||||
<div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
|
||||
<div class="space-y-6">
|
||||
<h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
|
||||
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
||||
<div class="space-y-3" data-reveal>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p>
|
||||
<h2 id="method-heading" class="text-3xl font-medium text-amare-text">Cuidado orientado por processo.</h2>
|
||||
<p class="text-amare-text-muted">{{ $intro }}</p>
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<ol class="grid gap-5 border-t border-amare-border">
|
||||
@foreach ($steps as $index => $step)
|
||||
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal>
|
||||
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]">
|
||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
])
|
||||
|
||||
@if ($cases->isNotEmpty())
|
||||
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text" data-chapter="portfolio">
|
||||
<section aria-labelledby="portfolio-heading" class="border-b border-amare-accent-deep bg-amare-accent-deep py-20 text-amare-accent-text">
|
||||
<div class="container-amare space-y-10">
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
|
||||
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
|
||||
<div class="space-y-3">
|
||||
<h2 id="portfolio-heading" class="text-3xl font-medium md:text-4xl">Celebrações que ganham forma com intenção.</h2>
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="grid gap-10 md:grid-cols-2">
|
||||
@foreach ($cases as $case)
|
||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal>
|
||||
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4">
|
||||
@if (filled($case->cover_image_path))
|
||||
<x-media.image
|
||||
:path="$case->cover_image_path"
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
|
||||
@endphp
|
||||
|
||||
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
|
||||
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20">
|
||||
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div class="space-y-3" data-reveal>
|
||||
<div class="space-y-3">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p>
|
||||
<h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
|
||||
</div>
|
||||
<div class="space-y-8" data-reveal>
|
||||
<div class="space-y-8">
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
|
||||
<ul class="grid gap-3 border-t border-amare-border">
|
||||
@foreach ($principles as $principle)
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
])
|
||||
|
||||
@if ($services->isNotEmpty())
|
||||
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
|
||||
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20">
|
||||
<div class="container-amare space-y-10">
|
||||
<div class="max-w-2xl space-y-3" data-reveal>
|
||||
<div class="max-w-2xl space-y-3">
|
||||
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p>
|
||||
<h2 id="services-heading" class="text-3xl font-medium text-amare-text">Serviços</h2>
|
||||
<p class="text-amare-text-muted">Assessoria sob medida para decisões importantes e celebrações bem conduzidas.</p>
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<ol class="border-t border-amare-border">
|
||||
@foreach ($services as $index => $service)
|
||||
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6" data-reveal>
|
||||
<li class="grid gap-3 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,0.8fr)_minmax(0,1.2fr)] md:gap-6">
|
||||
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
|
||||
<h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
|
||||
<p class="text-amare-text-muted">{{ $service->summary }}</p>
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
])
|
||||
|
||||
@if ($testimonials->isNotEmpty())
|
||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials">
|
||||
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16">
|
||||
<div class="container-amare space-y-8">
|
||||
<div class="max-w-2xl space-y-3" data-reveal>
|
||||
<div class="max-w-2xl space-y-3">
|
||||
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2>
|
||||
<p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
|
||||
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
|
||||
@endphp
|
||||
<blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
|
||||
<blockquote class="space-y-4 border-t border-amare-border pt-4">
|
||||
<div class="space-y-3 text-lg text-amare-text">
|
||||
@foreach ($paragraphs as $index => $paragraph)
|
||||
<p>@if ($index === 0)“@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)”@endif</p>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
$city = $siteSettings->city ?: 'São Paulo - SP';
|
||||
@endphp
|
||||
|
||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare grid flex-1 content-start gap-12 py-16 md:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] md:py-24">
|
||||
<div class="space-y-6">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$chapters = [
|
||||
['id' => 'hero-heading', 'label' => 'Capa'],
|
||||
['id' => 'manifesto-heading', 'label' => 'Manifesto'],
|
||||
];
|
||||
|
||||
if ($content->featuredServices->isNotEmpty()) {
|
||||
$chapters[] = ['id' => 'services-heading', 'label' => 'Serviços'];
|
||||
}
|
||||
|
||||
if ($content->featuredCases->isNotEmpty()) {
|
||||
$chapters[] = ['id' => 'portfolio-heading', 'label' => 'Portfólio'];
|
||||
}
|
||||
|
||||
$chapters[] = ['id' => 'method-heading', 'label' => 'Método'];
|
||||
|
||||
if ($content->testimonials->isNotEmpty()) {
|
||||
$chapters[] = ['id' => 'testimonials-heading', 'label' => 'Depoimentos'];
|
||||
}
|
||||
|
||||
$chapters[] = ['id' => 'positioning-heading', 'label' => 'A Amare'];
|
||||
$chapters[] = ['id' => 'final-cta-heading', 'label' => 'Próximo passo'];
|
||||
@endphp
|
||||
|
||||
<x-home.chapter-index :chapters="$chapters" />
|
||||
|
||||
<x-home.hero :settings="$content->settings" />
|
||||
<x-home.manifesto :settings="$content->settings" />
|
||||
<x-home.services :services="$content->featuredServices" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
|
||||
<div class="border-b border-amare-border bg-amare-bg-deep">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<article data-motion="page-open">
|
||||
<article>
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-8 py-16 md:py-24">
|
||||
<header class="max-w-3xl space-y-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@extends('layouts.public')
|
||||
|
||||
@section('content')
|
||||
<div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
|
||||
<div class="border-b border-amare-border bg-amare-bg">
|
||||
<div class="container-amare space-y-10 py-16 md:py-24">
|
||||
<div class="max-w-2xl space-y-4">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>
|
||||
|
||||
8
tasks.md
8
tasks.md
@@ -1,8 +0,0 @@
|
||||
# Tasks: Git hooks (pre-commit + pre-push)
|
||||
|
||||
- [x] Install husky + add prepare script
|
||||
- [x] Create .husky/pre-commit (pint + phpstan)
|
||||
- [x] Create .husky/pre-push (DB gate + tests)
|
||||
- [x] Install hooks into repo, chmod +x
|
||||
- [x] Verify hooks (DB up/down, pre-commit)
|
||||
- [x] Document hooks in AGENTS.md
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -95,36 +95,20 @@ it('loads covered public routes without console errors', function (): void {
|
||||
});
|
||||
|
||||
it('disables transitions when prefers-reduced-motion is reduce', function (): void {
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'casamento-jardim',
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
$routes = [
|
||||
'/',
|
||||
'/servicos',
|
||||
'/portfolio',
|
||||
'/portfolio/'.$case->slug,
|
||||
'/sobre',
|
||||
'/contato',
|
||||
'/privacidade',
|
||||
];
|
||||
$duration = $page->script(<<<'JS'
|
||||
() => {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.transition = 'opacity var(--amare-duration-normal) ease';
|
||||
document.body.appendChild(probe);
|
||||
const value = getComputedStyle(probe).transitionDuration;
|
||||
probe.remove();
|
||||
return value;
|
||||
}
|
||||
JS);
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$page = $this->visit($route, [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
$duration = $page->script(<<<'JS'
|
||||
() => {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.transition = 'opacity var(--amare-duration-normal) ease';
|
||||
document.body.appendChild(probe);
|
||||
const value = getComputedStyle(probe).transitionDuration;
|
||||
probe.remove();
|
||||
return value;
|
||||
}
|
||||
JS);
|
||||
|
||||
expect((float) $duration)->toBeLessThan(0.02);
|
||||
}
|
||||
expect((float) $duration)->toBeLessThan(0.02);
|
||||
});
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
SiteSetting::instance()->update([
|
||||
'hero_title' => 'Celebrações com propósito',
|
||||
'default_og_image_path' => 'media/hero.jpg',
|
||||
'default_og_image_alt' => 'Capa editorial',
|
||||
]);
|
||||
|
||||
Service::factory()->published()->featured()->create([
|
||||
'title' => 'Casamentos',
|
||||
'summary' => 'Assessoria completa',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps home motion in final visible state when reduced motion is preferred', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'reduce',
|
||||
]);
|
||||
|
||||
$state = $page->script(<<<'JS'
|
||||
() => {
|
||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||
const title = document.querySelector('#hero-heading');
|
||||
const index = document.querySelector('[data-chapter-index]');
|
||||
const active = document.querySelector('[data-chapter-index] [aria-current="true"]');
|
||||
const titleStyle = title ? getComputedStyle(title) : null;
|
||||
|
||||
return {
|
||||
heroActive: hero?.classList.contains('is-active') ?? false,
|
||||
titleOpacity: titleStyle ? Number.parseFloat(titleStyle.opacity) : 0,
|
||||
hasIndex: Boolean(index),
|
||||
hasActiveChapter: Boolean(active),
|
||||
};
|
||||
}
|
||||
JS);
|
||||
|
||||
expect($state['heroActive'])->toBeTrue();
|
||||
expect($state['titleOpacity'])->toBeGreaterThan(0.9);
|
||||
expect($state['hasIndex'])->toBeTrue();
|
||||
expect($state['hasActiveChapter'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('activates the home focal opening once when motion is allowed', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => new Promise((resolve) => {
|
||||
const wait = () => {
|
||||
const hero = document.querySelector('[data-motion="dossie-hero"]');
|
||||
if (hero?.classList.contains('is-active')) {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(wait);
|
||||
};
|
||||
wait();
|
||||
setTimeout(() => resolve(false), 2000);
|
||||
})
|
||||
JS);
|
||||
|
||||
$active = $page->script(<<<'JS'
|
||||
() => document.querySelector('[data-motion="dossie-hero"]')?.classList.contains('is-active') ?? false
|
||||
JS);
|
||||
|
||||
expect($active)->toBeTrue();
|
||||
});
|
||||
|
||||
it('updates the active chapter while scrolling the home dossier', function (): void {
|
||||
$page = $this->visit('/', [
|
||||
'reducedMotion' => 'no-preference',
|
||||
]);
|
||||
|
||||
$page->resize(1440, 1000);
|
||||
|
||||
$before = $page->script(<<<'JS'
|
||||
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => {
|
||||
const target = document.querySelector('#method-heading');
|
||||
target?.scrollIntoView({ block: 'start' });
|
||||
window.dispatchEvent(new Event('scroll'));
|
||||
return Boolean(target);
|
||||
}
|
||||
JS);
|
||||
|
||||
$page->script(<<<'JS'
|
||||
() => new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
const check = () => {
|
||||
const href = document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href');
|
||||
if (href === '#method-heading' || frames > 60) {
|
||||
resolve(href);
|
||||
return;
|
||||
}
|
||||
frames += 1;
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
})
|
||||
JS);
|
||||
|
||||
$after = $page->script(<<<'JS'
|
||||
() => document.querySelector('[data-chapter-index] [aria-current="true"]')?.getAttribute('href') ?? null
|
||||
JS);
|
||||
|
||||
expect($before)->not->toBeNull();
|
||||
expect($after)->toBe('#method-heading');
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Livewire\Features\SupportFileUploads\FileUploadConfiguration;
|
||||
|
||||
it('keeps livewire temporary uploads on the local disk when the default filesystem is r2', function (): void {
|
||||
config(['filesystems.default' => 'r2']);
|
||||
|
||||
// Livewire swaps FileUploadConfiguration::disk() to tmp-for-tests under PHPUnit;
|
||||
// isUsingS3() still reads the configured temp disk and drives signed R2 uploads.
|
||||
expect(config('livewire.temporary_file_upload.disk'))->toBe('local')
|
||||
->and(FileUploadConfiguration::isUsingS3())->toBeFalse();
|
||||
});
|
||||
@@ -9,7 +9,6 @@ use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\ContentSeeder;
|
||||
use Database\Seeders\TestimonialsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
@@ -19,59 +18,6 @@ class TestimonialsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* quote: string,
|
||||
* author_name: string,
|
||||
* context: string,
|
||||
* sort_order: int,
|
||||
* is_featured: bool,
|
||||
* published_at: string
|
||||
* }>
|
||||
*/
|
||||
private const EXPECTED_TESTIMONIALS = [
|
||||
[
|
||||
'quote' => "Mi, quero agradecer você e a sua equipe por todo empenho, atenção, vocês são abençoadas.\n\nEra nítida sua preocupação em garantir que todos os detalhes planejados desta comemoração, fossem atendidos.\n\nQue você possa transformar o grande dia das noivinhas sempre com essa sua leveza!!!\n\nMuito obrigada!",
|
||||
'author_name' => 'Jeniffer e Maick',
|
||||
'context' => 'Casamento · 06/12/2025',
|
||||
'sort_order' => 1,
|
||||
'is_featured' => true,
|
||||
'published_at' => '2026-08-05 00:00:00',
|
||||
],
|
||||
[
|
||||
'quote' => "Mi, eu não tenho palavras pra agradecer você e tudo que você fez por mim e por nós na realização desse sonho. Eu tô ainda extasiada com tudo que aconteceu hoje; mas tenho certeza que sem a sua ajuda, muita coisa não aconteceria.\n\nObrigada por tudo !",
|
||||
'author_name' => 'Quesia e Jhonata',
|
||||
'context' => 'Casamento · 21/12/2025',
|
||||
'sort_order' => 2,
|
||||
'is_featured' => true,
|
||||
'published_at' => '2026-08-05 00:00:00',
|
||||
],
|
||||
[
|
||||
'quote' => 'Que equipe!! Que equipe maravilhosa!! Obrigado pelo empenho de fazer tudo como eu queria!! Obrigado por se esforçar tanto e vir de tão longe pra realizar meu sonho!! Incríveis!!',
|
||||
'author_name' => 'Milena e Weslley',
|
||||
'context' => 'Casamento · 13/02/2026',
|
||||
'sort_order' => 3,
|
||||
'is_featured' => false,
|
||||
'published_at' => '2026-08-05 00:00:00',
|
||||
],
|
||||
[
|
||||
'quote' => "Gostaríamos de agradecer por todo o acompanhamento e dedicação durante a realização do nosso casamento. Foi um dia muito especial e inesquecível para nós.\n\nDesde o início, conseguimos conduzir tudo aquilo que estávamos planejando, dentro dos horários que estipulamos, o que foi ótimo, e no grande dia sua equipe nos recebeu e tratou com muito carinho, atenção e cuidado, o que fez toda a diferença para vivermos esse momento com mais tranquilidade.\n\nTambém adoramos as sugestões e ideias para as fotos, que deixaram os registros ainda mais bonitos e espontâneos, porque não iríamos lembrar de quais poses fazer na hora.\n\nObrigada por fazer parte de um momento tão importante das nossas vidas. Desejamos muito sucesso e que muitos outros casais possam viver dias especiais através do trabalho da AMARE.",
|
||||
'author_name' => 'Raquel e Pedro',
|
||||
'context' => 'Casamento · 09/05/2026',
|
||||
'sort_order' => 4,
|
||||
'is_featured' => false,
|
||||
'published_at' => '2026-08-05 00:00:00',
|
||||
],
|
||||
[
|
||||
'quote' => "Miiii, meu amor… você e sua equipe foram impecáveis.\n\nSuperou todas as nossas expectativas. Somos eternamente gratos por fazer nosso dia acontecer muito melhor do que imaginávamos.\n\nSempre muito atenciosa e paciente.\n\nAdoramos te conhecer e estamos muito felizes em termos escolhido você para assessorar nosso dia.",
|
||||
'author_name' => 'Victoria e Pedro',
|
||||
'context' => 'Casamento · 24/06/2026',
|
||||
'sort_order' => 5,
|
||||
'is_featured' => false,
|
||||
'published_at' => '2026-08-05 00:00:00',
|
||||
],
|
||||
];
|
||||
|
||||
public function test_published_scope_excludes_unpublished_testimonials(): void
|
||||
{
|
||||
Testimonial::factory()->create(['published_at' => null]);
|
||||
@@ -102,27 +48,11 @@ class TestimonialsTest extends TestCase
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_testimonials_seeder_maps_all_five_real_couples_from_depoimentos(): void
|
||||
{
|
||||
$this->runTestimonialsSeeder();
|
||||
|
||||
$testimonials = Testimonial::query()
|
||||
->orderBy('sort_order')
|
||||
->get()
|
||||
->map(fn (Testimonial $testimonial): array => $this->sourceOwnedState($testimonial))
|
||||
->all();
|
||||
|
||||
$this->assertSame(self::EXPECTED_TESTIMONIALS, $testimonials);
|
||||
}
|
||||
|
||||
public function test_content_seeder_loads_five_real_couples_from_depoimentos(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
|
||||
$this->artisan('db:seed', [
|
||||
'--class' => ContentSeeder::class,
|
||||
'--no-interaction' => true,
|
||||
])->assertExitCode(0);
|
||||
$this->seed(ContentSeeder::class);
|
||||
|
||||
$authors = Testimonial::query()->orderBy('sort_order')->pluck('author_name')->all();
|
||||
|
||||
@@ -134,6 +64,11 @@ class TestimonialsTest extends TestCase
|
||||
'Victoria e Pedro',
|
||||
], $authors);
|
||||
|
||||
$this->assertNull(
|
||||
Testimonial::query()->where('author_name', 'Ana Souza')->first(),
|
||||
'Fictional demo authors must not remain after seeding real testimonials.',
|
||||
);
|
||||
|
||||
$jeniffer = Testimonial::query()->where('author_name', 'Jeniffer e Maick')->first();
|
||||
|
||||
$this->assertNotNull($jeniffer);
|
||||
@@ -142,87 +77,6 @@ class TestimonialsTest extends TestCase
|
||||
$this->assertSame('Casamento · 06/12/2025', $jeniffer->context);
|
||||
}
|
||||
|
||||
public function test_testimonials_seeder_authoritatively_overwrites_source_owned_fields(): void
|
||||
{
|
||||
$testimonial = Testimonial::factory()->create([
|
||||
'quote' => 'Depoimento desatualizado.',
|
||||
'author_name' => 'Jeniffer e Maick',
|
||||
'context' => 'Contexto desatualizado',
|
||||
'sort_order' => 99,
|
||||
'is_featured' => false,
|
||||
'published_at' => null,
|
||||
]);
|
||||
|
||||
$this->runTestimonialsSeeder();
|
||||
|
||||
$this->assertSame(
|
||||
self::EXPECTED_TESTIMONIALS[0],
|
||||
$this->sourceOwnedState($testimonial->refresh()),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_testimonials_seeder_preserves_curated_photo_fields_for_known_author(): void
|
||||
{
|
||||
$testimonial = Testimonial::factory()->create([
|
||||
'author_name' => 'Jeniffer e Maick',
|
||||
'photo_path' => 'testimonials/jeniffer-e-maick.jpg',
|
||||
'photo_alt' => 'Jeniffer e Maick durante o casamento',
|
||||
]);
|
||||
|
||||
$this->runTestimonialsSeeder();
|
||||
|
||||
$testimonial->refresh();
|
||||
|
||||
$this->assertSame('testimonials/jeniffer-e-maick.jpg', $testimonial->photo_path);
|
||||
$this->assertSame('Jeniffer e Maick durante o casamento', $testimonial->photo_alt);
|
||||
}
|
||||
|
||||
public function test_testimonials_seeder_leaves_unrelated_testimonials_unchanged(): void
|
||||
{
|
||||
$unrelated = Testimonial::factory()->published()->featured()->create([
|
||||
'quote' => 'Depoimento cadastrado pela equipe.',
|
||||
'author_name' => 'Casal não presente na fonte',
|
||||
'context' => 'Bodas · 02/08/2026',
|
||||
'photo_path' => 'testimonials/casal-equipe.jpg',
|
||||
'photo_alt' => 'Casal cadastrado pela equipe',
|
||||
'sort_order' => 42,
|
||||
]);
|
||||
$originalState = $unrelated->getAttributes();
|
||||
ksort($originalState);
|
||||
|
||||
$this->runTestimonialsSeeder();
|
||||
|
||||
$persisted = $unrelated->fresh();
|
||||
|
||||
$this->assertNotNull($persisted);
|
||||
$persistedState = $persisted->getAttributes();
|
||||
ksort($persistedState);
|
||||
|
||||
$this->assertSame($originalState, $persistedState);
|
||||
}
|
||||
|
||||
public function test_testimonials_seeder_is_idempotent_without_duplicate_authors(): void
|
||||
{
|
||||
$this->runTestimonialsSeeder();
|
||||
$firstRunState = Testimonial::query()
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (Testimonial $testimonial): array => $testimonial->getAttributes())
|
||||
->all();
|
||||
|
||||
$this->runTestimonialsSeeder();
|
||||
|
||||
$secondRunState = Testimonial::query()
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->map(fn (Testimonial $testimonial): array => $testimonial->getAttributes())
|
||||
->all();
|
||||
|
||||
$this->assertCount(5, $secondRunState);
|
||||
$this->assertSame($firstRunState, $secondRunState);
|
||||
$this->assertSame(5, Testimonial::query()->distinct()->count('author_name'));
|
||||
}
|
||||
|
||||
public function test_home_renders_multi_paragraph_testimonial_quotes(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
@@ -243,34 +97,4 @@ class TestimonialsTest extends TestCase
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
private function runTestimonialsSeeder(): void
|
||||
{
|
||||
$this->artisan('db:seed', [
|
||||
'--class' => TestimonialsSeeder::class,
|
||||
'--no-interaction' => true,
|
||||
])->assertExitCode(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* quote: string,
|
||||
* author_name: string,
|
||||
* context: string,
|
||||
* sort_order: int,
|
||||
* is_featured: bool,
|
||||
* published_at: string
|
||||
* }
|
||||
*/
|
||||
private function sourceOwnedState(Testimonial $testimonial): array
|
||||
{
|
||||
return [
|
||||
'quote' => (string) $testimonial->quote,
|
||||
'author_name' => (string) $testimonial->author_name,
|
||||
'context' => (string) $testimonial->context,
|
||||
'sort_order' => (int) $testimonial->sort_order,
|
||||
'is_featured' => (bool) $testimonial->is_featured,
|
||||
'published_at' => $testimonial->published_at?->format('Y-m-d H:i:s') ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\PublicSite;
|
||||
|
||||
use App\Models\PortfolioCase;
|
||||
use App\Models\Service;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MotionMarkupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_home_exposes_focal_hero_hooks_and_chapter_index_links(): void
|
||||
{
|
||||
$settings = SiteSetting::instance();
|
||||
$settings->update([
|
||||
'hero_title' => 'Celebrações com propósito',
|
||||
'default_og_image_path' => 'media/hero.jpg',
|
||||
'default_og_image_alt' => 'Capa editorial',
|
||||
]);
|
||||
|
||||
Service::factory()->published()->featured()->create(['title' => 'Casamentos']);
|
||||
PortfolioCase::factory()->published()->create([
|
||||
'title' => 'Caso A',
|
||||
'is_featured' => true,
|
||||
]);
|
||||
Testimonial::factory()->published()->create(['author_name' => 'Ana']);
|
||||
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('data-motion="dossie-hero"', false)
|
||||
->assertSee('data-motion-beat="seal"', false)
|
||||
->assertSee('data-motion-beat="title"', false)
|
||||
->assertSee('data-motion-beat="media"', false)
|
||||
->assertSee('data-motion-beat="cta"', false)
|
||||
->assertSee('data-chapter-index', false)
|
||||
->assertSee('data-chapter-progress', false)
|
||||
->assertSee('href="#hero-heading"', false)
|
||||
->assertSee('href="#manifesto-heading"', false)
|
||||
->assertSee('href="#services-heading"', false)
|
||||
->assertSee('href="#portfolio-heading"', false)
|
||||
->assertSee('href="#method-heading"', false)
|
||||
->assertSee('href="#testimonials-heading"', false)
|
||||
->assertSee('href="#positioning-heading"', false)
|
||||
->assertSee('href="#final-cta-heading"', false)
|
||||
->assertSee('data-chapter="hero"', false)
|
||||
->assertSee('data-chapter="manifesto"', false)
|
||||
->assertSee('data-reveal', false);
|
||||
}
|
||||
|
||||
public function test_chapter_index_omits_empty_cms_sections(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
|
||||
$response = $this->get(route('home'));
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertSee('data-chapter-index', false)
|
||||
->assertSee('href="#hero-heading"', false)
|
||||
->assertSee('href="#manifesto-heading"', false)
|
||||
->assertSee('href="#method-heading"', false)
|
||||
->assertSee('href="#positioning-heading"', false)
|
||||
->assertSee('href="#final-cta-heading"', false)
|
||||
->assertDontSee('href="#services-heading"', false)
|
||||
->assertDontSee('href="#portfolio-heading"', false)
|
||||
->assertDontSee('href="#testimonials-heading"', false)
|
||||
->assertDontSee('id="services-heading"', false)
|
||||
->assertDontSee('id="portfolio-heading"', false)
|
||||
->assertDontSee('id="testimonials-heading"', false);
|
||||
}
|
||||
|
||||
public function test_motion_tokens_and_runtime_exist_with_reduced_motion_guards(): void
|
||||
{
|
||||
$tokens = (string) file_get_contents(resource_path('css/tokens.css'));
|
||||
$appCss = (string) file_get_contents(resource_path('css/app.css'));
|
||||
$appJs = (string) file_get_contents(resource_path('js/app.js'));
|
||||
|
||||
$this->assertStringContainsString('--amare-duration-focal:', $tokens);
|
||||
$this->assertStringContainsString('--amare-ease-arrival:', $tokens);
|
||||
$this->assertStringContainsString('--amare-duration-focal: 0.01ms', $tokens);
|
||||
|
||||
$this->assertStringContainsString('[data-motion="dossie-hero"]', $appCss);
|
||||
$this->assertStringContainsString('[data-reveal]', $appCss);
|
||||
$this->assertStringContainsString('[data-chapter-index]', $appCss);
|
||||
$this->assertStringContainsString('prefers-reduced-motion: no-preference', $appCss);
|
||||
|
||||
// Entrance motion must not fade text opacity — mid-fade fails WCAG contrast (axe).
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
'/\[data-motion-beat="seal"\][^{]*\{[^}]*opacity:\s*0/s',
|
||||
$appCss,
|
||||
);
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
'/\[data-motion="page-open"\]:not\(\.is-active\)[^{]*\{[^}]*opacity:\s*0/s',
|
||||
$appCss,
|
||||
);
|
||||
$this->assertDoesNotMatchRegularExpression(
|
||||
'/\[data-reveal\]:not\(\.is-revealed\)[^{]*\{[^}]*opacity:\s*0/s',
|
||||
$appCss,
|
||||
);
|
||||
|
||||
$this->assertFileExists(resource_path('js/motion.js'));
|
||||
$this->assertStringContainsString("import './motion.js'", $appJs);
|
||||
$this->assertStringContainsString('prefers-reduced-motion', (string) file_get_contents(resource_path('js/motion.js')));
|
||||
}
|
||||
|
||||
public function test_internal_pages_expose_shared_page_open_hook(): void
|
||||
{
|
||||
SiteSetting::instance();
|
||||
$case = PortfolioCase::factory()->published()->create([
|
||||
'slug' => 'casamento-jardim',
|
||||
'title' => 'Casamento Jardim',
|
||||
]);
|
||||
|
||||
$this->get(route('services.index'))
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('portfolio.index'))
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('portfolio.show', $case->slug))
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('about'))
|
||||
->assertOk()
|
||||
->assertSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('contact'))
|
||||
->assertOk()
|
||||
->assertDontSee('data-motion="page-open"', false);
|
||||
|
||||
$this->get(route('privacy'))
|
||||
->assertOk()
|
||||
->assertDontSee('data-motion="page-open"', false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user