mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
feat(S13): cards personalizados da campanha no site público
Home ganha a seção Faça parte com os três modelos e a rota /cards concentra catálogo + compositor client-side (dialog desktop, bottom drawer mobile): nome no slot medido do mestre com Brexter 700 e foto full-bleed sob o overlay oficial (pan/zoom clampados), download PNG sem conta e sem enviar nome/foto. Núcleo puro em src/lib/card* com unit tests; e2e cobre home→deep-link, nome→PNG real, erro fail-closed, foto e drawer mobile. Sem migration/PII.
This commit is contained in:
parent
581cb933a8
commit
5b4f91ca3c
28 changed files with 2207 additions and 1 deletions
|
|
@ -20,8 +20,9 @@ Conceitos e decisões: `docs/ARCHITECTURE.md`. Operação (setup/seeds/deploy/se
|
|||
- `src/utilities/access/` — RBAC por domínio; `campaignAccess.ts` é o barrel de re-export; predicados de role client-safe em `src/lib/campaignRoles.ts`.
|
||||
- `src/utilities/<domínio>/` — subpastas de domínio (P3-J, gatilho D1): `municipality/` (incl. `campaignMunicipalityScope`, `goalCoverage`), `supporter/`, `territory/` (incl. `loadTerritoryOverview`), `leadership/`, `visit/`, `webauthn/` (`campaignWebAuthn*`). Imports diretos, **sem barrels**. O top-level de `src/utilities/` é **pinado** (`codebaseConventions.unit.spec.ts`): módulo novo top-level falha o build até ser registrado — a 3º módulo novo de um domínio no mês, o domínio ganha subpasta. Helpers puros que estavam na camada errada moram em `lib/` (`campaignTime`, `relationship`).
|
||||
- `src/components/campaign/<domínio>/` — municipality, activity, supporter, leadership, advisor, demand, organization, stateDeputy, votePledge, invite, map, dashboard, auth, shell (sidebar/nav/PWA), shared (sistema de listas + widgets genéricos).
|
||||
- `src/components/cards/` — estúdio público de cards personalizados (S13): `CardsStudio` (seleção + shell dialog/drawer), `CardComposer`, `CardModelGallery`/`CardModelTile`, `CardPreviewCanvas`, adapters em `cardCanvas.ts`. Geometria/catálogo puros em `src/lib/card{Models,NameFit,PhotoTransform,Render}.ts`.
|
||||
- `src/components/ui/` — kit shadcn da casa (sem peças mortas; `shadcn add` re-adiciona).
|
||||
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`).
|
||||
- `src/lib/schemas/` — zod de entrada; `src/lib/` também guarda catálogos estáticos e o artefato TSE (`electionAggregates/`), além dos módulos puros do estúdio de cards públicos (`cardModels`, `cardNameFit`, `cardPhotoTransform`, `cardRender`).
|
||||
- `src/migrations/` — história congelada; nunca editar retroativamente.
|
||||
- `tests/` — `unit` primeiro (lógica pura, DATABASE_URL inválida), depois `int` para fronteiras Payload/DB (Payload real em `teqo_test`, fixtures em `tests/helpers/campaignFixtures.ts`), e `e2e` só com benefício real por fluxo que as camadas inferiores não cobrem (Playwright smoke; dono da definição: skill `test-driven-development`).
|
||||
|
||||
|
|
|
|||
1
docs/changelog/2026-09-12-s13-cards-personalizados.md
Normal file
1
docs/changelog/2026-09-12-s13-cards-personalizados.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**S13 — cards personalizados da campanha no site público (2026-09-12):** o visitante agora cria um card de apoio com a identidade oficial e baixa um PNG sem conta e sem enviar nome/foto — tudo processado no aparelho. A home ganhou a seção `Faça parte / Mostre que você está com Solla` (depois da captura de novidades, antes do rodapé) com os três modelos e o CTA `Criar meu card`; a rota nova `/cards` (grupo `(home)`, herda tema `campaign-site` + MetaPixel) concentra catálogo e edição, com deep-link `?model=` sanitizado server-side (`isCardModelId`; valor inválido cai no catálogo). O compositor é uma ilha única (`CardsStudio`) com um editor (`CardComposer`) em dialog central (desktop) ou bottom drawer (mobile, `useIsMobileMeasured`); o núcleo de composição é puro e unit-testado (`cardModels`/`cardNameFit`/`cardPhotoTransform`/`cardRender`, ctx estrutural injetado): o nome entra na área medida do mestre (centro x≈570, cap 106px, tinta `#ffec01`, shrink/wrap até o mínimo legível e erro acionável em vez de corte silencioso) usando a Brexter 700 (`next/font/local`, `document.fonts.load` antes de medir) e as fotos são desenhadas full-bleed sob o overlay-mestre (mesmos PNGs oficiais, saída 1000×1000 e 1000×1440), com cover na janela transparente e pan/zoom clampados (toque, setas, slider de zoom). Assets originais versionados em `public/cards/`; os três modelos saem do mesmo catálogo em `src/lib/cardModels.ts` (home, catálogo e render). Sem migration, sem collection, sem Consent novo, sem PII em rede/analytics; e2e novo no spec `frontend` cobre home→deep-link, nome→resultado→download PNG real, erro de nome longo fail-closed, foto (decode + erro recuperável) e drawer mobile. Manifest de e2e ganhou os prefixos `src/components/cards`/`src/lib/card` → `frontend` e o prewarm dev inclui `/cards`. Plano: `docs/plans/cards-personalizados-campanha-impl.md`.
|
||||
192
docs/plans/cards-personalizados-campanha-impl.md
Normal file
192
docs/plans/cards-personalizados-campanha-impl.md
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Impl: Cards personalizados da campanha no site público
|
||||
|
||||
Status: em execução
|
||||
Atualizado em: 2026-09-12
|
||||
Issue: #941
|
||||
Intenção: docs/plans/cards-personalizados-campanha.md
|
||||
Appetite restante: herdado — ~3–4 dias eng (fases 1–4 somam ≤4 dias; sem migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** o visitante (majoritariamente no celular) encontra o convite na home com os três modelos, entra em `/cards`, personaliza com nome ou foto, confere a prévia e baixa um PNG com a identidade da campanha — sem conta e sem que nome/foto saiam do aparelho.
|
||||
- **O que NÃO negociar:**
|
||||
- Fluxo 100% client-side: sem upload, sem persistência, sem conta, sem analytics de PII; nome/foto nunca saem do aparelho.
|
||||
- Nenhuma collection/global/migration/CMS novo; artes-mestre intocadas (compor por cima, nunca redesenhar).
|
||||
- Dimensões reais de saída: nome `1080×1440`; foto quadrada `1000×1000`; foto vertical `1000×1440`.
|
||||
- Nome nunca cortado silenciosamente: reduzir/quebrar até o mínimo legível; acima disso, erro acionável.
|
||||
- `/cards` é a única superfície de edição; o download do PNG é o fim (sem API de rede social).
|
||||
- Copy literais da intenção preservados.
|
||||
- **O que reavaliar (hipóteses da intenção):**
|
||||
- “Arquivos originais já estão acessíveis em `/home/fsolla/Documentos/cards/`” — verdade no workstation, **não** em produção: assets e fonte precisam ser provisionados no repo (o Dockerfile copia `src/` e `public/`).
|
||||
- “overlays responsivos existentes apenas como referência” — o overlay oficial é o próprio PNG mestre; não recriar faixas/contra-formas em CSS/SVG.
|
||||
- A base do modelo de nome já traz os textos fixos (`#SOU`, `TÔ COM SOLLA`); o compositor desenha **somente o nome** na banda medida — não re-renderizar a frase.
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
H["Home · CampaignCardsSection"] -->|"Criar meu card"| C["/cards"]
|
||||
H -->|"/cards?model=id"| C
|
||||
C -->|"await searchParams"| S["CardsStudio (client)"]
|
||||
S --> G["CardModelGallery (grid / peek)"]
|
||||
S --> O{"viewport abaixo de 768px?"}
|
||||
O -->|"não"| M["Dialog (Radix)"]
|
||||
O -->|"sim"| W["Drawer (base-ui, bottom)"]
|
||||
M --> K["CardComposer (editor único)"]
|
||||
W --> K
|
||||
K -->|"nome"| NF["cardNameFit"]
|
||||
K -->|"foto"| PF["cardPhotoTransform"]
|
||||
NF --> R["cardRender"]
|
||||
PF --> R
|
||||
R --> P["prévia no canvas"]
|
||||
K -->|"Criar meu card"| RES["Resultado"]
|
||||
RES -->|"Baixar meu card"| DL["PNG no aparelho"]
|
||||
CM["cardModels — catálogo único"] -.-> G
|
||||
CM -.-> R
|
||||
```
|
||||
|
||||
**Opções consideradas:** A (rota dentro de `(home)` herdando o layout da campanha + ilha única de edição + núcleo puro em `src/lib/card*`) | B (rota com layout próprio espelhando a home, estilo `artigos/`) | C (dois compositores independentes, um modal e um drawer)
|
||||
**Recomendação:** A — a home e `/cards` são o mesmo funil e a mesma pele; o layout `(home)` já é o dono do `data-theme="campaign-site"`, do container de scroll e do MetaPixel, e o núcleo puro em `lib` mantém o editor testável sem DOM.
|
||||
**Rejeitadas:** B porque duplica o dono do tema/scroll/pixel e cria divergência de pele no meio do funil (`artigos/`/`privacidade/` são outro domínio, o editorial); C porque dois editores divergem — é exatamente o rabbit hole “dois fluxos de criação” da intenção; o que muda entre desktop e mobile é a casca, não o editor.
|
||||
|
||||
### Decisões de engenharia
|
||||
|
||||
1. **Rota/tema — `/cards` dentro de `(home)`.**
|
||||
Opções: A) `src/app/(frontend)/(home)/cards/page.tsx` herdando `(home)/layout.tsx` (campaign-site + MetaPixel + container `h-dvh overflow-y-auto`) | B) `src/app/(frontend)/cards/` com layout próprio no molde de `artigos/layout.tsx` | C) `(frontend)/cards/` sem layout novo.
|
||||
Recomendação: A — zero duplicação do que o layout da home já garante; a URL é `/cards` nos três casos e o segmento estático vence o `[type]` dinâmico, sem conflito.
|
||||
Rejeitadas: B porque reimplementa tema/scroll/pixel (twin do layout) para uma página do mesmo funil; C porque perde os tokens `--pt-*`/`--campaign-*` e o container de scroll interno.
|
||||
Consequências aceitas: `/cards` dispara o PageView do pixel S10 (sem PII; é o mesmo funil). Portais de Dialog/Drawer montam no `body`, fora do subtree temado: `DialogContent`/`DrawerContent` re-aplicam `data-theme="campaign-site"` (precedente `PetitionSuccessDialog` com `data-theme="petition"`) e não dependem de `--popover` do tema.
|
||||
|
||||
2. **`?model=` — leitura server-side com guard.**
|
||||
Opções: A) `await searchParams` na page e `initialModelId` para a ilha client | B) `useSearchParams` no client + `<Suspense>` | C) `window.location.search` em efeito.
|
||||
Recomendação: A — o primeiro render já conhece o modelo e a casca abre direto (sem flash de catálogo-fechado); precedente no repo (`(campaign)/campanha/redefinir-senha/page.tsx:22-34`); a página vira dinâmica e isso é gratuito (não lê dados).
|
||||
Rejeitadas: B porque exige Suspense e ainda pode pintar o catálogo antes de abrir; C porque é o flash pós-mount que a intenção quer evitar e não funciona no SSR.
|
||||
Guard: `isCardModelId` (puro) sanitiza; `string[]` → primeiro item; id desconhecido → ignora e mostra só o catálogo (sem 404, sem redirect). A seleção feita em `/cards` não reescreve a URL (o param é entrada/deep-link; revisitar só se alguém pedir link do catálogo).
|
||||
|
||||
3. **Compositor — uma ilha, um editor, dois shells.**
|
||||
Opções: A) `CardsStudio` (client) dono de `selectedId`/`open`; `CardComposer` único compartilhado entre `Dialog` (desktop) e `Drawer` (mobile), com o modo nome/foto ramificado pelo `kind` do modelo; lógica pura em `src/lib/card*` | B) compositor modal e compositor drawer independentes | C) um componente por modelo (nome/foto) com estado duplicado.
|
||||
Recomendação: A — um só estado e um só editor; `useIsMobileMeasured` (`src/hooks/use-mobile.ts:31-34`) evita montar a casca errada no primeiro frame (até medir, renderiza só o catálogo); casca desktop = `ui/dialog` (Radix, precedente `PetitionSuccessDialog`), mobile = `ui/Drawer` (`modal`, `swipeDirection='down'`, `showSwipeHandle`).
|
||||
Rejeitadas: B porque os editores divergem (o estado de nome/foto/transform vazaria para dois lugares); C porque a diferença nome↔foto é um campo de formulário, não dois fluxos — duplicaria fit/transform/render.
|
||||
Nota de render: prévia desenha em canvas com tamanho limitado (capped, ex. maior aresta ~720px) e o export desenha na dimensão do modelo; ambos usam o mesmo `cardRender`, então não há divergência de spec.
|
||||
|
||||
4. **Fonte — `next/font/local` com o TTF no repo.**
|
||||
Opções: A) `localFont` em `src/app/(frontend)/fonts.ts` com o arquivo em `src/app/(frontend)/fonts/Brexter-Bold.ttf` | B) `new FontFace(...)` client-side a partir de `public/cards/brexter-700.ttf` | C) fetch do TTF como ArrayBuffer + `FontFace` sem CSS.
|
||||
Recomendação: A — convenção do repo (`next/font/google` em `(frontend)/layout.tsx:13-17`), self-host + hash + preload, e o arquivo viaja no build (Dockerfile copia `src/`); o client lê `brexterBold.style.fontFamily` e **espera** `document.fonts.load(...)` antes de medir/desenhar (canvas não espera o CSS).
|
||||
Rejeitadas: B porque carrega manualmente, sem preload/pipeline e divergindo da convenção; C porque reinventa o pipeline do Next sem ganho.
|
||||
Restrição estrutural: `src/lib` não pode importar `next` (ESLint `no-restricted-imports`), por isso o módulo da fonte vive em `app/` e só a família (string) chega ao núcleo puro.
|
||||
Fallback documentado: se o build reclamar do formato TTF, converter para WOFF2 na mesma fase (mesma família/permissão), mantendo o path do módulo.
|
||||
|
||||
5. **Slot do nome — reproduzir o mestre, não centralizar.**
|
||||
Opções: A) seguir a geometria medida do mestre: centro x≈570, cap height 106px, ink ≤714px, banda y[430..536], tinta `#ffec01`; reduzir/quebrar até `MIN_CAP_HEIGHT` e acima disso erro acionável | B) centralizar no canvas (x=540) e simplificar o fit.
|
||||
Recomendação: A — o aceite exige “sem aproximação visual”; o centro do ink do mestre é ~570 (30px fora do centro do canvas) e centralizar desencaixa o nome dos textos fixos da base.
|
||||
Rejeitadas: B porque viola o aceite de fidelidade ao mestre em um ponto mensurável.
|
||||
Mecânica: `resolveFontSizeForCapHeight` calibra o corpo a partir de `actualBoundingBoxAscent` de um probe caixa-alta (não usar 106 como `font-size`); fit tenta 1 linha reduzindo, depois 2 linhas por quebra de espaço; nunca quebra no meio da palavra nem trunca; normalização `trim` + colapso de espaços + caixa alta `pt-BR` (o mestre é caixa alta; acentos preservados). Se o natural não reproduzir os gaps do mestre para `FULANO`, fallback é desenho por caractere com tracking explícito (puro, testável) — decidido na Fase 1 com a comparação com a referência preenchida.
|
||||
|
||||
6. **e2e — estender `tests/e2e/frontend.e2e.spec.ts`.**
|
||||
Opções: A) novo `test.describe` no spec `frontend` já coletado + prewarm de `/cards` | B) criar `tests/e2e/cards.e2e.spec.ts` + `testMatch` no `playwright.config.ts` + manifest + pins.
|
||||
Recomendação: A — menor blast radius: o projeto `frontend` já coleta esse arquivo (`playwright.config.ts:176-181`) e o manifest já mapeia `src/app/(frontend)` → `frontend` (`scripts/lib/e2e-affected-manifest.mjs:62-65`); cobre o que unit não cobre (canvas/`toBlob`/download e o fluxo real de arquivo). Adicionar `/cards` à lista de prewarm do `setup.e2e.spec.ts` (só dev; prod pula o setup).
|
||||
Rejeitadas: B porque muda config + manifest + pins por nenhum ganho de cobertura, e um spec novo sem `testMatch` nem seria coletado.
|
||||
Cobertura: home (seção/ordem/3 modelos/hrefs), `/cards` direto (catálogo, composer fechado), `?model=` (composer aberto sem clique), nome → resultado → download real (`waitForEvent('download')`, `.png`, bytes > 0), mobile drawer + foto via `setInputFiles` com buffer em memória, buffer inválido → erro recuperável com `Escolher outra foto`.
|
||||
Manifest: mapear os prefixos novos `src/components/cards` e `src/lib/card` → `['frontend']`, para diffs futuros dessa superfície não caírem no fallback `campaignHomeActions` (que não cobre o site público).
|
||||
|
||||
7. **Assets — `public/cards/` com bytes originais e nomes ASCII.**
|
||||
Opções: A) copiar os mestres sem re-encode para `public/cards/name-card-base.jpg`, `photo-square-frame.png`, `photo-portrait-frame.png`; catálogo reusa os mesmos arquivos via `next/image` | B) manter os nomes originais (`template-to-com-solla-site-sem-o-nome.jpg.jpeg`) | C) re-encodar/otimizar os mestres.
|
||||
Recomendação: A — preserva o aceite “sem aproximação visual”, dá paths previsíveis e reusa o mesmo arquivo no catálogo (assets pequenos: ~430KB/142KB/169KB). A fonte vai separada em `src/app/(frontend)/fonts/` (não em `public`).
|
||||
Rejeitadas: B porque path frágil (extensão dupla, não-ASCII); C porque re-encodar altera a arte-mestre (fora de escopo e risco de perda).
|
||||
Tile do modelo de nome: `next/image` da base vazia com o placeholder `SEU NOME` sobreposto em HTML posicionado pelos percentuais do slot (centro 52,78%, banda 29,86%); o canvas desenha nome de verdade apenas no compositor.
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/app/(frontend)/(home)/cards/page.tsx`** (novo): RSC; `searchParams: Promise<{ model?: string | string[] }>`, primeiro item se array, `isCardModelId`; metadata `Crie seu card de apoio | Jorge Solla`; renderiza `<CardsStudio initialModelId={…} />`.
|
||||
- **`src/app/(frontend)/(home)/CampaignCardsSection.tsx`** (novo): seção server da home no molde de `CampaignNewsletterSection.tsx` — `id="cards"`, `aria-labelledby`, `data-home-section="cards"`, `mx-auto max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16`, eyebrow/título/corpo com `.campaign-section-*`, CTA `--pt-red`; usa `CardModelGallery variant="link"` (tiles levam a `/cards?model=<id>`).
|
||||
- **`src/app/(frontend)/(home)/page.tsx`** (editar): `<CampaignCardsSection />` logo após `<CampaignNewsletterSection … />`, dentro do `<main>`, antes de `</main>`/`CampaignFooter`.
|
||||
- **`src/app/(frontend)/fonts.ts`** + **`src/app/(frontend)/fonts/Brexter-Bold.ttf`** (novos): `export const brexterBold = localFont({ src: './fonts/Brexter-Bold.ttf', weight: '700', style: 'normal', display: 'swap' })`; só a família (string) cruza para os componentes.
|
||||
- **`src/lib/cardModels.ts`** (novo, puro): `CardModelId`, `CardModel`, `CARD_MODELS`, `isCardModelId`, `NAME_SLOT` (centerX 570 / capHeight 106 / maxInkWidth 714 / bandTop 430 / bandBottom 536 / ink `#ffec01`), `PHOTO_WINDOWS` (quadrada 1000×1000 janela y[0..740]; vertical 1000×1440 janela y[0..1044]), `OUTPUT` dims e paths ASCII. Fonte única do catálogo (home + `/cards` + render).
|
||||
- **`src/lib/cardNameFit.ts`** (novo, puro): `fitCardName(name, measure, slot)` + `resolveFontSizeForCapHeight`; retorno `{ ok: true; lines; fontSize; lineHeight } | { ok: false; reason: 'too-long' }`; `MIN_CAP_HEIGHT` fill-in (~48px, validado no craft); nunca trunca.
|
||||
- **`src/lib/cardPhotoTransform.ts`** (novo, puro): cover scale na janela, `clampPhotoTransform`, `photoDrawRect`; zoom 1–4; pan clampado para nunca abrir gap na janela (inclui a contra-forma transparente dentro dos limites da janela).
|
||||
- **`src/lib/cardRender.ts`** (novo, puro): interfaces estruturais mínimas `CardRenderContext`/`CardTextMetrics` (o `CanvasRenderingContext2D` real as satisfaz); `renderNameCard` (base → nome) e `renderPhotoCard` (foto → máscara, nessa ordem); sem dependência de DOM/Next.
|
||||
- **`src/components/cards/CardsStudio.tsx`** (novo, client): dono de `selectedId`/`open`; escolhe a casca com `useIsMobileMeasured`; fechar mantém a seleção no catálogo; `Criar outro card` volta ao catálogo.
|
||||
- **`src/components/cards/CardModelGallery.tsx`** (novo, client): grid de 3 no desktop; scroll-snap com peek do próximo no mobile, sem auto-advance; `variant: 'link' | 'select'`; reusa os mesmos assets do catálogo via `next/image`.
|
||||
- **`src/components/cards/CardComposer.tsx`** (novo, client): editor único (conteúdo compartilhado entre Dialog e Drawer); passos compose → result; modo pelo `kind` (nome: input + fit + erro inline; foto: file picker + pan/zoom + `Trocar foto`); ações `Cancelar`/`Criar meu card`; resultado `Baixar meu card`/`Criar outro card`; erro de foto com `Escolher outra foto`.
|
||||
- **`src/components/cards/CardPreviewCanvas.tsx`** (novo, client): carrega assets e espera `document.fonts.load` (só desenha com a fonte real), desenha via `cardRender` em canvas de preview limitado.
|
||||
- **`src/components/cards/cardCanvas.ts`** (novo, client-safe adapter): `loadCardImage` (`createImageBitmap` + fallback `Image.decode`), `toPngBlob`, `downloadBlob` (Blob → object URL → `<a download>` → revoke, precedente `SupporterImportWizard.tsx:50-58`); filename sem PII (`card-jorge-solla-<modelId>.png`).
|
||||
- **`src/app/(frontend)/styles.css`** (editar só se o craft criar classe própria, `.campaign-cards-*` sob `[data-theme='campaign-site']`; preferir Tailwind + tokens existentes).
|
||||
- **`scripts/lib/e2e-affected-manifest.mjs`** (editar): prefixos `src/components/cards` e `src/lib/card` → `['frontend']`.
|
||||
- **`tests/e2e/setup.e2e.spec.ts`** (editar): `'/cards'` no prewarm.
|
||||
- **`tests/e2e/frontend.e2e.spec.ts`** (editar): novo describe “Cards personalizados” com os cenários da decisão 6.
|
||||
- **`tests/unit/cardModels.unit.spec.ts`**, **`tests/unit/cardNameFit.unit.spec.ts`**, **`tests/unit/cardPhotoTransform.unit.spec.ts`**, **`tests/unit/cardRender.unit.spec.ts`** (novos): fake ctx/measure (jsdom não tem canvas); catálogo íntegro, fit (shrink/quebra/mínimo/erro/acentos), clamp do pan/zoom e ordem de desenho.
|
||||
- **`.agents/rules/codebase-map.mdc`** (editar): registrar `src/components/cards/` na seção “Onde vive o quê”.
|
||||
- **`docs/changelog/2026-09-12-s13-cards-personalizados.md`** (novo): entrada da entrega.
|
||||
- **Migration:** sem migration — nenhuma collection/global/field toca o schema; `push:false` intocado.
|
||||
- **Access / Consent:** não se aplica — nenhuma superfície Payload; nenhum dado pessoal trafega; sem chave nova de Consent.
|
||||
- **UI:** Impeccable C — shape (cenas do rascunho) → craft (geometria do mestre, fonte, estados) → critique (a11y, mobile real, copy) → polish. Shells `ui/dialog` (Radix) e `ui/Drawer` (base-ui) com foco preso/ESC/`DialogTitle`+`DialogDescription`; alvos ≥44px; seleção com `aria-current`/`aria-pressed` + marcador (não só cor); zoom e setas cobrem “não depender de arrastar”; aviso de privacidade antes de escolher a foto.
|
||||
|
||||
### Copy (literais da intenção → superfície)
|
||||
|
||||
| Literal | Onde entra |
|
||||
| -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| `Faça parte` | eyebrow da seção da home e de `/cards` |
|
||||
| `Mostre que você está com Solla` | h2 da seção da home |
|
||||
| `Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com sua gente e fortaleça nossa caminhada.` | corpo da seção da home |
|
||||
| `Criar meu card` | CTA da home e ação primária do composer (compose) |
|
||||
| `Crie seu card de apoio` | h1 de `/cards` + `<title>` |
|
||||
| `Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.` | intro de `/cards` |
|
||||
| `Card com seu nome` / `Moldura quadrada` / `Moldura vertical` | labels dos modelos (catálogo e composer) |
|
||||
| `Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.` | aviso de privacidade (página e composer, antes da foto) |
|
||||
| `Trocar foto` | ação do passo de foto |
|
||||
| `Cancelar` | ação secundária do composer |
|
||||
| `Seu card está pronto para compartilhar.` | passo resultado |
|
||||
| `Baixar meu card` / `Criar outro card` | ações do resultado |
|
||||
|
||||
Copy de apoio vinda do rascunho UI (não é literal da intenção; validar no craft/critique, não reescrever ao implementar): `Seu nome` (label), `Personalize com seu nome` / `Enquadre sua foto` (títulos do composer), `Zoom` / `Ajuste fino`, `Não foi possível usar esta foto` / `Escolha outra imagem e tente novamente.` / `Escolher outra foto` (erro de foto), `Voltar para a campanha` (voltar). Copy nova do plano, sujeita ao gate humano/critique: mensagem inline de nome longo (“Esse nome não cabe no card. Use um nome mais curto, como você é chamado.”) e estado de carregamento do preview.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
Não se aplica (data-presentation Q3): nenhum KPI, mapa ou série; a única “forma” é a geometria do próprio card, definida pelas artes-mestre. Nome/foto são insumos locais da imagem, nunca dados apresentados ou coletados.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer ponta a ponta do modelo de nome** — quota ~1,5 dia. Provisiona assets/fonte; `cardModels`/`cardNameFit`/`cardRender` + units; rota `(home)/cards` + `CardsStudio` mínimo + composer de nome + preview + resultado/download; calibra o slot contra a referência preenchida (`FULANO`) no navegador.
|
||||
Prova: `pnpm gate:fast` verde; `/cards?model=eu-sou-solla` abre o composer direto; download de um PNG `1080×1440` com `João`/`Conceição` legível e encaixado como no mestre.
|
||||
2. **Catálogo + seção na home** — quota ~1 dia. `CardModelGallery` (grid desktop / peek mobile) e `CampaignCardsSection` depois da newsletter; variantes link/select; a11y e sem overflow horizontal.
|
||||
Prova: home na ordem certa, 3 modelos visíveis no desktop, 1 + peek no mobile, tiles com `href` `/cards?model=<id>`; e2e de home verde.
|
||||
3. **Modelos de foto** — quota ~1 dia. `cardPhotoTransform` + unit; file picker, decode, pan/zoom clampados, overlay por cima, erro recuperável, `Trocar foto`; saídas `1000×1000` e `1000×1440`.
|
||||
Prova: retrato e paisagem enquadram sem gap; arquivo inválido cai no erro sem travar; unit do clamp verde.
|
||||
4. **Craft/Critique/Polish + e2e + gates** — quota ~0,5 dia. Impeccable C; e2e novo + prewarm + manifest; changelog; `pnpm gate:fast`; e2e local do describe (`pnpm test:e2e --no-deps -- tests/e2e/frontend.e2e.spec.ts -g "Cards personalizados"`); `pnpm push`.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Não criar collection/global/migration/CMS para modelos (o catálogo é código).
|
||||
- Não criar um segundo compositor na home: o tile da home é link e só `/cards` edita.
|
||||
- Não recriar faixas/contra-formas das artes em CSS/SVG; overlay é o PNG mestre.
|
||||
- Não implementar Web Share API, share de rede social, upload, conta, galeria ou histórico.
|
||||
- Não extrair carrossel genérico para uma terceira variante agora (precedente documentado em `CampaignContentCarousel.tsx:13-18`); gatilho de revisitação: uma 4ª superfície de carrossel.
|
||||
- Não adicionar detecção de rosto, crop automático, filtros, textos livres, múltiplos nomes ou tipografia configurável.
|
||||
- Não testar pixels no jsdom nem snapshot visual no CI; fidelidade ao mestre é provada no craft + download real no e2e.
|
||||
- Não usar OffscreenCanvas/worker sem evidência de jank (prévia limitada + export final resolvem).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Fonte não pronta no draw** (medida com fallback desalinha o fit): só desenhar após `document.fonts.load` e `document.fonts.check` na família primária; estado de carregamento no preview.
|
||||
- **Canvas tainted/cross-origin**: assets same-origin em `public/cards/`; nunca URL externa.
|
||||
- **Foto HEIC/grande/corrompida**: decode em try/catch com erro recuperável (`Escolher outra foto`); formatos suportados pelo browser seguem funcionando; limite defensivo de tamanho sem bloquear caso comum.
|
||||
- **Gap no pan/zoom**: clamp puro testado; a foto é desenhada antes do overlay, então nem uma borda vazaria.
|
||||
- **iOS/Safari**: `toBlob` suportado; testar em iPhone físico no craft e registrar no PR; fallback `toDataURL` no catch.
|
||||
- **Portais fora do tema**: `data-theme="campaign-site"` no conteúdo portaled e cores fixadas explicitamente (não depender de `--popover`).
|
||||
- **Peso da home**: `next/image` com `sizes` para os tiles; os assets full só são carregados quando o composer abre (lazy).
|
||||
- **Cold compile do `/cards` no e2e dev**: entrada no prewarm do `setup.e2e.spec.ts`.
|
||||
- **Formato TTF no `next/font/local`**: se o build reclamar, converter para WOFF2 na mesma fase (família/permissão inalteradas).
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto: seção na home após a newsletter; 3 modelos (grid/carrossel com peek); CTA e tiles levam a `/cards`; composer modal desktop/bottom drawer mobile; nome só na banda do mestre, sem corte silencioso; foto com pan/zoom sobre a máscara; aviso de privacidade; resultado + download `PNG`; erro recuperável; toque e teclado.
|
||||
- [ ] Invariantes AGENTS/engineering-standards: sem schema/migration; sem Consent novo; nenhum PII em rede/analytics; `src/lib` puro (sem Next/Payload); copy pt-BR / identificadores em inglês; sem `as never`; knip sem órfãos; gate `pnpm gate:fast` + `pnpm push` verdes.
|
||||
- [ ] Testes de domínio previstos: unit dos módulos puros (fit, transform, render, catálogo) obrigatório; e2e no spec `frontend` com download real; **int não se aplica** — não há Payload/DB/access nem escrita multi-collection neste fluxo (100% client-side + assets estáticos).
|
||||
|
||||
## Self-score decision-quality: 5/5
|
||||
|
||||
1. **Decisões caras com rejeitadas**: rota/tema, `?model=`, estrutura do compositor, fonte, slot do nome, harness de e2e e assets — todas com Opções/Recomendação/Rejeitadas e consequências.
|
||||
2. **Cabe no appetite**: 4 fases somam ≤4 dias, sem migration, sem infra nova; o tracer do nome vem primeiro.
|
||||
3. **Rabbit holes nomeados**: coleção/CMS, segundo compositor, redesenho das artes, share social, carrossel genérico, testes de pixel/worker.
|
||||
4. **Depth check**: reusa shells (`ui/dialog`, `ui/Drawer`, `useIsMobileMeasured`, `next/font`, padrão de seção da home, prewarm/manifest/specs existentes); não cria abstração especulativa.
|
||||
5. **Intenção preservada**: aceite de produto intacto; engenharia não reescreveu outcome, copy nem geometria do mestre.
|
||||
BIN
public/cards/name-card-base.jpg
Normal file
BIN
public/cards/name-card-base.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 417 KiB |
BIN
public/cards/photo-portrait-frame.png
Normal file
BIN
public/cards/photo-portrait-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
public/cards/photo-square-frame.png
Normal file
BIN
public/cards/photo-square-frame.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
|
|
@ -63,6 +63,13 @@ export const E2E_AFFECTED_MANIFEST = [
|
|||
prefixes: ['src/app/(frontend)'],
|
||||
specs: ['frontend'],
|
||||
},
|
||||
{
|
||||
// S13 — the personalized-cards studio lives in shared cards components and
|
||||
// pure card* modules; a future diff there must wake the frontend spec (the
|
||||
// home section is already covered by the src/app/(frontend) prefix).
|
||||
prefixes: ['src/components/cards', 'src/lib/card'],
|
||||
specs: ['frontend'],
|
||||
},
|
||||
{
|
||||
prefixes: ['src/app/(campaign)/campanha/login', 'src/utilities/campaignAuth'],
|
||||
specs: ['campaignAuth'],
|
||||
|
|
|
|||
47
src/app/(frontend)/(home)/CampaignCardsSection.tsx
Normal file
47
src/app/(frontend)/(home)/CampaignCardsSection.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { CardModelGallery } from '@/components/cards/CardModelGallery'
|
||||
|
||||
/**
|
||||
* S13 — the home invitation to the card funnel: short copy + the three model
|
||||
* tiles, positioned after the newsletter capture (which stays the first
|
||||
* conversion of the page end) and before the footer. Each tile deep-links into
|
||||
* `/cards` with its model selected; the general CTA opens the plain catalog.
|
||||
*/
|
||||
export const CampaignCardsSection = () => (
|
||||
<section
|
||||
id="cards"
|
||||
aria-labelledby="cards-title"
|
||||
data-home-section="cards"
|
||||
className="border-b border-(--campaign-line) bg-(--campaign-cream)"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1160px] px-5 py-12 sm:px-8 lg:px-10 lg:py-16">
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
|
||||
Faça parte
|
||||
</p>
|
||||
<h2
|
||||
id="cards-title"
|
||||
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
|
||||
>
|
||||
Mostre que você está com Solla
|
||||
</h2>
|
||||
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">
|
||||
Escolha um modelo, coloque seu nome ou sua foto e baixe seu card. Compartilhe com sua
|
||||
gente e fortaleça nossa caminhada.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardModelGallery variant="link" ariaLabel="Modelos de card" />
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
href="/cards"
|
||||
className="inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-(--pt-red) px-6 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none sm:w-auto"
|
||||
>
|
||||
Criar meu card
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
64
src/app/(frontend)/(home)/cards/page.tsx
Normal file
64
src/app/(frontend)/(home)/cards/page.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { brexterBold } from '@/app/(frontend)/fonts'
|
||||
import { CampaignFooter } from '@/components/CampaignFooter'
|
||||
import { CardsStudio } from '@/components/cards/CardsStudio'
|
||||
import { isCardModelId } from '@/lib/cardModels'
|
||||
|
||||
const intro =
|
||||
'Escolha um dos três modelos, personalize com seu nome ou sua foto e baixe para compartilhar.'
|
||||
const privacyNote =
|
||||
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Crie seu card de apoio | Jorge Solla',
|
||||
description: intro,
|
||||
}
|
||||
|
||||
type CardsPageProps = {
|
||||
searchParams: Promise<{ model?: string | string[] }>
|
||||
}
|
||||
|
||||
export default async function CardsPage({ searchParams }: CardsPageProps) {
|
||||
const params = await searchParams
|
||||
const rawModel = Array.isArray(params.model) ? params.model[0] : params.model
|
||||
const initialModelId = isCardModelId(rawModel) ? rawModel : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className={`w-full bg-white text-black ${brexterBold.variable}`}>
|
||||
<div className="mx-auto w-full max-w-[1160px] px-5 pt-6 sm:px-8 lg:px-10">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex min-h-11 items-center text-sm font-bold text-(--pt-red) underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
>
|
||||
← Voltar para a campanha
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section
|
||||
aria-labelledby="cards-page-title"
|
||||
className="mx-auto w-full max-w-[1160px] px-5 pt-6 pb-12 sm:px-8 lg:px-10 lg:pt-10 lg:pb-16"
|
||||
>
|
||||
<div className="mx-auto max-w-2xl text-center">
|
||||
<p className="campaign-section-eyebrow m-0 font-black tracking-[0.1em] text-(--pt-red) uppercase">
|
||||
Faça parte
|
||||
</p>
|
||||
<h1
|
||||
id="cards-page-title"
|
||||
className="campaign-section-title m-0 mt-1 border-0 p-0 font-black tracking-[-0.02em] text-balance"
|
||||
>
|
||||
Crie seu card de apoio
|
||||
</h1>
|
||||
<p className="campaign-section-copy m-0 mt-1 text-(--campaign-muted)">{intro}</p>
|
||||
<p className="mt-2 text-xs text-(--campaign-muted)">{privacyNote}</p>
|
||||
</div>
|
||||
|
||||
<CardsStudio initialModelId={initialModelId} fontFamily={brexterBold.style.fontFamily} />
|
||||
</section>
|
||||
</main>
|
||||
<CampaignFooter />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { CampaignProblemCard } from '@/components/CampaignProblemCard'
|
|||
import { CampaignStorySection } from '@/components/CampaignStorySection'
|
||||
import { getCampaignHomeMetaPixelId } from '@/utilities/campaignHomeTracking'
|
||||
import type { ReactNode } from 'react'
|
||||
import { CampaignCardsSection } from './CampaignCardsSection'
|
||||
import { CampaignNewsletterSection } from './CampaignNewsletterSection'
|
||||
|
||||
/**
|
||||
|
|
@ -236,6 +237,7 @@ export default async function HomePage() {
|
|||
|
||||
<CampaignStorySection />
|
||||
<CampaignNewsletterSection pixelId={pixelId ?? undefined} />
|
||||
<CampaignCardsSection />
|
||||
</main>
|
||||
|
||||
<CampaignFooter />
|
||||
|
|
|
|||
18
src/app/(frontend)/fonts.ts
Normal file
18
src/app/(frontend)/fonts.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import localFont from 'next/font/local'
|
||||
|
||||
/**
|
||||
* S13 — Brexter 700, the display face of the official card masters. Web use is
|
||||
* licensed for the campaign (regular DEMO face is deliberately excluded). The
|
||||
* font is only consumed by the card canvas, so the family string travels to the
|
||||
* client and `ensureCardFont` awaits `document.fonts.load` before measuring.
|
||||
*/
|
||||
export const brexterBold = localFont({
|
||||
src: './fonts/Brexter-Bold.ttf',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
display: 'swap',
|
||||
variable: '--font-brexter',
|
||||
// Canvas-only face: preloading it on every page would be waste (the studio
|
||||
// loads it on demand via `document.fonts.load`).
|
||||
preload: false,
|
||||
})
|
||||
BIN
src/app/(frontend)/fonts/Brexter-Bold.ttf
Normal file
BIN
src/app/(frontend)/fonts/Brexter-Bold.ttf
Normal file
Binary file not shown.
486
src/components/cards/CardComposer.tsx
Normal file
486
src/components/cards/CardComposer.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUpIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useId, useRef, useState, type PointerEvent } from 'react'
|
||||
|
||||
import {
|
||||
canvasToPngBlob,
|
||||
cardFileName,
|
||||
downloadBlob,
|
||||
ensureCardFont,
|
||||
loadCardImage,
|
||||
loadCardPhoto,
|
||||
} from '@/components/cards/cardCanvas'
|
||||
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
|
||||
import type { CardModel } from '@/lib/cardModels'
|
||||
import type { CardNameFit } from '@/lib/cardNameFit'
|
||||
import {
|
||||
CARD_PHOTO_MAX_ZOOM,
|
||||
CARD_PHOTO_MIN_ZOOM,
|
||||
centerCardPhotoTransform,
|
||||
panCardPhotoTransform,
|
||||
zoomCardPhotoTransform,
|
||||
type CardPhotoTransform,
|
||||
} from '@/lib/cardPhotoTransform'
|
||||
import { createCardMeasure, renderNameCard, renderPhotoCard } from '@/lib/cardRender'
|
||||
|
||||
const PRIVACY_NOTE =
|
||||
'Seu nome e sua foto são processados apenas no seu aparelho e não são enviados para nós.'
|
||||
const PHOTO_PRIVACY_NOTE = 'Sua foto fica neste aparelho e não é enviada para nós.'
|
||||
|
||||
const primaryButtonClassName =
|
||||
'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-(--pt-red) px-5 text-sm font-extrabold text-white transition hover:brightness-95 focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
||||
const secondaryButtonClassName =
|
||||
'inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border-2 border-(--campaign-line) bg-white px-5 text-sm font-extrabold text-(--pt-red) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none'
|
||||
const controlButtonClassName =
|
||||
'inline-flex size-11 items-center justify-center rounded-lg border border-(--campaign-line) bg-white text-(--campaign-ink) transition-colors hover:bg-(--campaign-band) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none'
|
||||
|
||||
type CardComposerProps = {
|
||||
model: CardModel
|
||||
shell: 'dialog' | 'drawer'
|
||||
fontFamily: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const CardComposer = ({ model, shell, fontFamily, onClose }: CardComposerProps) => {
|
||||
const [step, setStep] = useState<'compose' | 'result'>('compose')
|
||||
const [name, setName] = useState('')
|
||||
const [nameFit, setNameFit] = useState<CardNameFit | null>(null)
|
||||
const [photo, setPhoto] = useState<HTMLImageElement | null>(null)
|
||||
const [photoTransform, setPhotoTransform] = useState<CardPhotoTransform | null>(null)
|
||||
const [photoError, setPhotoError] = useState<string | null>(null)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const dragRef = useRef<{ pointerId: number; x: number; y: number } | null>(null)
|
||||
const nameInputId = useId()
|
||||
const zoomInputId = useId()
|
||||
|
||||
const photoWindow = model.photoWindow
|
||||
const photoSize = photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const draw = async () => {
|
||||
const ctx = canvasRef.current?.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
setLoadState('loading')
|
||||
try {
|
||||
await ensureCardFont(fontFamily)
|
||||
const base = await loadCardImage(model.assetSrc)
|
||||
if (cancelled) return
|
||||
|
||||
ctx.clearRect(0, 0, model.width, model.height)
|
||||
|
||||
if (model.kind === 'name') {
|
||||
const fit = renderNameCard(ctx, model, {
|
||||
image: base,
|
||||
name,
|
||||
fontFamily,
|
||||
measure: createCardMeasure(ctx, fontFamily),
|
||||
})
|
||||
if (!cancelled) setNameFit(fit)
|
||||
} else if (photo && photoTransform && photoWindow) {
|
||||
const clamped = renderPhotoCard(ctx, {
|
||||
photo,
|
||||
frame: base,
|
||||
photoSize: { width: photo.naturalWidth, height: photo.naturalHeight },
|
||||
frameSize: { width: model.width, height: model.height },
|
||||
window: photoWindow,
|
||||
transform: photoTransform,
|
||||
})
|
||||
if (
|
||||
!cancelled &&
|
||||
(clamped.zoom !== photoTransform.zoom ||
|
||||
clamped.offsetX !== photoTransform.offsetX ||
|
||||
clamped.offsetY !== photoTransform.offsetY)
|
||||
) {
|
||||
setPhotoTransform(clamped)
|
||||
}
|
||||
} else if (photoWindow) {
|
||||
ctx.fillStyle = '#ded8d2'
|
||||
ctx.fillRect(0, 0, model.width, model.height)
|
||||
ctx.drawImage(base, 0, 0, model.width, model.height)
|
||||
}
|
||||
|
||||
if (!cancelled) setLoadState('ready')
|
||||
} catch {
|
||||
if (!cancelled) setLoadState('error')
|
||||
}
|
||||
}
|
||||
|
||||
void draw()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [model, name, photo, photoTransform, photoWindow, fontFamily])
|
||||
|
||||
const handlePhotoFile = async (file: File | undefined) => {
|
||||
if (!file || !photoWindow) return
|
||||
setPhotoError(null)
|
||||
|
||||
try {
|
||||
const image = await loadCardPhoto(file)
|
||||
setPhoto(image)
|
||||
setPhotoTransform(
|
||||
centerCardPhotoTransform(
|
||||
{ width: image.naturalWidth, height: image.naturalHeight },
|
||||
photoWindow,
|
||||
),
|
||||
)
|
||||
} catch {
|
||||
setPhoto(null)
|
||||
setPhotoTransform(null)
|
||||
setPhotoError('Não foi possível usar esta foto. Escolha outra imagem e tente novamente.')
|
||||
} finally {
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!photoSize || !photoTransform || !photoWindow) return
|
||||
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
|
||||
const drag = dragRef.current
|
||||
const canvas = canvasRef.current
|
||||
if (
|
||||
!drag ||
|
||||
drag.pointerId !== event.pointerId ||
|
||||
!photoSize ||
|
||||
!photoTransform ||
|
||||
!photoWindow
|
||||
)
|
||||
return
|
||||
if (!canvas) return
|
||||
|
||||
const scale = model.width / Math.max(1, canvas.getBoundingClientRect().width)
|
||||
const dx = (event.clientX - drag.x) * scale
|
||||
const dy = (event.clientY - drag.y) * scale
|
||||
dragRef.current = { ...drag, x: event.clientX, y: event.clientY }
|
||||
setPhotoTransform(panCardPhotoTransform(photoTransform, photoSize, photoWindow, dx, dy))
|
||||
}
|
||||
|
||||
const handlePointerEnd = () => {
|
||||
dragRef.current = null
|
||||
}
|
||||
|
||||
const panBy = (dx: number, dy: number) => {
|
||||
if (!photoSize || !photoTransform || !photoWindow) return
|
||||
setPhotoTransform(panCardPhotoTransform(photoTransform, photoSize, photoWindow, dx, dy))
|
||||
}
|
||||
|
||||
const zoomTo = (zoom: number) => {
|
||||
if (!photoSize || !photoTransform || !photoWindow) return
|
||||
setPhotoTransform(zoomCardPhotoTransform(photoTransform, photoSize, photoWindow, zoom))
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
setIsDownloading(true)
|
||||
setDownloadError(null)
|
||||
try {
|
||||
const blob = await canvasToPngBlob(canvas)
|
||||
downloadBlob(blob, cardFileName(model.id))
|
||||
} catch {
|
||||
setDownloadError('Não foi possível gerar o arquivo agora. Tente de novo.')
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isNameModel = model.kind === 'name'
|
||||
const nameCanAdvance = nameFit?.ok === true
|
||||
const canAdvance = isNameModel ? nameCanAdvance : photo !== null
|
||||
const nameError = isNameModel && name.trim().length > 0 && nameFit !== null && !nameFit.ok
|
||||
const title =
|
||||
step === 'result'
|
||||
? 'Seu card está pronto para compartilhar.'
|
||||
: isNameModel
|
||||
? 'Personalize com seu nome'
|
||||
: 'Enquadre sua foto'
|
||||
const description =
|
||||
step === 'result' || isNameModel
|
||||
? null
|
||||
: 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
|
||||
|
||||
const header =
|
||||
shell === 'dialog' ? (
|
||||
<DialogHeader className="min-w-0 text-left">
|
||||
<DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<DrawerHeader className="min-w-0 text-left">
|
||||
<DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance">
|
||||
{title}
|
||||
</DrawerTitle>
|
||||
{description ? <DrawerDescription>{description}</DrawerDescription> : null}
|
||||
</DrawerHeader>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-(--background) text-(--foreground)">
|
||||
<div className="flex items-start gap-3 px-5 pt-5">
|
||||
{header}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Fechar"
|
||||
className="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-xl leading-none text-(--campaign-muted) transition-colors hover:bg-(--campaign-band) hover:text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 pb-5">
|
||||
<p className="sr-only" role="status" aria-live="polite">
|
||||
{loadState === 'loading' ? 'Carregando prévia…' : ''}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={`relative mx-auto w-full max-w-[22rem] ${photo ? 'touch-none' : ''}`}
|
||||
tabIndex={photo ? 0 : undefined}
|
||||
role={photo ? 'group' : undefined}
|
||||
aria-label={photo ? 'Ajuste da foto' : undefined}
|
||||
onKeyDown={
|
||||
photo
|
||||
? (event) => {
|
||||
const step = event.shiftKey ? 48 : 12
|
||||
const deltas: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-step, 0],
|
||||
ArrowRight: [step, 0],
|
||||
ArrowUp: [0, -step],
|
||||
ArrowDown: [0, step],
|
||||
}
|
||||
const delta = deltas[event.key]
|
||||
if (!delta) return
|
||||
event.preventDefault()
|
||||
panBy(delta[0], delta[1])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<CardPreviewCanvas
|
||||
model={model}
|
||||
canvasRef={canvasRef}
|
||||
className={`mx-auto block h-auto max-h-[38dvh] w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${
|
||||
loadState === 'loading' ? 'opacity-60' : 'opacity-100'
|
||||
}`}
|
||||
onPointerDown={photo ? handlePointerDown : undefined}
|
||||
onPointerMove={photo ? handlePointerMove : undefined}
|
||||
onPointerUp={photo ? handlePointerEnd : undefined}
|
||||
onPointerCancel={photo ? handlePointerEnd : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadState === 'error' ? (
|
||||
<p role="alert" className="mt-3 text-sm font-semibold text-(--pt-red)">
|
||||
Não foi possível carregar o modelo. Recarregue a página e tente de novo.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && isNameModel ? (
|
||||
<div className="mt-5">
|
||||
<label htmlFor={nameInputId} className="block text-sm font-bold text-(--campaign-ink)">
|
||||
Seu nome
|
||||
</label>
|
||||
<input
|
||||
id={nameInputId}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoComplete="off"
|
||||
maxLength={60}
|
||||
className="mt-2 h-11 w-full rounded-lg border border-(--field-border) bg-white px-3 text-base text-(--campaign-ink) focus-visible:border-(--pt-red) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-(--campaign-muted)">
|
||||
Use seu primeiro nome ou o nome pelo qual as pessoas conhecem você.
|
||||
</p>
|
||||
{nameError ? (
|
||||
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
||||
Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é
|
||||
chamado.
|
||||
</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-(--campaign-muted)">{PRIVACY_NOTE}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'compose' && !isNameModel && photoWindow ? (
|
||||
<div className="mt-5">
|
||||
{photo && photoTransform ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3">
|
||||
<label htmlFor={zoomInputId} className="text-sm font-bold text-(--campaign-ink)">
|
||||
Zoom
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Diminuir zoom"
|
||||
onClick={() => zoomTo(photoTransform.zoom - 0.1)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<MinusIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<input
|
||||
id={zoomInputId}
|
||||
type="range"
|
||||
min={CARD_PHOTO_MIN_ZOOM}
|
||||
max={CARD_PHOTO_MAX_ZOOM}
|
||||
step={0.05}
|
||||
value={photoTransform.zoom}
|
||||
onChange={(event) => zoomTo(Number(event.target.value))}
|
||||
className="h-11 w-full flex-1 accent-(--pt-red)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Aumentar zoom"
|
||||
onClick={() => zoomTo(photoTransform.zoom + 0.1)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<PlusIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-bold text-(--campaign-ink)">Ajuste fino</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Mover foto para a esquerda"
|
||||
onClick={() => panBy(-12, 0)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Mover foto para cima"
|
||||
onClick={() => panBy(0, -12)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<ArrowUpIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Mover foto para baixo"
|
||||
onClick={() => panBy(0, 12)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Mover foto para a direita"
|
||||
onClick={() => panBy(12, 0)}
|
||||
className={controlButtonClassName}
|
||||
>
|
||||
<ArrowRightIcon className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-(--campaign-muted)">
|
||||
Escolha uma foto vertical ou horizontal para começar.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
onChange={(event) => void handlePhotoFile(event.target.files?.[0])}
|
||||
/>
|
||||
|
||||
{photoError ? (
|
||||
<div role="alert" className="mt-4 rounded-lg border border-(--pt-red) bg-white p-3">
|
||||
<p className="text-sm font-bold text-(--pt-red)">Não foi possível usar esta foto</p>
|
||||
<p className="mt-1 text-sm text-(--campaign-muted)">
|
||||
Escolha outra imagem e tente novamente.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={secondaryButtonClassName}
|
||||
>
|
||||
{photoError ? 'Escolher outra foto' : photo ? 'Trocar foto' : 'Escolher foto'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-(--campaign-muted)">{PHOTO_PRIVACY_NOTE}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'result' ? (
|
||||
<div className="mt-5">
|
||||
<p className="text-sm text-(--campaign-muted)">{PRIVACY_NOTE}</p>
|
||||
{downloadError ? (
|
||||
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
|
||||
{downloadError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-3 border-t border-(--campaign-line) px-5 py-4">
|
||||
{step === 'compose' ? (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className={secondaryButtonClassName}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAdvance || loadState !== 'ready'}
|
||||
onClick={() => setStep('result')}
|
||||
className={primaryButtonClassName}
|
||||
>
|
||||
Criar meu card
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={onClose} className={secondaryButtonClassName}>
|
||||
Criar outro card
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDownloading || loadState !== 'ready'}
|
||||
onClick={() => void handleDownload()}
|
||||
className={primaryButtonClassName}
|
||||
>
|
||||
{isDownloading ? 'Gerando…' : 'Baixar meu card'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
src/components/cards/CardModelGallery.tsx
Normal file
92
src/components/cards/CardModelGallery.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { CardModelTile } from '@/components/cards/CardModelTile'
|
||||
import { CARD_MODELS, cardModelHref, type CardModel, type CardModelId } from '@/lib/cardModels'
|
||||
|
||||
type CardModelGalleryProps = {
|
||||
variant: 'link' | 'select'
|
||||
ariaLabel: string
|
||||
selectedId?: CardModelId | null
|
||||
onSelect?: (id: CardModelId) => void
|
||||
}
|
||||
|
||||
const tileClassName =
|
||||
'group flex h-full w-full flex-col rounded-xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:ring-offset-(--campaign-cream)'
|
||||
|
||||
/**
|
||||
* S13 — the three models side by side on desktop and a one-item-with-peek
|
||||
* snap track on mobile. `link` renders deep links (home funnel), `select`
|
||||
* renders buttons for the `/cards` studio; both come from the same catalog.
|
||||
*/
|
||||
export const CardModelGallery = ({
|
||||
variant,
|
||||
ariaLabel,
|
||||
selectedId = null,
|
||||
onSelect,
|
||||
}: CardModelGalleryProps) => {
|
||||
const renderItem = (model: CardModel) => {
|
||||
const selected = variant === 'select' && selectedId === model.id
|
||||
const content = (
|
||||
<>
|
||||
<CardModelTile model={model} selected={selected} />
|
||||
<span className="mt-auto block pt-3 text-center text-sm font-bold text-(--campaign-ink) sm:text-base">
|
||||
{model.label}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
|
||||
if (variant === 'select') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={`${model.label}${selected ? ' (selecionado)' : ''}`}
|
||||
onClick={() => onSelect?.(model.id)}
|
||||
className={tileClassName}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={cardModelHref(model.id)}
|
||||
aria-label={`${model.label} — criar meu card`}
|
||||
className={tileClassName}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<ul
|
||||
aria-label={`${ariaLabel} (desktop)`}
|
||||
className="m-0 hidden list-none grid-cols-3 gap-5 p-0 sm:gap-6 lg:grid"
|
||||
>
|
||||
{CARD_MODELS.map((model) => (
|
||||
<li key={model.id} className="m-0">
|
||||
{renderItem(model)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<ul
|
||||
aria-label={`${ariaLabel} (mobile)`}
|
||||
className="scrollbar-hide m-0 flex list-none snap-x snap-mandatory gap-4 overflow-x-auto scroll-px-5 p-0 pb-2 lg:hidden"
|
||||
>
|
||||
{CARD_MODELS.map((model) => (
|
||||
<li key={model.id} className="m-0 flex w-[78%] shrink-0 snap-start sm:w-[58%]">
|
||||
{renderItem(model)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className="mt-1 text-center text-xs font-semibold text-(--campaign-muted) lg:hidden">
|
||||
Deslize para ver os três modelos
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
79
src/components/cards/CardModelTile.tsx
Normal file
79
src/components/cards/CardModelTile.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { CheckIcon, UserRoundIcon } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
|
||||
import { NAME_CARD_SLOT, type CardModel } from '@/lib/cardModels'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAME_OVERLAY_LEFT = `${(NAME_CARD_SLOT.centerX / 1080) * 100}%`
|
||||
const NAME_OVERLAY_TOP = `${(NAME_CARD_SLOT.capTop / 1440) * 100}%`
|
||||
|
||||
/**
|
||||
* S13 — artwork preview of one card model, shared by the home section and the
|
||||
* `/cards` gallery. The name tile reuses the empty master with an HTML
|
||||
* `SEU NOME` placeholder (the real name is only drawn by the canvas); the photo
|
||||
* tiles show the official transparent overlay above a neutral photo slot.
|
||||
*/
|
||||
export const CardModelTile = ({
|
||||
model,
|
||||
selected = false,
|
||||
priority = false,
|
||||
}: {
|
||||
model: CardModel
|
||||
selected?: boolean
|
||||
priority?: boolean
|
||||
}) => (
|
||||
<span
|
||||
className={cn(
|
||||
'relative block overflow-hidden rounded-xl border bg-(--campaign-band) transition duration-200',
|
||||
selected
|
||||
? 'border-(--pt-red) ring-2 ring-(--pt-red)'
|
||||
: 'border-(--campaign-line) group-hover:-translate-y-0.5 group-hover:shadow-lg',
|
||||
)}
|
||||
style={{ aspectRatio: `${model.width} / ${model.height}`, containerType: 'inline-size' }}
|
||||
>
|
||||
{model.kind === 'photo' ? (
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-[linear-gradient(160deg,#ded8d2,#f7f3ef)]"
|
||||
/>
|
||||
{model.photoWindow ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 flex items-center justify-center"
|
||||
style={{
|
||||
height: `${((model.photoWindow.y + model.photoWindow.height) / model.height) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<UserRoundIcon className="h-[30%] w-auto text-(--campaign-muted) opacity-45" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Image
|
||||
src={model.assetSrc}
|
||||
alt=""
|
||||
fill
|
||||
priority={priority}
|
||||
sizes="(min-width: 1024px) 340px, 78vw"
|
||||
className="object-cover"
|
||||
/>
|
||||
|
||||
{model.kind === 'name' ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute -translate-x-1/2 font-[family-name:var(--font-exo2)] leading-none font-black tracking-[-0.02em] text-[#ffec01]"
|
||||
style={{ left: NAME_OVERLAY_LEFT, top: NAME_OVERLAY_TOP, fontSize: '11.5cqw' }}
|
||||
>
|
||||
SEU NOME
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{selected ? (
|
||||
<span className="absolute top-3 right-3 inline-flex size-7 items-center justify-center rounded-full bg-(--pt-red) text-white shadow-md">
|
||||
<CheckIcon className="size-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
26
src/components/cards/CardPreviewCanvas.tsx
Normal file
26
src/components/cards/CardPreviewCanvas.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { HTMLAttributes, RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* S13 — the canvas element of the card studio. Presentational only: the
|
||||
* composer owns the ref, the draw effect and the pointer/keyboard controls.
|
||||
*/
|
||||
export const CardPreviewCanvas = ({
|
||||
model,
|
||||
canvasRef,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
model: { width: number; height: number; label: string }
|
||||
canvasRef: RefObject<HTMLCanvasElement | null>
|
||||
className?: string
|
||||
} & HTMLAttributes<HTMLCanvasElement>) => (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={model.width}
|
||||
height={model.height}
|
||||
role="img"
|
||||
aria-label={`Prévia do card: ${model.label}`}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
79
src/components/cards/CardsStudio.tsx
Normal file
79
src/components/cards/CardsStudio.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { CardComposer } from '@/components/cards/CardComposer'
|
||||
import { CardModelGallery } from '@/components/cards/CardModelGallery'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { Drawer, DrawerContent } from '@/components/ui/Drawer'
|
||||
import { useIsMobileMeasured } from '@/hooks/use-mobile'
|
||||
import { getCardModel, type CardModelId } from '@/lib/cardModels'
|
||||
|
||||
/**
|
||||
* S13 — the `/cards` studio island: owns which model is selected and whether
|
||||
* the composer is open, renders the catalog and mounts the single editor in a
|
||||
* centered dialog (desktop) or a bottom drawer (mobile). Closing returns to the
|
||||
* catalog with the selection kept; the URL is only read, never rewritten.
|
||||
*/
|
||||
export const CardsStudio = ({
|
||||
initialModelId,
|
||||
fontFamily,
|
||||
}: {
|
||||
initialModelId?: CardModelId
|
||||
fontFamily: string
|
||||
}) => {
|
||||
const initialModel = initialModelId ? getCardModel(initialModelId) : undefined
|
||||
const [selectedId, setSelectedId] = useState<CardModelId | null>(initialModel?.id ?? null)
|
||||
const [open, setOpen] = useState(Boolean(initialModel))
|
||||
const { isMobile, measured } = useIsMobileMeasured()
|
||||
const selectedModel = selectedId ? getCardModel(selectedId) : undefined
|
||||
|
||||
const handleSelect = (id: CardModelId) => {
|
||||
setSelectedId(id)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CardModelGallery
|
||||
variant="select"
|
||||
ariaLabel="Modelos de card"
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
|
||||
{selectedModel && measured ? (
|
||||
isMobile ? (
|
||||
<Drawer open={open} onOpenChange={setOpen} swipeDirection="down" showSwipeHandle>
|
||||
<DrawerContent
|
||||
data-theme="campaign-site"
|
||||
className="bg-background text-foreground [--drawer-content-max-height:92dvh]"
|
||||
>
|
||||
<CardComposer
|
||||
model={selectedModel}
|
||||
shell="drawer"
|
||||
fontFamily={fontFamily}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
) : (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent
|
||||
data-theme="campaign-site"
|
||||
showCloseButton={false}
|
||||
className="max-h-[92dvh] gap-0 overflow-hidden p-0 sm:max-w-lg"
|
||||
>
|
||||
<CardComposer
|
||||
model={selectedModel}
|
||||
shell="dialog"
|
||||
fontFamily={fontFamily}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
74
src/components/cards/cardCanvas.ts
Normal file
74
src/components/cards/cardCanvas.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* S13 — browser adapters for the card studio. Kept out of `src/lib` because
|
||||
* they touch DOM APIs; the pure geometry stays in `cardRender`/`cardNameFit`/
|
||||
* `cardPhotoTransform`. Imports of the model assets are cached so reopening a
|
||||
* composer never re-downloads the master.
|
||||
*/
|
||||
|
||||
const CARD_IMAGE_CACHE = new Map<string, Promise<HTMLImageElement>>()
|
||||
|
||||
export const loadCardImage = (src: string): Promise<HTMLImageElement> => {
|
||||
const cached = CARD_IMAGE_CACHE.get(src)
|
||||
if (cached) return cached
|
||||
|
||||
const promise = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image()
|
||||
image.decoding = 'async'
|
||||
image.onload = () => resolve(image)
|
||||
image.onerror = () => {
|
||||
CARD_IMAGE_CACHE.delete(src)
|
||||
reject(new Error(`card-image-load-failed:${src}`))
|
||||
}
|
||||
image.src = src
|
||||
})
|
||||
|
||||
CARD_IMAGE_CACHE.set(src, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
export const loadCardPhoto = (file: File): Promise<HTMLImageElement> =>
|
||||
new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.decoding = 'async'
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('card-photo-load-failed'))
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
|
||||
/** Canvas does not wait for CSS fonts: the family must be loaded before measuring. */
|
||||
export const ensureCardFont = async (fontFamily: string): Promise<boolean> => {
|
||||
if (typeof document === 'undefined' || !document.fonts) return false
|
||||
|
||||
try {
|
||||
await document.fonts.load(`700 100px ${fontFamily}`)
|
||||
return document.fonts.check(`700 100px ${fontFamily}`)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const canvasToPngBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
|
||||
new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error('card-png-unavailable'))
|
||||
}, 'image/png')
|
||||
})
|
||||
|
||||
export const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export const cardFileName = (modelId: string): string => `card-jorge-solla-${modelId}.png`
|
||||
90
src/lib/cardModels.ts
Normal file
90
src/lib/cardModels.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* S13 — the three personalized-card models of the campaign site. Pure data so
|
||||
* the home section, the `/cards` gallery and the canvas renderer share one
|
||||
* catalog (no second editor, no CMS): each entry carries the master asset, the
|
||||
* real output size in pixels and, for photo models, the transparent window the
|
||||
* visitor's photo must cover.
|
||||
*
|
||||
* Geometry comes from the master files (pixel-measured, keep in sync with
|
||||
* `docs/plans/cards-personalizados-campanha-impl.md`): the photo frames are
|
||||
* 1000×1000 and 1000×1440 despite the `1080x` in the original file names.
|
||||
*/
|
||||
|
||||
const CARD_MODEL_IDS = ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular'] as const
|
||||
|
||||
export type CardModelId = (typeof CARD_MODEL_IDS)[number]
|
||||
type CardModelKind = 'name' | 'photo'
|
||||
|
||||
export type CardRect = {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type CardModel = {
|
||||
id: CardModelId
|
||||
kind: CardModelKind
|
||||
label: string
|
||||
assetSrc: string
|
||||
width: number
|
||||
height: number
|
||||
/** Photo models only: the transparent window the photo must cover. */
|
||||
photoWindow?: CardRect
|
||||
}
|
||||
|
||||
export const CARD_MODELS: readonly CardModel[] = [
|
||||
{
|
||||
id: 'eu-sou-solla',
|
||||
kind: 'name',
|
||||
label: 'Card com seu nome',
|
||||
assetSrc: '/cards/name-card-base.jpg',
|
||||
width: 1080,
|
||||
height: 1440,
|
||||
},
|
||||
{
|
||||
id: 'perfil-quadrado',
|
||||
kind: 'photo',
|
||||
label: 'Moldura quadrada',
|
||||
assetSrc: '/cards/photo-square-frame.png',
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
photoWindow: { x: 0, y: 0, width: 1000, height: 740 },
|
||||
},
|
||||
{
|
||||
id: 'perfil-retangular',
|
||||
kind: 'photo',
|
||||
label: 'Moldura vertical',
|
||||
assetSrc: '/cards/photo-portrait-frame.png',
|
||||
width: 1000,
|
||||
height: 1440,
|
||||
photoWindow: { x: 0, y: 0, width: 1000, height: 1044 },
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Name model slot, pixel-measured from the filled master (`FULANO` example):
|
||||
* glyph cap band tops at y=430 with a 106px cap height, ink spans x[213..926]
|
||||
* (714px) centered at x≈570 — not at the canvas center. The visitor's name is
|
||||
* the only thing drawn over the base; the fixed lines already live in the art.
|
||||
*/
|
||||
export const NAME_CARD_SLOT = {
|
||||
centerX: 570,
|
||||
capTop: 430,
|
||||
capHeight: 106,
|
||||
maxInkWidth: 714,
|
||||
maxBlockBottom: 630,
|
||||
minCapHeight: 48,
|
||||
fill: '#ffec01',
|
||||
maxLines: 2,
|
||||
} as const
|
||||
|
||||
export const BRAND_FALLBACK_FONT = 'sans-serif'
|
||||
|
||||
export const isCardModelId = (value: unknown): value is CardModelId =>
|
||||
typeof value === 'string' && (CARD_MODEL_IDS as readonly string[]).includes(value)
|
||||
|
||||
export const getCardModel = (id: CardModelId): CardModel | undefined =>
|
||||
CARD_MODELS.find((model) => model.id === id)
|
||||
|
||||
export const cardModelHref = (id: CardModelId): string => `/cards?model=${id}`
|
||||
151
src/lib/cardNameFit.ts
Normal file
151
src/lib/cardNameFit.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* S13 — pure fit math for the name card. The visitor's name must never be cut
|
||||
* silently: it starts at the master's cap height (106px), shrinks and — when a
|
||||
* single line would drop below the readable minimum — wraps into two lines
|
||||
* before shrinking further. Past `minCapHeight` the fit fails and the composer
|
||||
* asks for a shorter name.
|
||||
*
|
||||
* Text measurement is injected (`CardMeasureText`) so the module stays free of
|
||||
* DOM and unit-testable with a deterministic fake.
|
||||
*/
|
||||
|
||||
import { NAME_CARD_SLOT } from './cardModels'
|
||||
|
||||
type CardTextMetrics = {
|
||||
width: number
|
||||
actualBoundingBoxAscent: number
|
||||
actualBoundingBoxDescent: number
|
||||
}
|
||||
|
||||
export type CardMeasureText = (text: string, fontSize: number) => CardTextMetrics
|
||||
|
||||
export type CardNameFit =
|
||||
| {
|
||||
ok: true
|
||||
lines: string[]
|
||||
fontSize: number
|
||||
lineHeight: number
|
||||
capHeight: number
|
||||
}
|
||||
| { ok: false; reason: 'empty' | 'too-long' }
|
||||
|
||||
export type CardNameSlot = typeof NAME_CARD_SLOT
|
||||
|
||||
const CAP_PROBE = 'X'
|
||||
const LINE_HEIGHT_RATIO = 1.25
|
||||
const MEASURE_PROBE_SIZE = 100
|
||||
|
||||
export const normalizeCardName = (name: string): string =>
|
||||
name.trim().replace(/\s+/g, ' ').toLocaleUpperCase('pt-BR')
|
||||
|
||||
export const resolveFontSizeForCapHeight = (
|
||||
measure: CardMeasureText,
|
||||
capHeight: number,
|
||||
): number => {
|
||||
const probe = measure(CAP_PROBE, MEASURE_PROBE_SIZE)
|
||||
if (probe.actualBoundingBoxAscent <= 0) return capHeight
|
||||
|
||||
return (capHeight / probe.actualBoundingBoxAscent) * MEASURE_PROBE_SIZE
|
||||
}
|
||||
|
||||
const widthAt = (measure: CardMeasureText, text: string, fontSize: number): number =>
|
||||
Math.max(0, measure(text, fontSize).width)
|
||||
|
||||
const capAt = (measure: CardMeasureText, fontSize: number): number =>
|
||||
Math.max(0, measure(CAP_PROBE, fontSize).actualBoundingBoxAscent)
|
||||
|
||||
const fitSingleLine = (
|
||||
text: string,
|
||||
measure: CardMeasureText,
|
||||
slot: CardNameSlot,
|
||||
): number | null => {
|
||||
const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight)
|
||||
const idealWidth = widthAt(measure, text, idealSize)
|
||||
if (idealWidth <= slot.maxInkWidth) return idealSize
|
||||
|
||||
const shrunk = Math.floor((idealSize * slot.maxInkWidth) / idealWidth)
|
||||
if (shrunk <= 0) return null
|
||||
if (widthAt(measure, text, shrunk) > slot.maxInkWidth) return null
|
||||
|
||||
return shrunk
|
||||
}
|
||||
|
||||
const fitTwoLines = (
|
||||
words: string[],
|
||||
measure: CardMeasureText,
|
||||
slot: CardNameSlot,
|
||||
): { fontSize: number; lines: [string, string] } | null => {
|
||||
const idealSize = resolveFontSizeForCapHeight(measure, slot.capHeight)
|
||||
const maxBlockHeight = slot.maxBlockBottom - slot.capTop
|
||||
let best: { fontSize: number; lines: [string, string] } | null = null
|
||||
|
||||
for (let split = 1; split < words.length; split++) {
|
||||
const lines: [string, string] = [words.slice(0, split).join(' '), words.slice(split).join(' ')]
|
||||
const widest = Math.max(
|
||||
widthAt(measure, lines[0], idealSize),
|
||||
widthAt(measure, lines[1], idealSize),
|
||||
)
|
||||
if (widest <= 0) continue
|
||||
|
||||
let size = Math.min(idealSize, (idealSize * slot.maxInkWidth) / widest)
|
||||
const capHeight = capAt(measure, size)
|
||||
const blockHeight = size * LINE_HEIGHT_RATIO + capHeight
|
||||
if (blockHeight > maxBlockHeight) {
|
||||
size *= maxBlockHeight / blockHeight
|
||||
}
|
||||
|
||||
size = Math.floor(size)
|
||||
if (size <= 0) continue
|
||||
|
||||
const lineWidths = [widthAt(measure, lines[0], size), widthAt(measure, lines[1], size)]
|
||||
const sizeCap = capAt(measure, size)
|
||||
const sizeBlock = size * LINE_HEIGHT_RATIO + sizeCap
|
||||
if (lineWidths.some((width) => width > slot.maxInkWidth)) continue
|
||||
if (sizeBlock > maxBlockHeight) continue
|
||||
if (best === null || size > best.fontSize) best = { fontSize: size, lines }
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Fits `name` to the master slot honoring `maxLines`. The returned geometry is
|
||||
* what `renderNameCard` draws: `capHeight` is the measured cap at the chosen
|
||||
* size and `lineHeight` is the baseline-to-baseline distance.
|
||||
*/
|
||||
export const fitCardName = (
|
||||
name: string,
|
||||
measure: CardMeasureText,
|
||||
slot: CardNameSlot = NAME_CARD_SLOT,
|
||||
): CardNameFit => {
|
||||
const normalized = normalizeCardName(name)
|
||||
if (!normalized) return { ok: false, reason: 'empty' }
|
||||
|
||||
const minFontSize = resolveFontSizeForCapHeight(measure, slot.minCapHeight)
|
||||
const singleSize = fitSingleLine(normalized, measure, slot)
|
||||
if (singleSize !== null && singleSize >= minFontSize) {
|
||||
return {
|
||||
ok: true,
|
||||
lines: [normalized],
|
||||
fontSize: singleSize,
|
||||
lineHeight: singleSize * LINE_HEIGHT_RATIO,
|
||||
capHeight: capAt(measure, singleSize),
|
||||
}
|
||||
}
|
||||
|
||||
const words = normalized.split(' ')
|
||||
if (slot.maxLines >= 2 && words.length > 1) {
|
||||
const twoLines = fitTwoLines(words, measure, slot)
|
||||
if (twoLines !== null && twoLines.fontSize >= minFontSize) {
|
||||
return {
|
||||
ok: true,
|
||||
lines: twoLines.lines,
|
||||
fontSize: twoLines.fontSize,
|
||||
lineHeight: twoLines.fontSize * LINE_HEIGHT_RATIO,
|
||||
capHeight: capAt(measure, twoLines.fontSize),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, reason: 'too-long' }
|
||||
}
|
||||
126
src/lib/cardPhotoTransform.ts
Normal file
126
src/lib/cardPhotoTransform.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* S13 — pure cover/pan/zoom math for the photo card models. The visitor photo
|
||||
* is drawn full-bleed under the master overlay and must always cover the
|
||||
* transparent window: `clampCardPhotoTransform` keeps the zoom inside
|
||||
* [1, 4] and pins the offsets so no gap can open at the window edges.
|
||||
*
|
||||
* `centerCardPhotoTransform` is the initial framing (cover on the window, not
|
||||
* on the whole card) so the face lands on the visible area of the frame.
|
||||
*/
|
||||
|
||||
import type { CardRect } from './cardModels'
|
||||
|
||||
export const CARD_PHOTO_MIN_ZOOM = 1
|
||||
export const CARD_PHOTO_MAX_ZOOM = 4
|
||||
|
||||
export type CardPhotoSize = {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type CardPhotoTransform = {
|
||||
zoom: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(Math.max(value, min), max)
|
||||
|
||||
export const coverScale = (source: CardPhotoSize, window: CardRect): number => {
|
||||
if (source.width <= 0 || source.height <= 0 || window.width <= 0 || window.height <= 0) return 1
|
||||
|
||||
return Math.max(window.width / source.width, window.height / source.height)
|
||||
}
|
||||
|
||||
export const cardPhotoDrawRect = (
|
||||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
): CardRect => {
|
||||
const clamped = clampCardPhotoTransform(transform, source, window)
|
||||
const scale = coverScale(source, window) * clamped.zoom
|
||||
|
||||
return {
|
||||
x: clamped.offsetX,
|
||||
y: clamped.offsetY,
|
||||
width: source.width * scale,
|
||||
height: source.height * scale,
|
||||
}
|
||||
}
|
||||
|
||||
export const clampCardPhotoTransform = (
|
||||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
): CardPhotoTransform => {
|
||||
const zoom = clamp(transform.zoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
|
||||
const scale = coverScale(source, window) * zoom
|
||||
const width = source.width * scale
|
||||
const height = source.height * scale
|
||||
|
||||
return {
|
||||
zoom,
|
||||
offsetX: clamp(transform.offsetX, window.x + window.width - width, window.x),
|
||||
offsetY: clamp(transform.offsetY, window.y + window.height - height, window.y),
|
||||
}
|
||||
}
|
||||
|
||||
export const centerCardPhotoTransform = (
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
): CardPhotoTransform => {
|
||||
const scale = coverScale(source, window)
|
||||
const width = source.width * scale
|
||||
const height = source.height * scale
|
||||
|
||||
return {
|
||||
zoom: CARD_PHOTO_MIN_ZOOM,
|
||||
offsetX: window.x + (window.width - width) / 2,
|
||||
offsetY: window.y + (window.height - height) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/** Nudges the offset by a step in card pixels, clamped to the window. */
|
||||
export const panCardPhotoTransform = (
|
||||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
dx: number,
|
||||
dy: number,
|
||||
): CardPhotoTransform =>
|
||||
clampCardPhotoTransform(
|
||||
{ ...transform, offsetX: transform.offsetX + dx, offsetY: transform.offsetY + dy },
|
||||
source,
|
||||
window,
|
||||
)
|
||||
|
||||
/**
|
||||
* Rescales around a window anchor (used by the zoom control): keeps the point
|
||||
* under `anchor` stable from old to new zoom, then clamps.
|
||||
*/
|
||||
export const zoomCardPhotoTransform = (
|
||||
transform: CardPhotoTransform,
|
||||
source: CardPhotoSize,
|
||||
window: CardRect,
|
||||
nextZoom: number,
|
||||
anchor: { x: number; y: number } = {
|
||||
x: window.x + window.width / 2,
|
||||
y: window.y + window.height / 2,
|
||||
},
|
||||
): CardPhotoTransform => {
|
||||
const zoom = clamp(nextZoom, CARD_PHOTO_MIN_ZOOM, CARD_PHOTO_MAX_ZOOM)
|
||||
if (zoom === transform.zoom) return clampCardPhotoTransform(transform, source, window)
|
||||
|
||||
const ratio = zoom / transform.zoom
|
||||
|
||||
return clampCardPhotoTransform(
|
||||
{
|
||||
zoom,
|
||||
offsetX: anchor.x - (anchor.x - transform.offsetX) * ratio,
|
||||
offsetY: anchor.y - (anchor.y - transform.offsetY) * ratio,
|
||||
},
|
||||
source,
|
||||
window,
|
||||
)
|
||||
}
|
||||
114
src/lib/cardRender.ts
Normal file
114
src/lib/cardRender.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* S13 — the single canvas renderer shared by the preview and the PNG export:
|
||||
* `renderNameCard` draws the master base then the fitted name; `renderPhotoCard`
|
||||
* draws the visitor photo full-bleed then the master overlay (the overlay is
|
||||
* authoritative — never redraw bands or contra-forms). The context is a
|
||||
* structural subset of `CanvasRenderingContext2D` so unit tests drive a fake
|
||||
* recorder instead of jsdom canvas.
|
||||
*/
|
||||
|
||||
import { BRAND_FALLBACK_FONT, NAME_CARD_SLOT, type CardModel, type CardRect } from './cardModels'
|
||||
import {
|
||||
fitCardName,
|
||||
type CardMeasureText,
|
||||
type CardNameFit,
|
||||
type CardNameSlot,
|
||||
} from './cardNameFit'
|
||||
import {
|
||||
cardPhotoDrawRect,
|
||||
clampCardPhotoTransform,
|
||||
type CardPhotoSize,
|
||||
type CardPhotoTransform,
|
||||
} from './cardPhotoTransform'
|
||||
|
||||
export type CardDrawContext = {
|
||||
font: string
|
||||
fillStyle: string | CanvasGradient | CanvasPattern
|
||||
textAlign: CanvasTextAlign
|
||||
textBaseline: CanvasTextBaseline
|
||||
drawImage(image: CanvasImageSource, dx: number, dy: number, dWidth: number, dHeight: number): void
|
||||
fillText(text: string, x: number, y: number): void
|
||||
measureText(text: string): CardTextMetricsLike
|
||||
}
|
||||
|
||||
type CardTextMetricsLike = {
|
||||
width: number
|
||||
actualBoundingBoxAscent: number
|
||||
actualBoundingBoxDescent: number
|
||||
}
|
||||
|
||||
export const createCardMeasure = (
|
||||
ctx: Pick<CardDrawContext, 'font' | 'measureText'>,
|
||||
fontFamily: string,
|
||||
): CardMeasureText => {
|
||||
return (text, fontSize) => {
|
||||
ctx.font = `700 ${fontSize}px ${fontFamily}, ${BRAND_FALLBACK_FONT}`
|
||||
const metrics = ctx.measureText(text)
|
||||
|
||||
return {
|
||||
width: metrics.width,
|
||||
actualBoundingBoxAscent: metrics.actualBoundingBoxAscent ?? 0,
|
||||
actualBoundingBoxDescent: metrics.actualBoundingBoxDescent ?? 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type NameCardRenderArgs = {
|
||||
image: CanvasImageSource
|
||||
name: string
|
||||
fontFamily: string
|
||||
measure: CardMeasureText
|
||||
slot?: CardNameSlot
|
||||
}
|
||||
|
||||
export const renderNameCard = (
|
||||
ctx: CardDrawContext,
|
||||
model: CardModel,
|
||||
args: NameCardRenderArgs,
|
||||
): CardNameFit => {
|
||||
const slot = args.slot ?? NAME_CARD_SLOT
|
||||
ctx.drawImage(args.image, 0, 0, model.width, model.height)
|
||||
|
||||
const fit = fitCardName(args.name, args.measure, slot)
|
||||
if (!fit.ok) return fit
|
||||
|
||||
ctx.font = `700 ${fit.fontSize}px ${args.fontFamily}`
|
||||
ctx.fillStyle = slot.fill
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'alphabetic'
|
||||
|
||||
if (fit.lines.length === 1) {
|
||||
ctx.fillText(fit.lines[0], slot.centerX, slot.capTop + fit.capHeight)
|
||||
return fit
|
||||
}
|
||||
|
||||
const availableHeight = slot.maxBlockBottom - slot.capTop
|
||||
const blockHeight = fit.lineHeight + fit.capHeight
|
||||
const blockTop = slot.capTop + Math.max(0, (availableHeight - blockHeight) / 2)
|
||||
|
||||
fit.lines.forEach((line, index) => {
|
||||
ctx.fillText(line, slot.centerX, blockTop + fit.capHeight + index * fit.lineHeight)
|
||||
})
|
||||
|
||||
return fit
|
||||
}
|
||||
|
||||
export type PhotoCardRenderArgs = {
|
||||
photo: CanvasImageSource
|
||||
frame: CanvasImageSource
|
||||
photoSize: CardPhotoSize
|
||||
frameSize: CardPhotoSize
|
||||
window: CardRect
|
||||
transform: CardPhotoTransform
|
||||
}
|
||||
|
||||
export const renderPhotoCard = (
|
||||
ctx: CardDrawContext,
|
||||
args: PhotoCardRenderArgs,
|
||||
): CardPhotoTransform => {
|
||||
const rect = cardPhotoDrawRect(args.transform, args.photoSize, args.window)
|
||||
ctx.drawImage(args.photo, rect.x, rect.y, rect.width, rect.height)
|
||||
ctx.drawImage(args.frame, 0, 0, args.frameSize.width, args.frameSize.height)
|
||||
|
||||
return clampCardPhotoTransform(args.transform, args.photoSize, args.window)
|
||||
}
|
||||
|
|
@ -1,10 +1,19 @@
|
|||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
import { adminHeaders } from '../helpers/adminApi'
|
||||
import { seedTestUser } from '../helpers/seedUser'
|
||||
import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub'
|
||||
import { expect, test } from './fixtures/e2eTest'
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
// 4×3 opaque PNG — enough to exercise decode/cover/clamp without a binary fixture.
|
||||
const TEST_PHOTO_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVR4nGOQqzjxHxkzEBQAAMHJHF0PRQWkAAAAAElFTkSuQmCC',
|
||||
'base64',
|
||||
)
|
||||
|
||||
const swipeLeft = async (
|
||||
page: Page,
|
||||
box: { x: number; y: number; width: number; height: number },
|
||||
|
|
@ -1646,3 +1655,130 @@ test.describe('Campaign home content section', () => {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Cards personalizados (S13)', () => {
|
||||
test('home invites to the card funnel right after the newsletter capture', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
|
||||
const order = await page
|
||||
.locator('[data-home-section]')
|
||||
.evaluateAll((sections) =>
|
||||
sections.map((section) => section.getAttribute('data-home-section')),
|
||||
)
|
||||
expect(order.indexOf('cards')).toBe(order.indexOf('newsletter') + 1)
|
||||
|
||||
const section = page.locator('section#cards')
|
||||
await expect(
|
||||
section.getByRole('heading', { name: 'Mostre que você está com Solla' }),
|
||||
).toBeVisible()
|
||||
for (const model of ['eu-sou-solla', 'perfil-quadrado', 'perfil-retangular']) {
|
||||
await expect(section.locator(`a[href="/cards?model=${model}"]`).first()).toBeAttached()
|
||||
}
|
||||
|
||||
await section.getByRole('link', { name: /Moldura quadrada/ }).click()
|
||||
await expect(page).toHaveURL(/\/cards\?model=perfil-quadrado$/)
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByRole('heading', { name: 'Enquadre sua foto' })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('name card fits the master, reaches the result and downloads a real PNG', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/cards')
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: 'Card com seu nome' }).click()
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible()
|
||||
|
||||
const primary = dialog.getByRole('button', { name: 'Criar meu card' })
|
||||
await expect(primary).toBeDisabled()
|
||||
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('João')
|
||||
await expect(primary).toBeEnabled()
|
||||
|
||||
const canvas = dialog.locator('canvas')
|
||||
await expect(canvas).toHaveAttribute('width', '1080')
|
||||
await expect(canvas).toHaveAttribute('height', '1440')
|
||||
await expect
|
||||
.poll(() =>
|
||||
canvas.evaluate((element) => {
|
||||
const node = element as HTMLCanvasElement
|
||||
const band = node.getContext('2d')!.getImageData(0, 420, node.width, 120).data
|
||||
let yellow = 0
|
||||
for (let index = 0; index < band.length; index += 4) {
|
||||
if (band[index] > 230 && band[index + 1] > 200 && band[index + 2] < 80) yellow += 1
|
||||
}
|
||||
return yellow
|
||||
}),
|
||||
)
|
||||
.toBeGreaterThan(1000)
|
||||
|
||||
await primary.click()
|
||||
await expect(dialog.getByText('Seu card está pronto para compartilhar.')).toBeVisible()
|
||||
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
dialog.getByRole('button', { name: 'Baixar meu card' }).click(),
|
||||
])
|
||||
expect(download.suggestedFilename()).toBe('card-jorge-solla-eu-sou-solla.png')
|
||||
const downloadPath = await download.path()
|
||||
expect(downloadPath).toBeTruthy()
|
||||
expect(readFileSync(downloadPath!).subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true)
|
||||
})
|
||||
|
||||
test('a name that cannot fit fails closed with an actionable message', async ({ page }) => {
|
||||
await page.goto('/cards?model=eu-sou-solla')
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
|
||||
await dialog.getByRole('textbox', { name: 'Seu nome' }).fill('Anticonstitucionalissimamente')
|
||||
await expect(dialog.getByRole('alert')).toContainText('nome mais curto')
|
||||
await expect(dialog.getByRole('button', { name: 'Criar meu card' })).toBeDisabled()
|
||||
})
|
||||
|
||||
test('photo card decodes the file, keeps the overlay and recovers from a bad image', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/cards?model=perfil-quadrado')
|
||||
const dialog = page.getByRole('dialog')
|
||||
const canvas = dialog.locator('canvas')
|
||||
await expect(canvas).toHaveAttribute('width', '1000')
|
||||
await expect(canvas).toHaveAttribute('height', '1000')
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'foto-e2e.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: TEST_PHOTO_PNG,
|
||||
})
|
||||
await expect(dialog.getByRole('button', { name: 'Trocar foto' })).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
canvas.evaluate((element) => {
|
||||
const node = element as HTMLCanvasElement
|
||||
return node.getContext('2d')!.getImageData(5, 5, 1, 1).data[3]
|
||||
}),
|
||||
)
|
||||
.toBe(255)
|
||||
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: 'nao-e-foto.txt',
|
||||
mimeType: 'text/plain',
|
||||
buffer: Buffer.from('not an image'),
|
||||
})
|
||||
await expect(dialog.getByRole('alert')).toContainText('Não foi possível usar esta foto')
|
||||
await expect(dialog.getByRole('button', { name: 'Escolher outra foto' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('mobile opens the composer in the bottom drawer', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto('/cards?model=eu-sou-solla')
|
||||
|
||||
const drawer = page.locator('[data-slot="drawer-popup"]')
|
||||
await expect(drawer).toBeVisible()
|
||||
await expect(drawer.getByRole('heading', { name: 'Personalize com seu nome' })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(drawer).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => {
|
|||
'/campanha/sw.js',
|
||||
'/campanha/convite/e2e-prewarm',
|
||||
'/',
|
||||
'/cards',
|
||||
]) {
|
||||
await prewarmGet(path)
|
||||
}
|
||||
|
|
|
|||
78
tests/unit/cardModels.unit.spec.ts
Normal file
78
tests/unit/cardModels.unit.spec.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CARD_MODELS,
|
||||
NAME_CARD_SLOT,
|
||||
cardModelHref,
|
||||
getCardModel,
|
||||
isCardModelId,
|
||||
} from '@/lib/cardModels'
|
||||
|
||||
describe('card model catalog (S13)', () => {
|
||||
it('exposes the three shipped models with unique ids', () => {
|
||||
expect(CARD_MODELS.map((model) => model.id)).toEqual([
|
||||
'eu-sou-solla',
|
||||
'perfil-quadrado',
|
||||
'perfil-retangular',
|
||||
])
|
||||
expect(new Set(CARD_MODELS.map((model) => model.id)).size).toBe(CARD_MODELS.length)
|
||||
})
|
||||
|
||||
it('serves every master from the local /cards asset folder', () => {
|
||||
for (const model of CARD_MODELS) {
|
||||
expect(model.assetSrc.startsWith('/cards/')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('pins the real output dimensions of the masters (not the 1080 in the file names)', () => {
|
||||
expect(getCardModel('eu-sou-solla')).toMatchObject({ width: 1080, height: 1440, kind: 'name' })
|
||||
expect(getCardModel('perfil-quadrado')).toMatchObject({
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
kind: 'photo',
|
||||
})
|
||||
expect(getCardModel('perfil-retangular')).toMatchObject({
|
||||
width: 1000,
|
||||
height: 1440,
|
||||
kind: 'photo',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the photo window measured from the transparent masters', () => {
|
||||
expect(getCardModel('perfil-quadrado')?.photoWindow).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 740,
|
||||
})
|
||||
expect(getCardModel('perfil-retangular')?.photoWindow).toEqual({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1000,
|
||||
height: 1044,
|
||||
})
|
||||
expect(getCardModel('eu-sou-solla')?.photoWindow).toBeUndefined()
|
||||
})
|
||||
|
||||
it('guards the name slot geometry measured from the filled master', () => {
|
||||
expect(NAME_CARD_SLOT).toMatchObject({
|
||||
centerX: 570,
|
||||
capTop: 430,
|
||||
capHeight: 106,
|
||||
maxInkWidth: 714,
|
||||
})
|
||||
})
|
||||
|
||||
it('sanitizes query-param values through isCardModelId', () => {
|
||||
expect(isCardModelId('eu-sou-solla')).toBe(true)
|
||||
expect(isCardModelId('perfil-quadrado')).toBe(true)
|
||||
expect(isCardModelId('perfil-retangular')).toBe(true)
|
||||
expect(isCardModelId('modelo-inventado')).toBe(false)
|
||||
expect(isCardModelId(['perfil-quadrado'])).toBe(false)
|
||||
expect(isCardModelId(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('builds the deep-link used by the home tiles', () => {
|
||||
expect(cardModelHref('perfil-quadrado')).toBe('/cards?model=perfil-quadrado')
|
||||
})
|
||||
})
|
||||
95
tests/unit/cardNameFit.unit.spec.ts
Normal file
95
tests/unit/cardNameFit.unit.spec.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { NAME_CARD_SLOT } from '@/lib/cardModels'
|
||||
import {
|
||||
fitCardName,
|
||||
normalizeCardName,
|
||||
resolveFontSizeForCapHeight,
|
||||
type CardMeasureText,
|
||||
} from '@/lib/cardNameFit'
|
||||
|
||||
/** Deterministic fake: 0.6em per char, 0.72em cap, 0.2em descent. */
|
||||
const measure: CardMeasureText = (text, fontSize) => ({
|
||||
width: text.length * fontSize * 0.6,
|
||||
actualBoundingBoxAscent: fontSize * 0.72,
|
||||
actualBoundingBoxDescent: fontSize * 0.2,
|
||||
})
|
||||
|
||||
const inkWidth = (text: string, fontSize: number) => measure(text, fontSize).width
|
||||
const minFontSize = resolveFontSizeForCapHeight(measure, NAME_CARD_SLOT.minCapHeight)
|
||||
|
||||
describe('normalizeCardName', () => {
|
||||
it('trims, collapses spaces and uppercases with the pt-BR locale', () => {
|
||||
expect(normalizeCardName(' joão conceição ')).toBe('JOÃO CONCEIÇÃO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveFontSizeForCapHeight', () => {
|
||||
it('calibrates the font size from the measured cap, not the raw cap value', () => {
|
||||
expect(resolveFontSizeForCapHeight(measure, 106)).toBeCloseTo(106 / 0.72, 5)
|
||||
})
|
||||
|
||||
it('falls back to the cap value when the probe has no ascent', () => {
|
||||
const broken: CardMeasureText = () => ({
|
||||
width: 0,
|
||||
actualBoundingBoxAscent: 0,
|
||||
actualBoundingBoxDescent: 0,
|
||||
})
|
||||
expect(resolveFontSizeForCapHeight(broken, 106)).toBe(106)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitCardName', () => {
|
||||
it('keeps the master cap height for a short name', () => {
|
||||
const fit = fitCardName('João', measure)
|
||||
|
||||
expect(fit).toMatchObject({ ok: true, lines: ['JOÃO'] })
|
||||
if (fit.ok) {
|
||||
expect(fit.capHeight).toBeCloseTo(NAME_CARD_SLOT.capHeight, 5)
|
||||
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
|
||||
}
|
||||
})
|
||||
|
||||
it('shrinks a long single line to the ink width instead of cutting it', () => {
|
||||
const fit = fitCardName('Conceição', measure)
|
||||
|
||||
expect(fit).toMatchObject({ ok: true, lines: ['CONCEIÇÃO'] })
|
||||
if (fit.ok) {
|
||||
expect(fit.fontSize).toBeLessThan(resolveFontSizeForCapHeight(measure, 106))
|
||||
expect(inkWidth(fit.lines[0], fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
|
||||
expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize)
|
||||
}
|
||||
})
|
||||
|
||||
it('wraps into two balanced lines when single-line would fall under the minimum', () => {
|
||||
const fit = fitCardName('Jorge Solla da Bahia', measure)
|
||||
|
||||
expect(fit.ok).toBe(true)
|
||||
if (fit.ok) {
|
||||
expect(fit.lines).toEqual(['JORGE SOLLA', 'DA BAHIA'])
|
||||
expect(fit.fontSize).toBeGreaterThanOrEqual(minFontSize)
|
||||
for (const line of fit.lines) {
|
||||
expect(inkWidth(line, fit.fontSize)).toBeLessThanOrEqual(NAME_CARD_SLOT.maxInkWidth)
|
||||
}
|
||||
const blockHeight = fit.lineHeight + fit.capHeight
|
||||
expect(blockHeight).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom - NAME_CARD_SLOT.capTop)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not split a single word in the middle', () => {
|
||||
const fit = fitCardName('Bartolomeucostajunior', measure)
|
||||
|
||||
expect(fit).toEqual({ ok: false, reason: 'too-long' })
|
||||
})
|
||||
|
||||
it('fails closed for an empty name', () => {
|
||||
expect(fitCardName(' ', measure)).toEqual({ ok: false, reason: 'empty' })
|
||||
})
|
||||
|
||||
it('preserves Portuguese accents in the fitted lines', () => {
|
||||
const fit = fitCardName('Coração', measure)
|
||||
|
||||
expect(fit.ok).toBe(true)
|
||||
if (fit.ok) expect(fit.lines[0]).toBe('CORAÇÃO')
|
||||
})
|
||||
})
|
||||
118
tests/unit/cardPhotoTransform.unit.spec.ts
Normal file
118
tests/unit/cardPhotoTransform.unit.spec.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CardRect } from '@/lib/cardModels'
|
||||
import {
|
||||
CARD_PHOTO_MAX_ZOOM,
|
||||
CARD_PHOTO_MIN_ZOOM,
|
||||
cardPhotoDrawRect,
|
||||
centerCardPhotoTransform,
|
||||
clampCardPhotoTransform,
|
||||
coverScale,
|
||||
panCardPhotoTransform,
|
||||
zoomCardPhotoTransform,
|
||||
type CardPhotoSize,
|
||||
} from '@/lib/cardPhotoTransform'
|
||||
|
||||
const WINDOW: CardRect = { x: 0, y: 0, width: 1000, height: 740 }
|
||||
const LANDSCAPE: CardPhotoSize = { width: 2000, height: 1000 }
|
||||
const PORTRAIT: CardPhotoSize = { width: 900, height: 1600 }
|
||||
|
||||
const coversWindow = (rect: CardRect, window: CardRect) =>
|
||||
rect.x <= window.x &&
|
||||
rect.y <= window.y &&
|
||||
rect.x + rect.width >= window.x + window.width &&
|
||||
rect.y + rect.height >= window.y + window.height
|
||||
|
||||
describe('coverScale', () => {
|
||||
it('covers the window on the tighter axis', () => {
|
||||
expect(coverScale(LANDSCAPE, WINDOW)).toBeCloseTo(0.74, 5)
|
||||
expect(coverScale(PORTRAIT, WINDOW)).toBeCloseTo(1000 / 900, 5)
|
||||
})
|
||||
|
||||
it('degrades to 1 for empty sizes instead of dividing by zero', () => {
|
||||
expect(coverScale({ width: 0, height: 0 }, WINDOW)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('centerCardPhotoTransform', () => {
|
||||
it('covers the window and centers the photo on it', () => {
|
||||
const transform = centerCardPhotoTransform(LANDSCAPE, WINDOW)
|
||||
const rect = cardPhotoDrawRect(transform, LANDSCAPE, WINDOW)
|
||||
|
||||
expect(transform).toEqual({ zoom: 1, offsetX: -240, offsetY: 0 })
|
||||
expect(coversWindow(rect, WINDOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('centers a portrait photo with vertical slack', () => {
|
||||
const transform = centerCardPhotoTransform(PORTRAIT, WINDOW)
|
||||
const rect = cardPhotoDrawRect(transform, PORTRAIT, WINDOW)
|
||||
|
||||
expect(rect.height).toBeGreaterThan(WINDOW.height)
|
||||
expect(rect.y).toBeLessThan(0)
|
||||
expect(coversWindow(rect, WINDOW)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampCardPhotoTransform', () => {
|
||||
it('clamps the zoom to [1, 4]', () => {
|
||||
expect(
|
||||
clampCardPhotoTransform({ zoom: 0.2, offsetX: 0, offsetY: 0 }, LANDSCAPE, WINDOW).zoom,
|
||||
).toBe(CARD_PHOTO_MIN_ZOOM)
|
||||
expect(
|
||||
clampCardPhotoTransform({ zoom: 9, offsetX: 0, offsetY: 0 }, LANDSCAPE, WINDOW).zoom,
|
||||
).toBe(CARD_PHOTO_MAX_ZOOM)
|
||||
})
|
||||
|
||||
it('pins the offsets so no gap can open at the window edges', () => {
|
||||
const tooFarRight = clampCardPhotoTransform(
|
||||
{ zoom: 1, offsetX: 9999, offsetY: 9999 },
|
||||
LANDSCAPE,
|
||||
WINDOW,
|
||||
)
|
||||
const tooFarLeft = clampCardPhotoTransform(
|
||||
{ zoom: 1, offsetX: -9999, offsetY: -9999 },
|
||||
LANDSCAPE,
|
||||
WINDOW,
|
||||
)
|
||||
|
||||
expect(tooFarRight).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
|
||||
expect(tooFarLeft).toEqual({ zoom: 1, offsetX: -480, offsetY: 0 })
|
||||
expect(coversWindow(cardPhotoDrawRect(tooFarLeft, LANDSCAPE, WINDOW), WINDOW)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('panCardPhotoTransform', () => {
|
||||
it('nudges the offset and stays clamped', () => {
|
||||
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
|
||||
const panned = panCardPhotoTransform(start, LANDSCAPE, WINDOW, 100, -40)
|
||||
|
||||
expect(panned.offsetX).toBe(-140)
|
||||
expect(panned.offsetY).toBe(0)
|
||||
|
||||
const overPanned = panCardPhotoTransform(panned, LANDSCAPE, WINDOW, 9999, 9999)
|
||||
expect(overPanned).toEqual({ zoom: 1, offsetX: 0, offsetY: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoomCardPhotoTransform', () => {
|
||||
it('keeps the anchor point stable while zooming', () => {
|
||||
const start = centerCardPhotoTransform(LANDSCAPE, WINDOW)
|
||||
const zoomed = zoomCardPhotoTransform(start, LANDSCAPE, WINDOW, 2, { x: 500, y: 370 })
|
||||
const scale = coverScale(LANDSCAPE, WINDOW) * 2
|
||||
|
||||
expect((500 - zoomed.offsetX) / scale).toBeCloseTo((500 - start.offsetX) / (scale / 2), 5)
|
||||
expect(coversWindow(cardPhotoDrawRect(zoomed, LANDSCAPE, WINDOW), WINDOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps above the max zoom and never opens a gap', () => {
|
||||
const zoomed = zoomCardPhotoTransform(
|
||||
centerCardPhotoTransform(PORTRAIT, WINDOW),
|
||||
PORTRAIT,
|
||||
WINDOW,
|
||||
99,
|
||||
)
|
||||
|
||||
expect(zoomed.zoom).toBe(CARD_PHOTO_MAX_ZOOM)
|
||||
expect(coversWindow(cardPhotoDrawRect(zoomed, PORTRAIT, WINDOW), WINDOW)).toBe(true)
|
||||
})
|
||||
})
|
||||
129
tests/unit/cardRender.unit.spec.ts
Normal file
129
tests/unit/cardRender.unit.spec.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { NAME_CARD_SLOT, getCardModel, type CardRect } from '@/lib/cardModels'
|
||||
import { centerCardPhotoTransform, type CardPhotoSize } from '@/lib/cardPhotoTransform'
|
||||
import {
|
||||
createCardMeasure,
|
||||
renderNameCard,
|
||||
renderPhotoCard,
|
||||
type CardDrawContext,
|
||||
} from '@/lib/cardRender'
|
||||
|
||||
type DrawCall = { image: unknown; dx: number; dy: number; dw: number; dh: number }
|
||||
type TextCall = { text: string; x: number; y: number }
|
||||
|
||||
const fontSizeFrom = (font: string): number => {
|
||||
const match = /(\d+(?:\.\d+)?)px/.exec(font)
|
||||
return match ? Number(match[1]) : 0
|
||||
}
|
||||
|
||||
const createFakeContext = () => {
|
||||
const drawCalls: DrawCall[] = []
|
||||
const textCalls: TextCall[] = []
|
||||
|
||||
const ctx: CardDrawContext = {
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
textAlign: 'start',
|
||||
textBaseline: 'alphabetic',
|
||||
drawImage: (image, dx, dy, dw, dh) => {
|
||||
drawCalls.push({ image, dx, dy, dw, dh })
|
||||
},
|
||||
fillText: (text, x, y) => {
|
||||
textCalls.push({ text, x, y })
|
||||
},
|
||||
measureText: (text) => {
|
||||
const size = fontSizeFrom(ctx.font)
|
||||
return {
|
||||
width: text.length * size * 0.6,
|
||||
actualBoundingBoxAscent: size * 0.72,
|
||||
actualBoundingBoxDescent: size * 0.2,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return { ctx, drawCalls, textCalls }
|
||||
}
|
||||
|
||||
const nameModel = getCardModel('eu-sou-solla')!
|
||||
const squareModel = getCardModel('perfil-quadrado')!
|
||||
|
||||
describe('renderNameCard', () => {
|
||||
it('draws the master base first and then the centered name', () => {
|
||||
const { ctx, drawCalls, textCalls } = createFakeContext()
|
||||
const image = { id: 'base' } as unknown as CanvasImageSource
|
||||
const fit = renderNameCard(ctx, nameModel, {
|
||||
image,
|
||||
name: 'João',
|
||||
fontFamily: 'Brexter',
|
||||
measure: createCardMeasure(ctx, 'Brexter'),
|
||||
})
|
||||
|
||||
expect(fit.ok).toBe(true)
|
||||
expect(drawCalls).toEqual([{ image, dx: 0, dy: 0, dw: 1080, dh: 1440 }])
|
||||
expect(textCalls).toHaveLength(1)
|
||||
expect(textCalls[0]!.text).toBe('JOÃO')
|
||||
expect(textCalls[0]!.x).toBe(NAME_CARD_SLOT.centerX)
|
||||
expect(textCalls[0]!.y).toBeCloseTo(NAME_CARD_SLOT.capTop + NAME_CARD_SLOT.capHeight, 5)
|
||||
expect(ctx.fillStyle).toBe(NAME_CARD_SLOT.fill)
|
||||
expect(ctx.textAlign).toBe('center')
|
||||
})
|
||||
|
||||
it('wraps long names into two lines with baselines inside the free band', () => {
|
||||
const { ctx, textCalls } = createFakeContext()
|
||||
const fit = renderNameCard(ctx, nameModel, {
|
||||
image: { id: 'base' } as unknown as CanvasImageSource,
|
||||
name: 'Jorge Solla da Bahia',
|
||||
fontFamily: 'Brexter',
|
||||
measure: createCardMeasure(ctx, 'Brexter'),
|
||||
})
|
||||
|
||||
expect(fit.ok).toBe(true)
|
||||
expect(textCalls.map((call) => call.text)).toEqual(['JORGE SOLLA', 'DA BAHIA'])
|
||||
expect(textCalls[0]!.y).toBeLessThan(textCalls[1]!.y)
|
||||
expect(textCalls[1]!.y).toBeLessThanOrEqual(NAME_CARD_SLOT.maxBlockBottom)
|
||||
})
|
||||
|
||||
it('draws the base but no text when the name cannot fit (never cuts silently)', () => {
|
||||
const { ctx, drawCalls, textCalls } = createFakeContext()
|
||||
const fit = renderNameCard(ctx, nameModel, {
|
||||
image: { id: 'base' } as unknown as CanvasImageSource,
|
||||
name: 'Bartolomeucostajunior',
|
||||
fontFamily: 'Brexter',
|
||||
measure: createCardMeasure(ctx, 'Brexter'),
|
||||
})
|
||||
|
||||
expect(fit).toEqual({ ok: false, reason: 'too-long' })
|
||||
expect(drawCalls).toHaveLength(1)
|
||||
expect(textCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderPhotoCard', () => {
|
||||
it('draws the photo full-bleed under the official overlay', () => {
|
||||
const { ctx, drawCalls } = createFakeContext()
|
||||
const photo = { id: 'photo' } as unknown as CanvasImageSource
|
||||
const frame = { id: 'frame' } as unknown as CanvasImageSource
|
||||
const size: CardPhotoSize = { width: 2000, height: 1000 }
|
||||
const window: CardRect = squareModel.photoWindow!
|
||||
const transform = centerCardPhotoTransform(size, window)
|
||||
|
||||
const clamped = renderPhotoCard(ctx, {
|
||||
photo,
|
||||
frame,
|
||||
photoSize: size,
|
||||
frameSize: { width: squareModel.width, height: squareModel.height },
|
||||
window,
|
||||
transform,
|
||||
})
|
||||
|
||||
expect(clamped).toEqual(transform)
|
||||
expect(drawCalls).toHaveLength(2)
|
||||
expect(drawCalls[0]!.image).toBe(photo)
|
||||
expect(drawCalls[1]).toEqual({ image: frame, dx: 0, dy: 0, dw: 1000, dh: 1000 })
|
||||
expect(drawCalls[0]!.dx).toBeLessThanOrEqual(window.x)
|
||||
expect(drawCalls[0]!.dy).toBeLessThanOrEqual(window.y)
|
||||
expect(drawCalls[0]!.dx + drawCalls[0]!.dw).toBeGreaterThanOrEqual(window.x + window.width)
|
||||
expect(drawCalls[0]!.dy + drawCalls[0]!.dh).toBeGreaterThanOrEqual(window.y + window.height)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue