teqo-backup/src/components/cards/CardComposer.tsx

1225 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import {
ArrowDownIcon,
ArrowLeftIcon,
ArrowRightIcon,
ArrowUpIcon,
CheckIcon,
MinusIcon,
PlusIcon,
} from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent } from 'react'
import {
canvasToPngBlob,
cardFileName,
downloadBlob,
ensureCardFont,
loadCardImage,
loadCardPhoto,
} from '@/components/cards/cardCanvas'
import {
CARD_COLINHA_PRIVACY_NOTE,
CARD_HARMONY_HELP,
CARD_HARMONY_LABEL,
CARD_PHOTO_PRIVACY_NOTE,
CARD_PRIVACY_NOTE,
} from '@/components/cards/cardCopy'
import { CardPreviewCanvas } from '@/components/cards/CardPreviewCanvas'
import { StateDeputySelect } from '@/components/cards/StateDeputySelect'
import { useCardCutout } from '@/components/cards/useCardCutout'
import { DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/Drawer'
import { COLINHA_ESTADUAL_LABEL, COLINHA_FONT_FAMILY } from '@/lib/cardColinha'
import { TEAM_CARD_NAME_SLOT, type CardModel, type CardRect } from '@/lib/cardModels'
import { fitCardName, type CardNameFit } from '@/lib/cardNameFit'
import {
CARD_PHOTO_MAX_ZOOM,
CARD_PHOTO_MIN_ZOOM,
cardPhotoTransformsEqual,
centerCardPhotoTransform,
panCardPhotoTransform,
zoomCardPhotoTransform,
type CardPhotoSize,
type CardPhotoTransform,
} from '@/lib/cardPhotoTransform'
import {
createCardMeasure,
renderColinhaCard,
renderNameCard,
renderPhotoCard,
renderTeamCard,
} from '@/lib/cardRender'
import { sendCardDownloadEvent } from '@/lib/contentEvents'
import { getStateDeputyCard, type StateDeputyCatalogEntry } from '@/lib/stateDeputyCatalog'
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'
const NAME_TOO_LONG_MESSAGE =
'Não foi possível encaixar esse nome no card. Use um nome mais curto, como você é chamado.'
/** S15 design gate: the team preview width per state (S17 ready: 180px mobile / 240px desktop). */
type TeamPreviewStage = 'idle' | 'processing' | 'ready' | 'result'
const TEAM_PREVIEW_WIDTH: Record<TeamPreviewStage, string> = {
idle: 'max-w-[10.75rem]',
processing: 'max-w-[9rem]',
ready: 'max-w-[11.25rem] sm:max-w-[15rem]',
result: 'max-w-[11.875rem]',
}
/**
* S30 — the dobradinha preview gets the gate's larger stage (scene 4: 230px
* desktop; scene 5: 205px mobile) while the S15 tokens above stay untouched.
* `selected` is the chosen-but-photoless state, which the gate draws at the same
* prominence as the result; the pre-selection and the too-long error stay
* compact (scenes 3 and 4-right).
*/
type StateDeputyPreviewStage = TeamPreviewStage | 'selected'
const STATE_DEPUTY_PREVIEW_WIDTH: Record<StateDeputyPreviewStage, string> = {
idle: 'max-w-[10.75rem]',
processing: 'max-w-[9rem]',
ready: 'max-w-[12.8125rem] sm:max-w-[15rem]',
result: 'max-w-[12.8125rem] sm:max-w-[14.375rem]',
selected: 'max-w-[12.8125rem] sm:max-w-[14.375rem]',
}
/**
* S31/S34 — the colinha stages of the design gate: the delivered art while the
* estadual is unchosen (scene 1: 236px desktop / scene 2: 248px mobile) and the
* scene 3/5 stage after the pick (405px desktop, 300px mobile).
*/
const COLINHA_PREVIEW_WIDTH: Record<'idle' | 'selected', string> = {
idle: 'max-w-[15.5rem] sm:max-w-[14.75rem]',
selected: 'max-w-[18.75rem] sm:max-w-[25.3125rem]',
}
const TeamNameField = ({
id,
value,
onChange,
error,
inputClassName,
}: {
id: string
value: string
onChange: (value: string) => void
error: boolean
inputClassName: string
}) => (
<div>
<label htmlFor={id} className="block text-sm font-bold text-(--campaign-ink)">
Seu nome
</label>
<input
id={id}
type="text"
value={value}
onChange={(event) => onChange(event.target.value)}
autoComplete="off"
maxLength={60}
placeholder="Como você é chamado"
aria-invalid={error}
className={inputClassName}
/>
<p className="mt-2 text-xs leading-5 text-(--campaign-muted)">
Vai aparecer em caixa alta depois de “TIME DE”. Use um nome curto e conhecido.
</p>
{error ? (
<p role="alert" className="mt-2 text-sm leading-5 font-semibold text-(--pt-red)">
{NAME_TOO_LONG_MESSAGE}
</p>
) : null}
</div>
)
/**
* S17 — the tone-harmony switch of the ready state. Native `role="switch"` (the
* design gate's semantics; the studio has no shadcn Switch) with the whole row
* as the target and the label/help always visible.
*/
const TeamHarmonySwitch = ({ enabled, onToggle }: { enabled: boolean; onToggle: () => void }) => {
const helpId = useId()
return (
<button
type="button"
role="switch"
aria-checked={enabled}
aria-describedby={helpId}
onClick={onToggle}
className="mt-4 flex min-h-11 w-full items-start gap-3 rounded-lg border border-(--campaign-line) bg-white p-3 text-left focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:ring-offset-2 focus-visible:outline-none"
>
<span
aria-hidden="true"
className={`relative mt-1.5 inline-block h-[26px] w-11 flex-none rounded-full transition-colors ${
enabled ? 'bg-(--pt-red)' : 'bg-[#cfcac7]'
}`}
>
<span
className={`absolute top-[3px] size-5 rounded-full bg-white shadow-[0_1px_2px_rgb(0_0_0/25%)] transition-[left] ${
enabled ? 'left-[21px]' : 'left-[3px]'
}`}
/>
</span>
<span className="min-w-0">
<span className="block text-sm font-bold text-(--campaign-ink)">{CARD_HARMONY_LABEL}</span>
<span id={helpId} className="mt-0.5 block text-xs leading-5 text-(--campaign-muted)">
{CARD_HARMONY_HELP}
</span>
</span>
</button>
)
}
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 [baseImage, setBaseImage] = useState<HTMLImageElement | null>(null)
const [overlayImage, setOverlayImage] = useState<HTMLImageElement | null>(null)
const [previewImage, setPreviewImage] = useState<HTMLImageElement | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isDownloading, setIsDownloading] = useState(false)
/** S17 — LIGADO by default; resets only when the composer unmounts (no persistence). */
const [harmonyEnabled, setHarmonyEnabled] = useState(true)
/** S30 — the state deputy of the dobradinha model (name/number/art pair). */
const [selectedDeputySlug, setSelectedDeputySlug] = useState<string | null>(null)
const [deputyImages, setDeputyImages] = useState<{
slug: string
photos: HTMLImageElement
base: HTMLImageElement
} | null>(null)
const [deputyAssetError, setDeputyAssetError] = useState(false)
/** Bumped when the same deputy is re-picked after a pair load failure. */
const [deputyReloadToken, setDeputyReloadToken] = useState(0)
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
/** S33 — the model's adjustment policy: the two team models are position-free. */
const photoPosition = model.photoPosition ?? 'bounded'
const isNameModel = model.kind === 'name'
const isTeamModel = model.kind === 'team'
const isColinhaModel = model.kind === 'colinha'
const isStateDeputyModel = isTeamModel && model.stateDeputyPicker === true
const selectedDeputy = selectedDeputySlug
? (getStateDeputyCard(selectedDeputySlug) ?? null)
: null
const deputyPairReady = selectedDeputy !== null && deputyImages?.slug === selectedDeputy.slug
// S30 — the tone reference is the selected deputy's group art, frozen when the
// cutout starts (switching the deputy later keeps the processed photo).
const cutout = useCardCutout(
photoWindow,
isStateDeputyModel ? selectedDeputy?.photosSrc : isTeamModel ? model.assetSrc : undefined,
)
const cutoutState = cutout.state
const teamProcessing = cutoutState.status === 'processing' ? cutoutState : null
const teamError = cutoutState.status === 'error' ? cutoutState : null
const teamReady = cutoutState.status === 'ready' ? cutoutState : null
const photoSize = useMemo<CardPhotoSize | null>(
() => (photo ? { width: photo.naturalWidth, height: photo.naturalHeight } : null),
[photo],
)
const transformSourceSize = useMemo<CardPhotoSize | null>(() => {
if (!isTeamModel) return photoSize
return teamReady ? { width: teamReady.width, height: teamReady.height } : null
}, [isTeamModel, photoSize, teamReady])
/**
* The cutout carries the initial framing; local state takes over after the
* first pan/zoom. Deriving (instead of mirroring through an effect) keeps the
* first ready paint composed — no bare-base frame.
*/
const effectiveTransform = isTeamModel
? (photoTransform ?? teamReady?.transform ?? null)
: photoTransform
// Assets + font load once per model: typing/panning must not flip the preview
// back into the loading state (flicker) nor re-announce the live region.
useEffect(() => {
let cancelled = false
setLoadState('loading')
setBaseImage(null)
setOverlayImage(null)
setPreviewImage(null)
const load = async () => {
try {
const fontReady = await ensureCardFont(fontFamily)
if (!fontReady) throw new Error('card-font-unavailable')
// S30 — the dobradinha model loads its art pair per selection (below);
// the static defaults stay unused so no illustrative JULIO art flashes
// before the choice. S34 — the colinha draws the approved art as the
// base and carries no `previewSrc` (the tile falls back to `assetSrc`).
const [image, overlay, preview] = await Promise.all([
isStateDeputyModel ? Promise.resolve(null) : loadCardImage(model.assetSrc),
!isStateDeputyModel && model.overlaySrc
? loadCardImage(model.overlaySrc)
: Promise.resolve(null),
model.previewSrc ? loadCardImage(model.previewSrc) : Promise.resolve(null),
])
if (cancelled) return
setBaseImage(image)
setOverlayImage(overlay)
setPreviewImage(preview)
setLoadState('ready')
} catch {
if (!cancelled) setLoadState('error')
}
}
void load()
return () => {
cancelled = true
}
}, [
isStateDeputyModel,
isColinhaModel,
model.assetSrc,
model.overlaySrc,
model.previewSrc,
fontFamily,
])
/**
* S30 — the selected deputy's pair swaps in place: the previous art stays
* painted until both files decode (no loading state, no flicker) and the name
* and cutout states are untouched by the swap.
*/
useEffect(() => {
if (!isStateDeputyModel || !selectedDeputy) return
let cancelled = false
setDeputyAssetError(false)
const load = async () => {
try {
const [photos, base] = await Promise.all([
loadCardImage(selectedDeputy.photosSrc),
loadCardImage(selectedDeputy.baseSrc),
])
if (cancelled) return
setDeputyImages({ slug: selectedDeputy.slug, photos, base })
} catch {
if (!cancelled) setDeputyAssetError(true)
}
}
void load()
return () => {
cancelled = true
}
}, [isStateDeputyModel, selectedDeputy, deputyReloadToken])
useEffect(() => {
const ctx = canvasRef.current?.getContext('2d')
if (!ctx || loadState !== 'ready') return
const measure = createCardMeasure(ctx, fontFamily)
if (isNameModel) {
if (!baseImage) return
ctx.clearRect(0, 0, model.width, model.height)
setNameFit(renderNameCard(ctx, model, { image: baseImage, name, fontFamily, measure }))
return
}
if (isColinhaModel) {
// S34 — the approved art is the base; only the estadual row is drawn over
// it (or nothing, before a pick). No name, photo or cutout enters this flow.
if (!baseImage) return
ctx.clearRect(0, 0, model.width, model.height)
renderColinhaCard(ctx, model, {
image: baseImage,
deputy: selectedDeputy,
fontFamily: COLINHA_FONT_FAMILY,
measure: createCardMeasure(ctx, COLINHA_FONT_FAMILY, 900),
})
return
}
if (isTeamModel) {
// S30 — the dobradinha pair comes from the selected catalog entry: `photos`
// is the FOTOS background (drawn under the cutout) and `base` is the front
// BASE overlay (drawn above it) — the opposite of the S15 masters.
const artBase = isStateDeputyModel ? deputyImages?.photos : baseImage
const artOverlay = isStateDeputyModel ? deputyImages?.base : overlayImage
if (isStateDeputyModel && !selectedDeputy) {
// Before the choice: the approved example art (no banners), exactly the
// gate's scene 3 placeholder.
ctx.clearRect(0, 0, model.width, model.height)
setNameFit(fitCardName(name, measure, TEAM_CARD_NAME_SLOT))
if (previewImage) ctx.drawImage(previewImage, 0, 0, model.width, model.height)
return
}
// Art not decoded yet (first selection or a swap in flight): keep the
// current frame painted instead of clearing to a bare canvas.
if (!artBase || !artOverlay) return
ctx.clearRect(0, 0, model.width, model.height)
if (teamReady && effectiveTransform && photoWindow) {
const result = renderTeamCard(ctx, model, {
base: artBase,
overlay: artOverlay,
subject: {
photo: harmonyEnabled && teamReady.harmonized ? teamReady.harmonized : teamReady.canvas,
photoSize: { width: teamReady.width, height: teamReady.height },
transform: effectiveTransform,
},
window: photoWindow,
name,
fontFamily,
measure,
})
setNameFit(result.fit)
if (result.transform && !cardPhotoTransformsEqual(result.transform, effectiveTransform)) {
setPhotoTransform(result.transform)
}
return
}
if (isStateDeputyModel) {
// Chosen but no photo yet: the art shows as-is — the window stays empty
// until the visitor photo lands (no drawn placeholder).
if (!photoWindow) return
setNameFit(
renderTeamCard(ctx, model, {
base: artBase,
overlay: artOverlay,
subject: null,
window: photoWindow,
name,
fontFamily,
measure,
}).fit,
)
return
}
setNameFit(fitCardName(name, measure, TEAM_CARD_NAME_SLOT))
const idleImage = cutoutState.status === 'idle' && previewImage ? previewImage : artBase
ctx.drawImage(idleImage, 0, 0, model.width, model.height)
return
}
if (!baseImage) return
ctx.clearRect(0, 0, model.width, model.height)
if (photo && effectiveTransform && photoWindow && photoSize) {
const clamped = renderPhotoCard(ctx, {
photo,
frame: baseImage,
photoSize,
frameSize: { width: model.width, height: model.height },
window: photoWindow,
transform: effectiveTransform,
})
if (!cardPhotoTransformsEqual(clamped, effectiveTransform)) {
setPhotoTransform(clamped)
}
return
}
if (photoWindow) {
ctx.fillStyle = '#ded8d2'
ctx.fillRect(0, 0, model.width, model.height)
ctx.drawImage(baseImage, 0, 0, model.width, model.height)
}
}, [
baseImage,
overlayImage,
previewImage,
loadState,
isNameModel,
isTeamModel,
isColinhaModel,
isStateDeputyModel,
selectedDeputy,
deputyImages,
teamReady,
harmonyEnabled,
cutoutState.status,
model,
name,
fontFamily,
photo,
photoSize,
effectiveTransform,
photoWindow,
])
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 handleTeamPhotoFile = (file: File | undefined) => {
if (!file) return
setPhotoError(null)
setPhotoTransform(null)
void cutout.start(file)
if (fileInputRef.current) fileInputRef.current.value = ''
}
/** S30 — the swap keeps name and processed photo (only the art pair changes). */
const handleSelectDeputy = (card: StateDeputyCatalogEntry) => {
// Re-picking the same entry after a pair failure must retry the load.
if (card.slug === selectedDeputySlug) setDeputyReloadToken((token) => token + 1)
setSelectedDeputySlug(card.slug)
}
/** Applies a pan/zoom update over the derived or user-owned transform. */
const withTransform = (
update: (
transform: CardPhotoTransform,
size: CardPhotoSize,
window: CardRect,
) => CardPhotoTransform,
) => {
if (!transformSourceSize || !photoWindow) return
setPhotoTransform((current) => {
const base = current ?? (isTeamModel ? (teamReady?.transform ?? null) : null)
return base ? update(base, transformSourceSize, photoWindow) : base
})
}
const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
if (!transformSourceSize || !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 || !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 }
withTransform((transform, size, window) =>
panCardPhotoTransform(transform, size, window, dx, dy, { position: photoPosition }),
)
}
const handlePointerEnd = () => {
dragRef.current = null
}
const panBy = (dx: number, dy: number) => {
withTransform((transform, size, window) =>
panCardPhotoTransform(transform, size, window, dx, dy, { position: photoPosition }),
)
}
const zoomTo = (zoom: number) => {
withTransform((transform, size, window) =>
zoomCardPhotoTransform(transform, size, window, zoom, undefined, { position: photoPosition }),
)
}
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))
// S32 — anonymous download count (fire-and-forget, never blocks/throws);
// the state-deputy slug rides along on the models that have a picker.
sendCardDownloadEvent(model.id, selectedDeputy?.slug ?? null)
} catch {
setDownloadError('Não foi possível gerar o arquivo agora. Tente de novo.')
} finally {
setIsDownloading(false)
}
}
const nameCanAdvance = nameFit?.ok === true
const canAdvance = isColinhaModel
? selectedDeputy !== null
: isNameModel
? nameCanAdvance
: isStateDeputyModel
? selectedDeputy !== null && deputyPairReady && teamReady !== null && nameCanAdvance
: isTeamModel
? teamReady !== null && nameCanAdvance
: photo !== null
const nameError =
(isNameModel || isTeamModel) && name.trim().length > 0 && nameFit !== null && !nameFit.ok
const adjustable = isStateDeputyModel
? deputyPairReady && teamReady !== null
: isTeamModel
? teamReady !== null
: photo !== null
const title = (() => {
if (step === 'result') return 'Seu card está pronto para compartilhar.'
if (isNameModel) return 'Personalize com seu nome'
if (isColinhaModel) {
if (!selectedDeputy) return 'Escolha seu estadual'
return shell === 'dialog' ? 'Pronta para levar com você' : 'Pronta para baixar'
}
if (isStateDeputyModel && !selectedDeputy) return 'Escolha sua dobradinha'
if (!isTeamModel) return 'Enquadre sua foto'
if (teamProcessing) return 'Preparando sua foto'
if (teamError) return 'Vamos tentar outra vez'
if (nameError) return 'Encurte o nome'
if (teamReady) return 'Confira seu card'
if (isStateDeputyModel) return 'Agora coloque seu nome'
return 'Entre para o time'
})()
const photoHeaderDescription =
step === 'compose' && !isNameModel && !isTeamModel && !isColinhaModel
? 'Arraste para posicionar e use os controles para aproximar ou ajustar.'
: null
const stateDeputyHeaderDescription =
isStateDeputyModel && step === 'compose' && !selectedDeputy
? 'Escolha seu estadual da dobradinha.'
: null
/**
* S31 — the colinha description lives only in the dialog: the gate's mobile
* drawer (scene 5) carries eyebrow + title only, so the compact preview keeps
* the selector in the first viewport.
*/
const colinhaHeaderDescription =
isColinhaModel && step === 'compose' && shell === 'dialog'
? selectedDeputy
? 'Confira o estadual escolhido. As demais escolhas já vêm preenchidas no modelo oficial.'
: 'A linha de deputado estadual será preenchida na hora.'
: null
const headerDescription =
photoHeaderDescription ?? stateDeputyHeaderDescription ?? colinhaHeaderDescription
const teamBodyNotice =
isTeamModel && step === 'compose' && teamReady && !nameError
? 'O recorte já foi centralizado. Se precisar, arraste a foto ou use os controles.'
: null
const ShellDescription = shell === 'dialog' ? DialogDescription : DrawerDescription
const eyebrow = isColinhaModel
? 'Minha colinha'
: isTeamModel
? step === 'result'
? 'Tudo certo'
: teamError
? 'Falha no recorte'
: nameError
? 'Nome muito longo'
: isStateDeputyModel
? selectedDeputy
? 'Estadual escolhido'
: 'Time do estadual'
: 'Time de você'
: null
const eyebrowNode = eyebrow ? (
<p className="text-[10px] font-black tracking-[0.1em] text-(--pt-red) uppercase sm:text-xs">
{eyebrow}
</p>
) : null
// The dialog header takes the available row width so the close button
// sits on the right edge (S35 design gate). The drawer header defaults to
// centered + `shrink-0`, so `text-left!` + `flex-1` port the gate
// (left-aligned, wrapped header) for every model in the shared composer.
const header =
shell === 'dialog' ? (
<DialogHeader className="min-w-0 flex-1 text-left">
{eyebrowNode}
<DialogTitle className="text-xl font-black tracking-[-0.01em] text-balance">
{title}
</DialogTitle>
{headerDescription ? <ShellDescription>{headerDescription}</ShellDescription> : null}
</DialogHeader>
) : (
<DrawerHeader className="min-w-0 flex-1 text-left!">
{eyebrowNode}
<DrawerTitle className="text-lg font-black tracking-[-0.01em] text-balance">
{title}
</DrawerTitle>
{headerDescription ? <ShellDescription>{headerDescription}</ShellDescription> : null}
</DrawerHeader>
)
const teamPreviewStage: TeamPreviewStage =
step === 'result' ? 'result' : teamReady ? 'ready' : teamProcessing ? 'processing' : 'idle'
const stateDeputyPreviewStage: StateDeputyPreviewStage =
teamPreviewStage === 'idle' && selectedDeputy && !nameError ? 'selected' : teamPreviewStage
const previewWidthClassName = isColinhaModel
? selectedDeputy
? COLINHA_PREVIEW_WIDTH.selected
: COLINHA_PREVIEW_WIDTH.idle
: isStateDeputyModel
? STATE_DEPUTY_PREVIEW_WIDTH[stateDeputyPreviewStage]
: isTeamModel
? TEAM_PREVIEW_WIDTH[teamPreviewStage]
: 'max-w-[22rem]'
/**
* S34 designer critique (c) — the filled desktop state follows the gate's
* scene 3 composition: the full 9:16 preview beside the controls (two-column
* grid, wider dialog), never clipped by the footer. The empty state keeps the
* compact dialog of scene 1. The `data-colinha-filled` attribute below is the
* contract that widens the dialog in `CardsStudio` (`has-[[...]]`).
*/
const colinhaFilledDialog = isColinhaModel && shell === 'dialog' && selectedDeputy !== null
/**
* S34 — caps the colinha preview by the viewport so the whole 9:16 fits
* between the dialog chrome (`max-h-[92dvh]` in `CardsStudio` minus ~224px of
* header/footer); on tall viewports the width reaches the gate's 405px.
*/
const colinhaPreviewMaxHeight = 'max-h-[calc(92dvh-14rem)]'
const nameInputClassName = `mt-2 h-11 w-full rounded-lg bg-white px-3 text-base text-(--campaign-ink) focus-visible:ring-2 focus-visible:ring-(--pt-red) focus-visible:outline-none ${
nameError
? 'border-2 border-(--pt-red)'
: 'border border-(--field-border) focus-visible:border-(--pt-red)'
}`
const zoomAndPanControls = effectiveTransform ? (
<>
<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(effectiveTransform.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={effectiveTransform.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(effectiveTransform.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>
</>
) : null
// S23 design gate (scene 02): the drawer body carries a scroll tail so the
// team notice leaves the viewport by the end of the scroll.
const bodyScrollClassName = `min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pt-4 ${
teamBodyNotice && shell === 'drawer' ? 'pb-14' : 'pb-5'
}${colinhaFilledDialog ? ' sm:grid sm:grid-cols-[430px_1fr] sm:items-start sm:gap-8' : ''}`
return (
<div
data-colinha-filled={colinhaFilledDialog ? 'true' : undefined}
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={bodyScrollClassName}>
<p className="sr-only" role="status" aria-live="polite">
{loadState === 'loading' ? 'Carregando prévia…' : ''}
</p>
{teamBodyNotice ? (
<ShellDescription
className={`mx-auto text-sm leading-5 text-(--campaign-muted) ${
shell === 'dialog' ? 'mb-4 max-w-[420px]' : 'mb-3 max-w-[350px]'
}`}
>
{teamBodyNotice}
</ShellDescription>
) : null}
<div
className={`relative mx-auto w-full ${previewWidthClassName} ${adjustable ? 'touch-none' : ''}${
colinhaFilledDialog ? ' sm:col-start-1 sm:row-start-1' : ''
}`}
tabIndex={adjustable ? 0 : undefined}
role={adjustable ? 'group' : undefined}
aria-label={adjustable ? 'Ajuste da foto' : undefined}
onKeyDown={
adjustable
? (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 w-auto max-w-full rounded-lg border border-(--campaign-line) shadow-sm transition-opacity ${
isColinhaModel ? `${colinhaPreviewMaxHeight} ` : 'max-h-[38dvh] '
}${loadState === 'loading' ? 'opacity-60' : 'opacity-100'}`}
onPointerDown={adjustable ? handlePointerDown : undefined}
onPointerMove={adjustable ? handlePointerMove : undefined}
onPointerUp={adjustable ? handlePointerEnd : undefined}
onPointerCancel={adjustable ? 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' && isStateDeputyModel ? (
<div>
{!selectedDeputy ? (
<p className="mt-3 text-center text-[11px] leading-4 text-(--campaign-muted)">
A silhueta marca o lugar da sua foto. O card final usa a arte do estadual escolhido.
</p>
) : null}
<StateDeputySelect selected={selectedDeputy} onSelect={handleSelectDeputy} />
{deputyAssetError ? (
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
Não foi possível carregar a arte deste estadual. Escolha outro e tente de novo.
</p>
) : null}
</div>
) : null}
{step === 'compose' && isColinhaModel ? (
<div className={colinhaFilledDialog ? 'sm:col-start-2 sm:row-start-1' : undefined}>
<StateDeputySelect selected={selectedDeputy} onSelect={handleSelectDeputy} />
{selectedDeputy ? (
<div className="mt-4 rounded-lg bg-white p-4 ring-1 ring-(--campaign-line)">
<p className="text-sm font-bold text-(--campaign-ink)">Linha conferida</p>
<p className="mt-1 text-sm text-(--campaign-muted)">
{COLINHA_ESTADUAL_LABEL} · {selectedDeputy.name.toLocaleUpperCase('pt-BR')} ·{' '}
{selectedDeputy.ballotNumber}
</p>
</div>
) : (
<p className="mt-4 rounded-lg bg-(--campaign-cream) p-3 text-xs leading-5 text-(--campaign-muted)">
<span className="block font-bold text-(--campaign-ink)">
Escolha um estadual para baixar.
</span>
A linha vazia será preenchida com o nome e os cinco dígitos.
</p>
)}
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
{CARD_COLINHA_PRIVACY_NOTE}
</p>
{downloadError ? (
<p role="alert" className="mt-2 text-sm font-semibold text-(--pt-red)">
{downloadError}
</p>
) : null}
</div>
) : 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)">
{NAME_TOO_LONG_MESSAGE}
</p>
) : null}
<p className="mt-2 text-xs text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'compose' && isTeamModel && !teamProcessing && !teamReady && !teamError ? (
<div className="mt-5">
<TeamNameField
id={nameInputId}
value={name}
onChange={setName}
error={nameError}
inputClassName={nameInputClassName}
/>
{isStateDeputyModel && !selectedDeputy ? (
<p className="mt-4 rounded-lg bg-(--campaign-cream) p-3 text-xs leading-5 text-(--campaign-muted)">
<span className="block font-bold text-(--campaign-ink)">
Escolha um estadual para continuar.
</span>
A prévia e o download usam a arte oficial da dobradinha escolhida.
</p>
) : (
<div className="mt-5 rounded-lg border border-(--campaign-line) bg-(--campaign-surface) p-4">
<p className="text-sm font-bold text-(--campaign-ink)">Sua foto de busto</p>
<p className="mt-1 text-sm leading-5 text-(--campaign-muted)">
Para um recorte melhor, escolha uma foto nítida, de frente e com fundo simples.
</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-3 inline-flex min-h-11 w-full 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:outline-none"
>
Escolher foto
</button>
</div>
)}
<p className="mt-4 text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
) : null}
{step === 'compose' && teamProcessing ? (
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<p
role="status"
aria-live="polite"
className="text-sm font-bold text-(--campaign-ink)"
>
{teamProcessing.phase === 'download'
? 'Baixando o modelo de recorte…'
: 'Removendo o fundo da foto…'}
</p>
<p aria-hidden="true" className="text-sm font-black text-(--pt-red)">
{Math.round(teamProcessing.ratio * 100)}%
</p>
</div>
<div
role="progressbar"
aria-label={`Progresso do recorte: ${Math.round(teamProcessing.ratio * 100)}%`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(teamProcessing.ratio * 100)}
className="mt-2 h-2 overflow-hidden rounded-full bg-(--campaign-band)"
>
<div
className="h-full rounded-full bg-(--pt-red) transition-all"
style={{ width: `${Math.round(teamProcessing.ratio * 100)}%` }}
/>
</div>
<p className="mt-3 text-sm leading-5 text-(--campaign-muted)">
Na primeira vez, o modelo precisa ser baixado. Depois, vamos remover o fundo da foto
no seu aparelho. Isso pode levar um pouco.
</p>
<div className="mt-4 flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3">
<span
aria-hidden="true"
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded-full bg-(--team-blue) text-white"
>
<CheckIcon className="size-3" aria-hidden="true" />
</span>
<p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PHOTO_PRIVACY_NOTE}</p>
</div>
</div>
) : null}
{step === 'compose' && teamError ? (
<div className="mt-5">
<div role="alert" className="rounded-lg border border-(--pt-red) bg-red-50 p-4">
<p className="text-sm font-black text-(--pt-red)">
{teamError.reason === 'unsupported'
? 'Seu navegador está bloqueando o recorte de fundo.'
: 'Não foi possível remover o fundo desta foto.'}
</p>
<p className="mt-1 text-sm leading-5 text-(--campaign-ink)">
{teamError.reason === 'unsupported'
? 'O recorte roda no seu aparelho e precisa de WebAssembly e WebGL, que navegadores com proteções avançadas (como IronFox e Tor) desligam por padrão. Ative essas opções para este site nas configurações do navegador — ou abra a página em outro navegador — e toque em “Tentar de novo”.'
: 'Tente de novo ou escolha outra foto.'}
</p>
</div>
{teamError.reason === 'unsupported' ? null : (
<p className="mt-4 text-sm leading-5 text-(--campaign-muted)">
Fotos de busto, nítidas e com fundo simples costumam funcionar melhor.
</p>
)}
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
{CARD_PHOTO_PRIVACY_NOTE}
</p>
<div className="mt-5 flex gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={secondaryButtonClassName}
>
Escolher outra foto
</button>
<button type="button" onClick={cutout.retry} className={primaryButtonClassName}>
Tentar de novo
</button>
</div>
</div>
) : null}
{step === 'compose' && teamReady && photoWindow && transformSourceSize ? (
<div className="mt-5">
{nameError ? (
<>
<TeamNameField
id={nameInputId}
value={name}
onChange={setName}
error={nameError}
inputClassName={nameInputClassName}
/>
<p className="mt-4 text-xs leading-5 text-(--campaign-muted)">
A foto escolhida e o recorte permanecem salvos neste aparelho enquanto você
corrige o nome.
</p>
</>
) : (
<>
{zoomAndPanControls}
{teamReady.harmonized ? (
<TeamHarmonySwitch
enabled={harmonyEnabled}
onToggle={() => setHarmonyEnabled((enabled) => !enabled)}
/>
) : null}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className={`${secondaryButtonClassName} mt-3 w-full`}
>
Trocar foto
</button>
<p className="mt-3 text-xs leading-5 text-(--campaign-muted)">
{CARD_PHOTO_PRIVACY_NOTE}
</p>
</>
)}
</div>
) : null}
{step === 'compose' && !isNameModel && !isTeamModel && photoWindow ? (
<div className="mt-5">
{photo && effectiveTransform ? (
zoomAndPanControls
) : (
<p className="text-sm text-(--campaign-muted)">
Escolha uma foto vertical ou horizontal para começar.
</p>
)}
{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)">{CARD_PHOTO_PRIVACY_NOTE}</p>
</div>
) : null}
<input
ref={fileInputRef}
type="file"
accept="image/*"
tabIndex={-1}
aria-hidden="true"
className="sr-only"
onChange={(event) =>
isTeamModel
? handleTeamPhotoFile(event.target.files?.[0])
: void handlePhotoFile(event.target.files?.[0])
}
/>
{step === 'result' ? (
<div className="mt-5">
{isTeamModel ? (
<>
{isStateDeputyModel && selectedDeputy ? (
<p className="mb-3 text-center text-xs font-bold text-(--campaign-muted)">
{selectedDeputy.name} · {selectedDeputy.ballotNumber}
</p>
) : null}
<div className="flex items-start gap-3 rounded-lg bg-(--campaign-cream) p-3">
<span
aria-hidden="true"
className="inline-flex size-6 shrink-0 items-center justify-center rounded-full bg-green-700 text-white"
>
<CheckIcon className="size-3.5" aria-hidden="true" />
</span>
<p className="text-xs leading-5 text-(--campaign-muted)">{CARD_PRIVACY_NOTE}</p>
</div>
</>
) : (
<p className="text-sm text-(--campaign-muted)">{CARD_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' ? (
<>
{isColinhaModel && shell === 'drawer' ? null : (
<button type="button" onClick={onClose} className={secondaryButtonClassName}>
Cancelar
</button>
)}
<button
type="button"
disabled={!canAdvance || loadState !== 'ready' || (isColinhaModel && isDownloading)}
onClick={isColinhaModel ? () => void handleDownload() : () => setStep('result')}
className={primaryButtonClassName}
>
{isColinhaModel
? isDownloading
? 'Gerando…'
: 'Baixar minha colinha'
: teamProcessing
? 'Processando…'
: '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>
)
}