Compare commits

..

8 Commits

Author SHA1 Message Date
03145c747c test: refresh visual baselines from CI Ubuntu screenshots
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:11:24 -03:00
52afb27d6e chore: sync feat/dossie-vivo-motion with main 2026-08-05 20:02:30 -03:00
af31738021 test: align Livewire temp-disk assertion with PHPUnit disk swap
FileUploadConfiguration: :disk() always returns tmp-for-tests under PHPUnit; assert config + isUsingS3() instead.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:53:19 -03:00
b54908541f feat: add Dossiê vivo motion to the public site
Introduce a focal Home opening, chapter index, and restrained continuity
reveals while keeping reduced-motion and visual baselines intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:40:40 -03:00
286522d8a2 fix: keep Livewire temp uploads local when using R2
Avoid browser PUT to R2 (CORS) by defaulting Filament/Livewire temp disk to local, and document Dokploy tinker + R2 CORS gotchas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 00:11:32 -03:00
3e68192cf3 fix: join Dokploy network so Compose reaches managed Postgres (#9)
Migrate could not resolve the database internal hostname because the
Compose stack used an isolated project network. Attach services to the
external dokploy-network used by Dokploy Postgres.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:15:52 -03:00
1a31dd1cf6 fix: surface Dokploy API errors and normalize panel URL (#8)
curl -f hid 404 bodies; strip accidental /api on DOKPLOY_URL so compose.deploy diagnostics are actionable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 23:14:12 -03:00
9060036024 test: refresh visual baselines from CI Ubuntu screenshots (#7)
Heritage Editorial redesign snaps were generated outside CI and fail
assertScreenshotMatches on FrankenPHP/Ubuntu Chromium. Promote the
browser-failure-artifacts PNGs from run 30729267028 into Pest snaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 22:23:58 -03:00
34 changed files with 799 additions and 66 deletions

View File

@@ -12,5 +12,6 @@ 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. - **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. - **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. - **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. - **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. - **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.

View File

@@ -26,6 +26,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
$this->configureLivewireTemporaryUploads();
$this->freezeClockWhenConfigured(); $this->freezeClockWhenConfigured();
View::composer('layouts.public', function (ViewInstance $view): void { View::composer('layouts.public', function (ViewInstance $view): void {
@@ -46,6 +47,22 @@ 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 private function freezeClockWhenConfigured(): void
{ {
if ($this->app->environment('production')) { if ($this->app->environment('production')) {

View File

@@ -100,19 +100,41 @@ LOG_LEVEL=warning
Trusted proxies are configured in `bootstrap/app.php` so Traefik `X-Forwarded-*` headers work for HTTPS cookies and URLs. 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 ## GitHub Actions secrets
Repository secrets used by workflows: Repository secrets used by workflows:
| Secret | Purpose | | Secret | Purpose |
|---|---| |---|---|
| `DOKPLOY_URL` | Base URL of the Dokploy panel (no trailing slash) | | `DOKPLOY_URL` | Panel origin **without** `/api` (e.g. `https://panel.example.com`). Do not use the OpenAPI base URL that ends in `/api` — that yields `/api/api/...` and 404s. |
| `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI | | `DOKPLOY_API_KEY` | API key from Dokploy profile → API/CLI |
| `DOKPLOY_STAGING_COMPOSE_ID` | Staging compose id | | `DOKPLOY_STAGING_COMPOSE_ID` | Staging **Compose** service id (not an Application id) |
| `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production compose id | | `DOKPLOY_PRODUCTION_COMPOSE_ID` | Production **Compose** service id (not an Application id) |
| `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) | | `STAGING_URL` | Public origin for staging smoke (e.g. `https://staging.example.com`) |
| `PRODUCTION_URL` | Public origin for production smoke | | `PRODUCTION_URL` | Public origin for production smoke |
HTTP 404 from `compose.deploy` usually means the compose id is wrong (Application id instead of Compose) or `DOKPLOY_URL` still includes `/api`.
`GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline. `GITHUB_TOKEN` (automatic) publishes to GHCR with `packages:write`. No Laravel/`APP_KEY`/DB/R2/Resend secrets belong in GitHub for this pipeline.
## Workflows ## Workflows
@@ -141,23 +163,34 @@ Private repos on GitHub Free do not get Environment required reviewers; human ap
Seed credentials are local-only. For staging/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 ```bash
# From Dokploy → staging/production → Open terminal on `web` (or one-off run) # From Dokploy → staging/production → Open terminal on `web` (or one-off run)
php artisan tinker --execute=" XDG_CONFIG_HOME=/tmp php artisan tinker --execute="
\\App\\Models\\User::query()->updateOrCreate( \$user = \\App\\Models\\User::query()->updateOrCreate(
['email' => 'admin@example.com'], ['email' => 'admin@example.com'],
[ [
'name' => 'Admin', 'name' => 'Admin',
'password' => bcrypt('use-a-strong-password'), 'password' => 'use-a-strong-password',
'role' => 'admin', 'role' => 'admin',
'is_active' => true, 'is_active' => true,
'email_verified_at' => now(),
] ]
); );
\$user->forceFill(['email_verified_at' => now()])->save();
echo \$user->email.PHP_EOL;
" "
``` ```
Adjust field names if the model evolves. Never reuse `admin@amare.local` / `password`. 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`.
## Backup and restore ## Backup and restore

View File

@@ -61,6 +61,10 @@
font-family: var(--amare-font-serif); font-family: var(--amare-font-serif);
} }
[id$='-heading'] {
scroll-margin-top: 5.5rem;
}
a:focus-visible, a:focus-visible,
button:focus-visible, button:focus-visible,
summary:focus-visible, summary:focus-visible,
@@ -98,4 +102,147 @@
body.menu-open { body.menu-open {
overflow: hidden; 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) {
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="seal"],
html[data-motion="enhance"] [data-motion="dossie-hero"] [data-motion-beat="cta"] {
opacity: 0;
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] {
opacity: 1;
transform: none;
clip-path: inset(0 0 0 0);
transition:
opacity var(--amare-duration-slow) var(--amare-ease-arrival),
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) {
opacity: 0;
transform: translateY(0.5rem);
}
html[data-motion="enhance"] [data-reveal].is-revealed {
opacity: 1;
transform: none;
transition:
opacity var(--amare-duration-slow) var(--amare-ease-arrival),
transform var(--amare-duration-slow) var(--amare-ease-arrival);
}
html[data-motion="enhance"] [data-motion="page-open"]:not(.is-active) {
opacity: 0;
transform: translateY(0.4rem);
}
html[data-motion="enhance"] [data-motion="page-open"].is-active {
opacity: 1;
transform: none;
transition:
opacity var(--amare-duration-normal) var(--amare-ease-arrival),
transform var(--amare-duration-normal) var(--amare-ease-arrival);
}
}
} }

View File

@@ -56,7 +56,9 @@
--amare-duration-fast: 150ms; --amare-duration-fast: 150ms;
--amare-duration-normal: 250ms; --amare-duration-normal: 250ms;
--amare-duration-slow: 400ms; --amare-duration-slow: 400ms;
--amare-duration-focal: 720ms;
--amare-ease-standard: cubic-bezier(0.4, 0, 0.2, 1); --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) { @media (prefers-reduced-motion: reduce) {
@@ -64,6 +66,7 @@
--amare-duration-fast: 0.01ms; --amare-duration-fast: 0.01ms;
--amare-duration-normal: 0.01ms; --amare-duration-normal: 0.01ms;
--amare-duration-slow: 0.01ms; --amare-duration-slow: 0.01ms;
--amare-duration-focal: 0.01ms;
} }
*, *,

View File

@@ -1,3 +1,5 @@
import './motion.js';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const menuButton = document.querySelector('[data-menu-button]'); const menuButton = document.querySelector('[data-menu-button]');
const navigation = document.querySelector('[data-main-nav]'); const navigation = document.querySelector('[data-main-nav]');

166
resources/js/motion.js Normal file
View File

@@ -0,0 +1,166 @@
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();
}

View File

@@ -0,0 +1,15 @@
@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

View File

@@ -2,8 +2,8 @@
'settings', 'settings',
]) ])
<section aria-labelledby="final-cta-heading" class="border-t border-amare-border bg-amare-bg-deep py-20"> <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"> <div class="container-amare space-y-6 text-center" data-reveal>
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Próximo passo</p> <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> <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"> <p class="mx-auto max-w-2xl text-amare-muted">

View File

@@ -2,14 +2,22 @@
'settings', 'settings',
]) ])
<section aria-labelledby="hero-heading" class="border-b border-amare-border bg-amare-bg"> <section
aria-labelledby="hero-heading"
class="border-b border-amare-border bg-amare-bg"
data-chapter="hero"
data-motion="dossie-hero"
>
<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="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 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)) @if (filled($settings->hero_eyebrow))
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">{{ $settings->hero_eyebrow }}</p>
@endif @endif
</div>
<h1 id="hero-heading" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl"> <h1 id="hero-heading" data-motion-beat="title" class="max-w-3xl text-4xl font-medium leading-none text-amare-text md:text-5xl">
{{ $settings->hero_title }} {{ $settings->hero_title }}
</h1> </h1>
@@ -17,7 +25,7 @@
<p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p> <p class="max-w-2xl text-lg text-amare-text-muted">{{ $settings->hero_subtitle }}</p>
@endif @endif
<div class="flex flex-wrap items-center gap-4"> <div data-motion-beat="cta" class="flex flex-wrap items-center gap-4">
<a <a
href="{{ route('contact') }}" href="{{ route('contact') }}"
data-testid="home-primary-cta" data-testid="home-primary-cta"
@@ -42,7 +50,7 @@
</div> </div>
@if (filled($settings->default_og_image_path)) @if (filled($settings->default_og_image_path))
<div class="min-h-72 bg-amare-bg-deep"> <div data-motion-beat="media" class="min-h-72 overflow-hidden bg-amare-bg-deep">
<x-media.image <x-media.image
:path="$settings->default_og_image_path" :path="$settings->default_og_image_path"
:alt="$settings->default_og_image_alt ?: $settings->brand_name" :alt="$settings->default_og_image_alt ?: $settings->brand_name"

View File

@@ -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.'; $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 @endphp
<section aria-labelledby="manifesto-heading" class="border-b border-amare-border bg-amare-bg-deep py-20"> <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)]"> <div class="container-amare grid gap-8 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Manifesto</p>
<div class="space-y-6"> <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> <h2 id="manifesto-heading" class="max-w-3xl text-3xl font-medium leading-tight text-amare-text md:text-4xl">{{ $title }}</h2>

View File

@@ -7,9 +7,9 @@
$intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.'; $intro = $settings->method_intro ?: 'Clareza em cada etapa. Tranquilidade durante todo o processo.';
@endphp @endphp
<section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20"> <section aria-labelledby="method-heading" class="border-b border-amare-border bg-amare-bg-archive py-20" data-chapter="method">
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] md:items-start"> <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"> <div class="space-y-3" data-reveal>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Método</p> <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> <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> <p class="text-amare-text-muted">{{ $intro }}</p>
@@ -17,7 +17,7 @@
<ol class="grid gap-5 border-t border-amare-border"> <ol class="grid gap-5 border-t border-amare-border">
@foreach ($steps as $index => $step) @foreach ($steps as $index => $step)
<li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]"> <li class="grid gap-2 border-b border-amare-border py-5 md:grid-cols-[4rem_minmax(0,1fr)]" data-reveal>
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span> <span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span>
<div class="space-y-2"> <div class="space-y-2">
<h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3> <h3 class="text-2xl font-medium text-amare-text">{{ $step['title'] ?? '' }}</h3>

View File

@@ -3,9 +3,9 @@
]) ])
@if ($cases->isNotEmpty()) @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"> <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">
<div class="container-amare space-y-10"> <div class="container-amare space-y-10">
<div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]"> <div class="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]" data-reveal>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p> <p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent-text/80">Portfólio</p>
<div class="space-y-3"> <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> <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"> <div class="grid gap-10 md:grid-cols-2">
@foreach ($cases as $case) @foreach ($cases as $case)
<article class="space-y-4 border-t border-amare-accent-text/30 pt-4"> <article class="space-y-4 border-t border-amare-accent-text/30 pt-4" data-reveal>
@if (filled($case->cover_image_path)) @if (filled($case->cover_image_path))
<x-media.image <x-media.image
:path="$case->cover_image_path" :path="$case->cover_image_path"

View File

@@ -7,13 +7,13 @@
$principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples(); $principles = filled($settings->principles) ? $settings->principles : \App\Models\SiteSetting::defaultPrinciples();
@endphp @endphp
<section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20"> <section aria-labelledby="positioning-heading" class="border-b border-amare-border py-20" data-chapter="positioning">
<div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]"> <div class="container-amare grid gap-10 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div class="space-y-3"> <div class="space-y-3" data-reveal>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">A Amare</p> <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> <h2 id="positioning-heading" class="text-3xl font-medium text-amare-text">Presença que organiza o essencial.</h2>
</div> </div>
<div class="space-y-8"> <div class="space-y-8" data-reveal>
<p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p> <p class="max-w-2xl text-xl leading-relaxed text-amare-text">{{ $summary }}</p>
<ul class="grid gap-3 border-t border-amare-border"> <ul class="grid gap-3 border-t border-amare-border">
@foreach ($principles as $principle) @foreach ($principles as $principle)

View File

@@ -3,9 +3,9 @@
]) ])
@if ($services->isNotEmpty()) @if ($services->isNotEmpty())
<section aria-labelledby="services-heading" class="border-b border-amare-border py-20"> <section aria-labelledby="services-heading" class="border-b border-amare-border py-20" data-chapter="services">
<div class="container-amare space-y-10"> <div class="container-amare space-y-10">
<div class="max-w-2xl space-y-3"> <div class="max-w-2xl space-y-3" data-reveal>
<p class="text-sm font-medium uppercase tracking-[0.18em] text-amare-accent">Atuação</p> <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> <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> <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"> <ol class="border-t border-amare-border">
@foreach ($services as $index => $service) @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"> <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>
<span class="text-sm text-amare-accent">{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}</span> <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> <h3 class="text-2xl font-medium text-amare-text">{{ $service->title }}</h3>
<p class="text-amare-text-muted">{{ $service->summary }}</p> <p class="text-amare-text-muted">{{ $service->summary }}</p>

View File

@@ -3,9 +3,9 @@
]) ])
@if ($testimonials->isNotEmpty()) @if ($testimonials->isNotEmpty())
<section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16"> <section aria-labelledby="testimonials-heading" class="border-b border-amare-border bg-amare-bg-muted py-16" data-chapter="testimonials">
<div class="container-amare space-y-8"> <div class="container-amare space-y-8">
<div class="max-w-2xl space-y-3"> <div class="max-w-2xl space-y-3" data-reveal>
<h2 id="testimonials-heading" class="text-3xl font-semibold text-amare-text">Depoimentos</h2> <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> <p class="text-amare-text-muted">Quem celebrou com a Amare conta como foi a experiência.</p>
</div> </div>
@@ -16,7 +16,7 @@
$paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: []; $paragraphs = preg_split('/\n\s*\n/', trim((string) $testimonial->quote)) ?: [];
$paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== '')); $paragraphs = array_values(array_filter(array_map('trim', $paragraphs), fn (string $p): bool => $p !== ''));
@endphp @endphp
<blockquote class="space-y-4 border-t border-amare-border pt-4"> <blockquote class="space-y-4 border-t border-amare-border pt-4" data-reveal>
<div class="space-y-3 text-lg text-amare-text"> <div class="space-y-3 text-lg text-amare-text">
@foreach ($paragraphs as $index => $paragraph) @foreach ($paragraphs as $index => $paragraph)
<p>@if ($index === 0)@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)@endif</p> <p>@if ($index === 0)@endif{{ $paragraph }}@if ($index === count($paragraphs) - 1)@endif</p>

View File

@@ -8,7 +8,7 @@
$city = $siteSettings->city ?: 'São Paulo - SP'; $city = $siteSettings->city ?: 'São Paulo - SP';
@endphp @endphp
<div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg"> <div class="flex min-h-[calc(100dvh-14rem)] flex-col border-b border-amare-border bg-amare-bg" data-motion="page-open">
<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="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"> <div class="space-y-6">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">A Amare</p>

View File

@@ -1,6 +1,32 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @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.hero :settings="$content->settings" />
<x-home.manifesto :settings="$content->settings" /> <x-home.manifesto :settings="$content->settings" />
<x-home.services :services="$content->featuredServices" /> <x-home.services :services="$content->featuredServices" />

View File

@@ -1,7 +1,7 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="border-b border-amare-border bg-amare-bg-deep"> <div class="border-b border-amare-border bg-amare-bg-deep" data-motion="page-open">
<div class="container-amare space-y-10 py-16 md:py-24"> <div class="container-amare space-y-10 py-16 md:py-24">
<div class="max-w-2xl space-y-4"> <div class="max-w-2xl space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Portfólio</p>

View File

@@ -1,7 +1,7 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<article> <article data-motion="page-open">
<div class="border-b border-amare-border bg-amare-bg"> <div class="border-b border-amare-border bg-amare-bg">
<div class="container-amare space-y-8 py-16 md:py-24"> <div class="container-amare space-y-8 py-16 md:py-24">
<header class="max-w-3xl space-y-4"> <header class="max-w-3xl space-y-4">

View File

@@ -1,7 +1,7 @@
@extends('layouts.public') @extends('layouts.public')
@section('content') @section('content')
<div class="border-b border-amare-border bg-amare-bg"> <div class="border-b border-amare-border bg-amare-bg" data-motion="page-open">
<div class="container-amare space-y-10 py-16 md:py-24"> <div class="container-amare space-y-10 py-16 md:py-24">
<div class="max-w-2xl space-y-4"> <div class="max-w-2xl space-y-4">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p> <p class="text-xs font-semibold uppercase tracking-[0.14em] text-amare-accent">Serviços</p>

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Trigger a Dokploy Compose deploy and wait until it finishes. # Trigger a Dokploy Compose deploy and wait until it finishes.
# Required env: # Required env:
# DOKPLOY_URL e.g. https://panel.example.com # DOKPLOY_URL e.g. https://panel.example.com (panel origin, no /api)
# DOKPLOY_API_KEY x-api-key value # DOKPLOY_API_KEY x-api-key value
# DOKPLOY_COMPOSE_ID target compose id # DOKPLOY_COMPOSE_ID target compose id
# Optional env: # Optional env:
@@ -15,21 +15,53 @@ set -euo pipefail
: "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}" : "${DOKPLOY_API_KEY:?DOKPLOY_API_KEY is required}"
: "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}" : "${DOKPLOY_COMPOSE_ID:?DOKPLOY_COMPOSE_ID is required}"
# Panel origin only: strip trailing slash and accidental OpenAPI /api suffix.
DOKPLOY_URL="${DOKPLOY_URL%/}" DOKPLOY_URL="${DOKPLOY_URL%/}"
DOKPLOY_URL="${DOKPLOY_URL%/api}"
DOKPLOY_URL="${DOKPLOY_URL%/}"
DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}" DEPLOY_TITLE="${DEPLOY_TITLE:-GitHub deploy}"
DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}" DEPLOY_TIMEOUT_SEC="${DEPLOY_TIMEOUT_SEC:-900}"
DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}" DEPLOY_POLL_SEC="${DEPLOY_POLL_SEC:-10}"
echo "Dokploy API base: ${DOKPLOY_URL}/api"
api() { api() {
local method="$1" local method="$1"
local path="$2" local path="$2"
shift 2 shift 2
curl -fsS -X "$method" \ local url="${DOKPLOY_URL}/api${path}"
local response http_code body
# Body then HTTP status on the last line (no -f so 4xx/5xx still return a body).
response="$(
curl -sS -w $'\n%{http_code}' -X "$method" \
-H "accept: application/json" \ -H "accept: application/json" \
-H "content-type: application/json" \ -H "content-type: application/json" \
-H "x-api-key: ${DOKPLOY_API_KEY}" \ -H "x-api-key: ${DOKPLOY_API_KEY}" \
"${DOKPLOY_URL}/api${path}" \ "$url" \
"$@" "$@"
)" || {
echo "Dokploy API request failed (curl transport error)." >&2
echo " method: ${method}" >&2
echo " url: ${url}" >&2
exit 1
}
http_code="$(printf '%s' "$response" | tail -n1)"
body="$(printf '%s' "$response" | sed '$d')"
if [[ ! "$http_code" =~ ^2[0-9][0-9]$ ]]; then
echo "Dokploy API error." >&2
echo " method: ${method}" >&2
echo " url: ${url}" >&2
echo " status: ${http_code}" >&2
echo " body:" >&2
printf '%s\n' "$body" >&2
exit 1
fi
printf '%s' "$body"
} }
echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..." echo "Triggering Dokploy compose.deploy for ${DOKPLOY_COMPOSE_ID}..."

View File

@@ -95,7 +95,22 @@ it('loads covered public routes without console errors', function (): void {
}); });
it('disables transitions when prefers-reduced-motion is reduce', function (): void { it('disables transitions when prefers-reduced-motion is reduce', function (): void {
$page = $this->visit('/', [ $case = PortfolioCase::factory()->published()->create([
'slug' => 'casamento-jardim',
]);
$routes = [
'/',
'/servicos',
'/portfolio',
'/portfolio/'.$case->slug,
'/sobre',
'/contato',
'/privacidade',
];
foreach ($routes as $route) {
$page = $this->visit($route, [
'reducedMotion' => 'reduce', 'reducedMotion' => 'reduce',
]); ]);
@@ -111,4 +126,5 @@ it('disables transitions when prefers-reduced-motion is reduce', function (): vo
JS); JS);
expect((float) $duration)->toBeLessThan(0.02); expect((float) $duration)->toBeLessThan(0.02);
}
}); });

View File

@@ -0,0 +1,121 @@
<?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');
});

View File

@@ -0,0 +1,14 @@
<?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();
});

View File

@@ -0,0 +1,132 @@
<?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);
$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);
}
}