teqo-backup/src/components/conteudos/ContentPieceShareSheet.tsx
Francisco Solla 862da74fbe feat(S42): seção da Central compartilha a peça e abre /conteudos já filtrada
A seção S39 era só vitrine: quem via a peça do seu território tinha de ir
até o catálogo para compartilhar ou recortar. O card agora abre a MESMA
folha do S27 (mensagem de voto por tipo, contador C213, sem sair da home) e
as tags com faceta honesta — mais a fileira Tipo | Cidade | Região — levam
a /conteudos já filtrada pelo construtor canônico; nada filtra a amostra
local e as tags sem faceta (Mais recente, origem) continuam estáticas.

As facetas da fileira são derivadas no server (contentPieceCatalogFacets,
mesmo vocabulário do catálogo) e o lean ContentPieceHomeItem ganha só
sourceUrl, com a folha S27 estreitada ao que lê; classes novas são
home-scoped — CONTENT_PIECE_TAG e /conteudos ficam intactos. Sem migration,
Consent ou analytics novos.

Closes #1391
2026-09-30 01:09:07 -03:00

204 lines
7.3 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 { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { CopyLinkButton } from '@/components/CopyLinkButton'
import { WhatsAppIcon } from '@/components/socialIcons'
import { sendContentPieceEvent } from '@/lib/contentEvents'
import type { ContentPiecePublicItem } from '@/lib/contentPieceCatalog'
import {
buildContentPieceWhatsAppUrl,
contentPieceShareLink,
contentPieceVoteMessage,
} from '@/lib/contentPieceShare'
import { cn } from '@/lib/utils'
import {
CONTENT_PIECE_FOCUS,
CONTENT_PIECE_OUTLINE_BUTTON,
CONTENT_PIECE_PRIMARY_BUTTON,
} from './contentPieceClasses'
/**
* S42 — what the sheet actually reads: the catalogue passes the full public
* item and the home section passes its lean projection (the same structural
* precedent as `ContentPieceMedia`), so the one share mechanism serves both
* surfaces without the home carrying the search haystack.
*/
type ContentPieceShareSheetItem = Pick<
ContentPiecePublicItem,
'slug' | 'title' | 'type' | 'file' | 'isLink' | 'sourceUrl'
>
/**
* S27 — the share sheet (artefato: cenas 03/08): the vote message prefilled and
* EDITABLE, then `wa.me` of the sender's own WhatsApp — no recipient, nothing
* sent by the system. "A mídia" opens the native share sheet with the file
* where the device supports it and downloads the same file where it does not
* (never pretending the file was attached). The sheet portals to the body so
* no card overflow can clip it.
*/
export const ContentPieceShareSheet = ({
item,
onClose,
}: {
item: ContentPieceShareSheetItem
onClose: () => void
}) => {
const [mounted, setMounted] = useState(false)
const [preparing, setPreparing] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const shareLink = contentPieceShareLink(item, window.location.origin)
const [message, setMessage] = useState(() =>
contentPieceVoteMessage(item.type, item.title, shareLink),
)
useEffect(() => {
setMounted(true)
// Focus moves into the dialog on open and back to the trigger on close.
const previousFocus = document.activeElement as HTMLElement | null
textareaRef.current?.focus()
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
previousFocus?.focus?.()
}
}, [onClose])
const shareMedia = async () => {
const mediaFile = item.file
if (!mediaFile || preparing) return
setPreparing(true)
const path = mediaFile.path
try {
const response = await fetch(path)
if (!response.ok) throw new Error('fetch failed')
const blob = await response.blob()
const file = new File([blob], mediaFile.downloadFilename, {
type: blob.type || 'application/octet-stream',
})
if (typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] })) {
try {
await navigator.share({ files: [file], title: item.title })
return
} catch (error) {
// A cancelled share sheet is not a failure: never download instead.
if (error instanceof DOMException && error.name === 'AbortError') return
}
}
} catch {
// The honest fallback below is the download of the same file.
} finally {
setPreparing(false)
}
const anchor = document.createElement('a')
anchor.href = `${path}?download=1`
anchor.download = mediaFile.downloadFilename
anchor.click()
}
if (!mounted) return null
return createPortal(
// The portal lands outside the `campaign-site` layout, so the sheet carries
// its own theme: without it every campaign token resolves to nothing (the
// default `:root` foreground is near-white) and the panel fades away.
<div data-theme="campaign-site" className="fixed inset-0 z-50 text-(--campaign-ink)">
<button
type="button"
tabIndex={-1}
aria-label="Fechar compartilhamento"
onClick={onClose}
className="absolute inset-0 h-full w-full cursor-default bg-black/12"
/>
<aside
role="dialog"
aria-modal="true"
aria-label="Compartilhar peça"
className={cn(
'absolute inset-x-0 bottom-0 rounded-t-2xl border border-black/12 bg-white p-4 shadow-[0_-12px_35px_rgb(0_0_0/12%)]',
'sm:inset-x-auto sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:w-[430px] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:p-5 sm:shadow-[0_20px_55px_rgb(0_0_0/22%)]',
)}
>
<div
aria-hidden="true"
className="mx-auto mb-3 h-1 w-10 rounded-full bg-stone-300 sm:hidden"
/>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="border-0 pb-0 font-[family-name:var(--font-exo2)] text-xl font-black">
Peça esse voto no WhatsApp
</h2>
<p className="mt-1 text-xs text-(--campaign-muted)">
A mensagem é sua: revise antes de abrir.
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Fechar"
className={cn(
'grid size-8 shrink-0 place-items-center rounded-full border border-black/10 text-lg text-(--campaign-muted) hover:text-black',
CONTENT_PIECE_FOCUS,
)}
>
×
</button>
</div>
<label className="sr-only" htmlFor="content-piece-share-message">
Mensagem para compartilhar
</label>
<textarea
id="content-piece-share-message"
ref={textareaRef}
value={message}
onChange={(event) => setMessage(event.target.value)}
className="mt-4 min-h-32 w-full resize-none rounded-[10px] border border-black/18 bg-white p-3 text-sm leading-5 text-black focus-visible:border-(--pt-red) focus-visible:outline-[3px] focus-visible:outline-offset-[2px] focus-visible:outline-(--pt-red)"
/>
<a
href={buildContentPieceWhatsAppUrl(message)}
target="_blank"
rel="noopener noreferrer"
onClick={() => sendContentPieceEvent('compartilhar_whatsapp', item.slug)}
className={`${CONTENT_PIECE_PRIMARY_BUTTON} mt-3 w-full`}
>
<WhatsAppIcon className="size-4" />
Abrir no WhatsApp
</a>
<div className={cn('mt-3 grid gap-2', item.file ? 'grid-cols-2' : 'grid-cols-1')}>
{item.file ? (
<button
type="button"
onClick={() => void shareMedia()}
disabled={preparing}
className={cn(CONTENT_PIECE_OUTLINE_BUTTON, 'disabled:opacity-70')}
>
{preparing ? 'Preparando…' : 'A mídia'}
</button>
) : null}
<CopyLinkButton
url={shareLink}
className={CONTENT_PIECE_OUTLINE_BUTTON}
onCopied={() => sendContentPieceEvent('compartilhar_link', item.slug)}
/>
</div>
<p className="mt-3 text-center text-[11px] leading-4 text-(--campaign-muted)">
Sem destinatário e sem envio automático.
{item.file ? ' Se o aparelho não compartilhar o arquivo, ele será baixado.' : ''}
</p>
</aside>
</div>,
document.body,
)
}