diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 3f13066d..f80c53b0 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -1,8 +1,4 @@ -# Changelog de contexto para agentes (fatias do AGENTS.md) - -Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agentes paralelos): "Known Gaps" e todos os blocos "Recently resolved". Histórico — leia só quando precisar do contexto de uma entrega passada; o sempre-presente fica no `AGENTS.md`. Itens novos de "Recently resolved" passam a ser registrados aqui (uma entrada curta), não no AGENTS.md. - ---- +**Recently resolved (2026-08-10):** **Push /campanha — opt-in automático + fix do prompt nativo (ad-hoc).** O `requestPermission` do card de perfil rodava **dentro de `startTransition(async …)`** — qualquer fronteira assíncrona entre o clique e a chamada derruba a user activation e o Chrome **auto-nega silenciosamente** (resolve `'denied'` sem mostrar o prompt): o teste real em device deu exatamente "Permissão de notificação negada". Fix: os guards sync ficam no handler e **`Notification.requestPermission()` passa a ser chamado sincronamente no clique** (antes de qualquer `await`/transição); mensagens distintas para `denied` (permissão bloqueada — reativar nas configurações do site; um `denied` não pode ser re-perguntado por nenhuma API) vs `default` (prompt não exibido). **Opt-in automático** (pedido do usuário): novo `CampaignPushOptInToast` no shell `(app)` (padrão `InstallPwaToast`/`BiometricEnrollmentToast`), montado só para staff (leader não recebe push — sino idêntico), exibido ~2s após o login quando `Notification.permission === 'default'` (nunca `denied`), sem inscrição ativa no device, consentimento `campanha-notificacoes-push` publicado e VAPID configurado; iOS só com app instalado (standalone); "Aceitar e ativar" roda a mesma cerimônia com o requestPermission síncrono no clique do action; dismiss fica salvo em `localStorage` (chave `campaign-push-opt-in-dismissed`); ativação/desativação/leitura de inscrição extraídas para `src/utilities/campaignPushClient.ts` (compartilhado com o card — don't twin; registrado no pin de top-level com razão). **Diagnóstico de prod (read-only, `db:check:push-chain`):** consentimento `campanha-notificacoes-push` **já publicado** (PASS — nada a criar), envs VAPID presentes, SW versionado, 77 eventos no sino — **único elo quebrado: 0 inscrições push** (o erro de permissão impedia o cadastro). Sem migration/access/Consent/schema. Sem issue — entregue via ad-hoc no worktree `work/3`. **Recently resolved (2026-08-10):** **OPS37** (Issue #605) — o container Postgres compartilhado **não é mais recriado** por um worktree vizinho: `pnpm db:start` virou `scripts/db-start.mjs` (CLI) sobre `scripts/lib/db-start.mjs` — preflight "container `teqo-postgres-1` Up + `healthy`/`starting` → **no-op** (compose nem roda); senão, `compose -p teqo up -d --wait --wait-timeout 120 postgres` como antes. **Causa raiz provada por medição:** o bind mount **relativo** `./docker/postgres/init` do compose resolve um path absoluto por worktree → `config_hash` diferente por worktree (OPS37 `39e89956…` / B195 `cf3cb522…` / C101 `917e11bb…`; diff do `config` = só o `source:` do bind) → qualquer `compose up` cru de outro worktree fazia `replace` e matava dev+e2e de todos (`terminating connection due to administrator command`) — o incidente se repetiu **ao vivo durante o gate**: o worktree `work/4` recriou o container no meio da suíte int (17 testes mortos; os 3 arquivos re-rodaram verdes e a suíte completa 691/691 passou em seguida). **Verificação do aceite:** `StartedAt` inalterado após `db:start` repetido, inclusive rodado de **outro worktree** (D9, hash diferente) — o caso que antes recriava. Recriação legítima (bump de imagem/config) segue por `pnpm db:start --force-recreate` (explícito; não testado live para não matar a run e2e do D9 em voo — flag é pass-through de 1 arg). `worktree.mjs` provisionamento delega para a mesma lib (mesmo risco no `worktree next`; `dockerComposeUp`/`SHARED_COMPOSE_PROJECT` órfãos removidos). Skip deliberado em `starting` (container que um peer acabou de criar): **espera** o boot ficar `healthy` (60s, polling 2s) em vez de recriar — e cai no compose up se travou; **não** skip em `unhealthy`/sem healthcheck (volta à config atual exatamente uma vez); `db:stop`/`db:doctor`/compose file/CI intactos. Unit novos (`dbStart.unit.spec.ts`, 17: matrix running×health + parse do inspect + wrapper mockado — skip não chama compose, argv do up/force-recreate, espera de `starting`). Sem migration/access/Consent/UI. Intenção [`container-postgres-compartilhado-compose-replace.md`](plans/container-postgres-compartilhado-compose-replace.md); impl [`container-postgres-compartilhado-compose-replace-impl.md`](plans/container-postgres-compartilhado-compose-replace-impl.md). diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 6b185126..6941e45f 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -17,6 +17,10 @@ import { CampaignHomeSearchChromeProvider } from '@/components/campaign/shell/Ca import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar' import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot' import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext' +import { + CampaignPushOptInToast, + type CampaignPushOptInOffer, +} from '@/components/campaign/shell/CampaignPushOptInToast' import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext' import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar' import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault' @@ -27,7 +31,9 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { isStaffCampaignRole } from '@/lib/campaignRoles' import { deviceLabelFromUserAgent } from '@/lib/deviceLabel' import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth' +import { getCampaignPushConsent } from '@/utilities/campaignConsent' import { campaignUserShellView } from '@/utilities/campaignUserProfile' +import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaignPush' import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony' import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig' @@ -49,18 +55,29 @@ export default async function CampaignAppLayout({ children }: { children: React. // ceremony. Whether THIS device is already enrolled is a question only the // browser can answer, so the island finishes the decision. const relyingParty = await resolveCampaignWebAuthnRelyingParty() + const isStaff = isStaffCampaignRole(user.role) let biometricEnrollment: BiometricEnrollmentOffer | null = null - if (relyingParty) { + let pushOptIn: CampaignPushOptInOffer | null = null + if (relyingParty || isStaff) { const payload = await getPayload({ config }) - const [passkeys, requestHeaders] = await Promise.all([ - loadCampaignPasskeys(payload, user.id), + const [passkeys, requestHeaders, pushConsent] = await Promise.all([ + relyingParty ? loadCampaignPasskeys(payload, user.id) : Promise.resolve([]), headers(), + isStaff ? getCampaignPushConsent(payload) : Promise.resolve(null), ]) - biometricEnrollment = { - // Only the emptiness crosses the boundary: the device labels have no - // business in the RSC payload of every page. - hasEnrolledPasskeys: passkeys.length > 0, - suggestedDeviceLabel: deviceLabelFromUserAgent(requestHeaders.get('user-agent')), + if (relyingParty) { + biometricEnrollment = { + // Only the emptiness crosses the boundary: the device labels have no + // business in the RSC payload of every page. + hasEnrolledPasskeys: passkeys.length > 0, + suggestedDeviceLabel: deviceLabelFromUserAgent(requestHeaders.get('user-agent')), + } + } + if (isStaff) { + pushOptIn = { + pushConsentConfigured: pushConsent !== null, + vapidPublicKey: getCampaignVapidPublicKey(), + } } } @@ -104,6 +121,7 @@ export default async function CampaignAppLayout({ children }: { children: React. ) : null} + {pushOptIn ? : null} {biometricEnrollment ? ( ) : null} diff --git a/src/components/campaign/auth/CampaignPushNotificationsCard.tsx b/src/components/campaign/auth/CampaignPushNotificationsCard.tsx index 577c6f17..2c51e1ad 100644 --- a/src/components/campaign/auth/CampaignPushNotificationsCard.tsx +++ b/src/components/campaign/auth/CampaignPushNotificationsCard.tsx @@ -2,48 +2,30 @@ import { useState, useTransition } from 'react' -import { - subscribeCampaignPush, - unsubscribeCampaignPush, -} from '@/app/(campaign)/campanha/actions/notifications' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/Alert' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Checkbox } from '@/components/ui/Checkbox' import { Field, FieldContent, FieldDescription, FieldLabel } from '@/components/ui/field' import { Spinner } from '@/components/ui/Spinner' -import { CAMPAIGN_PWA_SW_PATH } from '@/utilities/campaignPwa' +import { + CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE, + CAMPAIGN_PUSH_ENV_MISSING_MESSAGE, + CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE, +} from '@/lib/campaignNotificationCopy' +import { + activateCampaignPushOnDevice, + deactivateCampaignPushOnDevice, + isIosSafari, + isStandaloneDisplay, + supportsCampaignPush, +} from '@/utilities/campaignPushClient' type CampaignPushNotificationsCardProps = { pushConsentConfigured: boolean vapidPublicKey: string | null } -const urlBase64ToUint8Array = (base64String: string): Uint8Array => { - const padding = '='.repeat((4 - (base64String.length % 4)) % 4) - const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') - const raw = window.atob(base64) - const output = new Uint8Array(raw.length) - for (let index = 0; index < raw.length; index += 1) { - output[index] = raw.charCodeAt(index) - } - return output -} - -const isIosSafari = (): boolean => { - const ua = navigator.userAgent - const isIos = - /iPad|iPhone|iPod/.test(ua) || - (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) - return isIos && /WebKit/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua) -} - -const isStandaloneDisplay = (): boolean => { - const standaloneMedia = window.matchMedia('(display-mode: standalone)').matches - const iosStandalone = Boolean((navigator as Navigator & { standalone?: boolean }).standalone) - return standaloneMedia || iosStandalone -} - export const CampaignPushNotificationsCard = ({ pushConsentConfigured, vapidPublicKey, @@ -54,97 +36,61 @@ export const CampaignPushNotificationsCard = ({ const [isPending, startTransition] = useTransition() const handleEnable = () => { + setMessage(null) + setError(null) + + // Synchronous guards first, then `requestPermission` INSIDE the click + // gesture: a permission request arriving outside the gesture (e.g. after a + // `startTransition` async boundary) is auto-denied by Chrome without ever + // showing the native prompt — the "Permissão de notificação negada" no-op. + if (!pushConsentConfigured) { + setError(CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE) + return + } + if (!vapidPublicKey) { + setError(CAMPAIGN_PUSH_ENV_MISSING_MESSAGE) + return + } + if (!consentAccepted) { + setError('Aceite o consentimento para ativar os avisos push.') + return + } + if (isIosSafari() && !isStandaloneDisplay()) { + setError('No iPhone, instale o app na tela inicial antes de ativar os avisos push.') + return + } + if (!supportsCampaignPush()) { + setError(CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE) + return + } + + const permissionRequest = Notification.requestPermission() + startTransition(async () => { - setMessage(null) - setError(null) + const permission = await permissionRequest + const result = await activateCampaignPushOnDevice({ + pushConsentConfigured, + vapidPublicKey, + permission, + }) - if (!pushConsentConfigured) { - setError('Consentimento de push ainda não configurado no admin.') - return - } - if (!vapidPublicKey) { - setError('Push ainda não está disponível neste ambiente.') - return - } - if (!consentAccepted) { - setError('Aceite o consentimento para ativar os avisos push.') - return - } - if (isIosSafari() && !isStandaloneDisplay()) { - setError('No iPhone, instale o app na tela inicial antes de ativar os avisos push.') - return - } - if (!('serviceWorker' in navigator) || !('PushManager' in window)) { - setError('Este navegador não suporta notificações push.') - return - } - - try { - const permission = await Notification.requestPermission() - if (permission !== 'granted') { - setError('Permissão de notificação negada.') - return - } - - const registration = await navigator.serviceWorker.register(CAMPAIGN_PWA_SW_PATH, { - scope: '/campanha', - }) - await navigator.serviceWorker.ready - - const subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource, - }) - const json = subscription.toJSON() - if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { - setError('Não foi possível registrar este dispositivo para push.') - return - } - - const result = await subscribeCampaignPush({ - endpoint: json.endpoint, - p256dh: json.keys.p256dh, - auth: json.keys.auth, - expirationTime: subscription.expirationTime, - consentAccepted: true, - }) - - if ('status' in result && result.status === 'success') { - setMessage(result.message) - } else if ('message' in result && result.message) { - setError(result.message) - } else { - setError('Não foi possível ativar os avisos push.') - } - } catch { - setError('Não foi possível ativar os avisos push neste dispositivo.') + if (result.status === 'success') { + setMessage(result.message) + } else { + setError(result.message) } }) } const handleDisable = () => { + setMessage(null) + setError(null) startTransition(async () => { - setMessage(null) - setError(null) - try { - const registration = await navigator.serviceWorker.getRegistration(CAMPAIGN_PWA_SW_PATH) - const subscription = await registration?.pushManager.getSubscription() - if (!subscription) { - setMessage('Nenhuma inscrição push ativa neste dispositivo.') - return - } - const endpoint = subscription.endpoint - await subscription.unsubscribe() - const result = await unsubscribeCampaignPush(endpoint) - if ('status' in result && result.status === 'success') { - setMessage(result.message) - } else if ('message' in result && result.message) { - setError(result.message) - } else { - setError('Não foi possível desativar os avisos push.') - } - } catch { - setError('Não foi possível desativar os avisos push.') + const result = await deactivateCampaignPushOnDevice() + if (result.status === 'success') { + setMessage(result.message) + } else { + setError(result.message) } }) } diff --git a/src/components/campaign/shell/CampaignPushOptInToast.tsx b/src/components/campaign/shell/CampaignPushOptInToast.tsx new file mode 100644 index 00000000..753cfca6 --- /dev/null +++ b/src/components/campaign/shell/CampaignPushOptInToast.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useEffect } from 'react' +import { toast } from 'sonner' + +import { + CAMPAIGN_PUSH_ACTIVATED_MESSAGE, + CAMPAIGN_PUSH_OPT_IN_ACCEPT_LABEL, + CAMPAIGN_PUSH_OPT_IN_TOAST_DESCRIPTION, + CAMPAIGN_PUSH_OPT_IN_TOAST_TITLE, +} from '@/lib/campaignNotificationCopy' +import { + activateCampaignPushOnDevice, + dismissCampaignPushOptIn, + isIosSafari, + isStandaloneDisplay, + readCampaignPushSubscription, + supportsCampaignPush, + wasCampaignPushOptInDismissed, +} from '@/utilities/campaignPushClient' + +const TOAST_ID = 'campaign-push-opt-in' + +export type CampaignPushOptInOffer = { + pushConsentConfigured: boolean + vapidPublicKey: string | null +} + +/** + * Discovery for push notifications, modeled on `InstallPwaToast` and + * `BiometricEnrollmentToast`: nobody opens the profile looking for a feature + * they have never heard of. Shows once, while the browser still CAN ask + * (`Notification.permission === 'default'`) — never for 'denied' (a denied + * permission cannot be re-prompted by any API) and never on iOS before the + * PWA is installed, where the request is silently ignored. Dismissed offers + * stay dismissed per device (localStorage); the permanent home stays the + * profile card. + */ +export const CampaignPushOptInToast = ({ + pushConsentConfigured, + vapidPublicKey, +}: CampaignPushOptInOffer) => { + useEffect(() => { + if (!pushConsentConfigured || !vapidPublicKey) return + if (!supportsCampaignPush()) return + if (Notification.permission !== 'default') return + if (wasCampaignPushOptInDismissed()) return + if (isIosSafari() && !isStandaloneDisplay()) return + + let cancelled = false + + const offer = async () => { + // First statement, before any await: the native prompt must be + // requested inside the click gesture or Chrome auto-denies it silently. + const permission = await Notification.requestPermission() + const result = await activateCampaignPushOnDevice({ + pushConsentConfigured, + vapidPublicKey, + permission, + }) + + toast.dismiss(TOAST_ID) + if (result.status === 'success') { + dismissCampaignPushOptIn() + toast.success(CAMPAIGN_PUSH_ACTIVATED_MESSAGE) + } else { + // A denied permission would deny every future attempt — stop offering. + if (Notification.permission === 'denied') dismissCampaignPushOptIn() + toast.error(result.message) + } + } + + // Same beat as the sibling toasts: let the first paint land before asking + // for anything, and skip the offer when this device is already subscribed. + const timer = window.setTimeout(() => { + void readCampaignPushSubscription().then((subscription) => { + if (cancelled || subscription) return + + toast.message(CAMPAIGN_PUSH_OPT_IN_TOAST_TITLE, { + id: TOAST_ID, + description: CAMPAIGN_PUSH_OPT_IN_TOAST_DESCRIPTION, + duration: Infinity, + closeButton: true, + onDismiss: dismissCampaignPushOptIn, + action: { + label: CAMPAIGN_PUSH_OPT_IN_ACCEPT_LABEL, + onClick: (event) => { + // Sonner closes the toast on action unless the click is + // defaulted-out — keeping it open lets a declined OS prompt + // land back on a visible offer instead of vanishing. + event.preventDefault() + void offer() + }, + }, + }) + }) + }, 2000) + + return () => { + cancelled = true + window.clearTimeout(timer) + toast.dismiss(TOAST_ID) + } + }, [pushConsentConfigured, vapidPublicKey]) + + return null +} diff --git a/src/lib/campaignNotificationCopy.ts b/src/lib/campaignNotificationCopy.ts index 5752aab6..078c412f 100644 --- a/src/lib/campaignNotificationCopy.ts +++ b/src/lib/campaignNotificationCopy.ts @@ -11,6 +11,28 @@ export const CAMPAIGN_PUSH_SUBSCRIBE_ERROR_MESSAGE = 'Não foi possível ativar export const CAMPAIGN_PUSH_UNSUBSCRIBE_ERROR_MESSAGE = 'Não foi possível desativar os avisos push.' +export const CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE = + 'Consentimento de push ainda não configurado no admin.' + +export const CAMPAIGN_PUSH_ENV_MISSING_MESSAGE = 'Push ainda não está disponível neste ambiente.' + +export const CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE = 'Este navegador não suporta notificações push.' + +export const CAMPAIGN_PUSH_PERMISSION_DENIED_MESSAGE = + 'Permissão bloqueada no navegador. Abra as configurações do site (cadeado na barra de endereço), permita notificações e tente novamente.' + +export const CAMPAIGN_PUSH_PERMISSION_NOT_SHOWN_MESSAGE = + 'O navegador não exibiu o pedido de permissão. Tente ativar novamente.' + +export const CAMPAIGN_PUSH_OPT_IN_TOAST_TITLE = 'Receba avisos da campanha' + +export const CAMPAIGN_PUSH_OPT_IN_TOAST_DESCRIPTION = + 'Alertas de atualizações, novos apoiadores e agenda, mesmo com o app fechado. Ao ativar, você concorda com o aviso LGPD de notificações.' + +export const CAMPAIGN_PUSH_OPT_IN_ACCEPT_LABEL = 'Aceitar e ativar' + +export const CAMPAIGN_PUSH_ACTIVATED_MESSAGE = 'Avisos push ativados neste dispositivo.' + export const CAMPAIGN_NOTIFICATION_THROW_SAFE_MESSAGES = [ CAMPAIGN_PUSH_CONSENT_MISSING_MESSAGE, CAMPAIGN_PUSH_CONSENT_REQUIRED_MESSAGE, diff --git a/src/utilities/campaignPushClient.ts b/src/utilities/campaignPushClient.ts new file mode 100644 index 00000000..2edfe1b3 --- /dev/null +++ b/src/utilities/campaignPushClient.ts @@ -0,0 +1,168 @@ +/** + * Browser-side push ceremony for /campanha, shared by the profile card and the + * opt-in toast (D6). The activation contract on every caller: the user gesture + * MUST call `Notification.requestPermission()` synchronously inside the click + * handler — browsers auto-deny a permission request that arrives outside the + * gesture (a `startTransition(async …)` boundary is enough to lose it), which + * surfaces as an instant "Permissão de notificação negada" with no prompt. + */ + +import { + subscribeCampaignPush, + unsubscribeCampaignPush, +} from '@/app/(campaign)/campanha/actions/notifications' +import { + CAMPAIGN_PUSH_ACTIVATED_MESSAGE, + CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE, + CAMPAIGN_PUSH_ENV_MISSING_MESSAGE, + CAMPAIGN_PUSH_PERMISSION_DENIED_MESSAGE, + CAMPAIGN_PUSH_PERMISSION_NOT_SHOWN_MESSAGE, + CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE, +} from '@/lib/campaignNotificationCopy' +import { CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY, CAMPAIGN_PWA_SW_PATH } from '@/utilities/campaignPwa' + +export type CampaignPushResult = + | { status: 'success'; message: string } + | { status: 'error'; message: string } + +const urlBase64ToUint8Array = (base64String: string): Uint8Array => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const raw = window.atob(base64) + const output = new Uint8Array(raw.length) + for (let index = 0; index < raw.length; index += 1) { + output[index] = raw.charCodeAt(index) + } + return output +} + +export const isIosSafari = (): boolean => { + const ua = navigator.userAgent + const isIos = + /iPad|iPhone|iPod/.test(ua) || + (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + return isIos && /WebKit/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua) +} + +export const isStandaloneDisplay = (): boolean => { + const standaloneMedia = window.matchMedia('(display-mode: standalone)').matches + const iosStandalone = Boolean((navigator as Navigator & { standalone?: boolean }).standalone) + return standaloneMedia || iosStandalone +} + +export const supportsCampaignPush = (): boolean => + 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window + +type ActivateCampaignPushInput = { + pushConsentConfigured: boolean + vapidPublicKey: string | null + /** Result of `Notification.requestPermission()` called synchronously in the gesture. */ + permission: NotificationPermission +} + +export const activateCampaignPushOnDevice = async ({ + pushConsentConfigured, + vapidPublicKey, + permission, +}: ActivateCampaignPushInput): Promise => { + if (!pushConsentConfigured) { + return { status: 'error', message: CAMPAIGN_PUSH_CONSENT_UNCONFIGURED_MESSAGE } + } + if (!vapidPublicKey) { + return { status: 'error', message: CAMPAIGN_PUSH_ENV_MISSING_MESSAGE } + } + if (permission !== 'granted') { + return { + status: 'error', + message: + permission === 'denied' + ? CAMPAIGN_PUSH_PERMISSION_DENIED_MESSAGE + : CAMPAIGN_PUSH_PERMISSION_NOT_SHOWN_MESSAGE, + } + } + if (!supportsCampaignPush()) { + return { status: 'error', message: CAMPAIGN_PUSH_UNSUPPORTED_MESSAGE } + } + + try { + const registration = await navigator.serviceWorker.register(CAMPAIGN_PWA_SW_PATH, { + scope: '/campanha', + }) + await navigator.serviceWorker.ready + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) as BufferSource, + }) + const json = subscription.toJSON() + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) { + return { status: 'error', message: 'Não foi possível registrar este dispositivo para push.' } + } + + const result = await subscribeCampaignPush({ + endpoint: json.endpoint, + p256dh: json.keys.p256dh, + auth: json.keys.auth, + expirationTime: subscription.expirationTime, + consentAccepted: true, + }) + + if ('status' in result && result.status === 'success') { + return { status: 'success', message: CAMPAIGN_PUSH_ACTIVATED_MESSAGE } + } + if ('message' in result && result.message) { + return { status: 'error', message: result.message } + } + return { status: 'error', message: 'Não foi possível ativar os avisos push.' } + } catch { + return { status: 'error', message: 'Não foi possível ativar os avisos push neste dispositivo.' } + } +} + +export const deactivateCampaignPushOnDevice = async (): Promise => { + try { + const registration = await navigator.serviceWorker.getRegistration(CAMPAIGN_PWA_SW_PATH) + const subscription = await registration?.pushManager.getSubscription() + if (!subscription) { + return { status: 'success', message: 'Nenhuma inscrição push ativa neste dispositivo.' } + } + const endpoint = subscription.endpoint + await subscription.unsubscribe() + const result = await unsubscribeCampaignPush(endpoint) + if ('status' in result && result.status === 'success') { + return { status: 'success', message: result.message } + } + if ('message' in result && result.message) { + return { status: 'error', message: result.message } + } + return { status: 'error', message: 'Não foi possível desativar os avisos push.' } + } catch { + return { status: 'error', message: 'Não foi possível desativar os avisos push.' } + } +} + +export const readCampaignPushSubscription = async (): Promise => { + try { + const registration = await navigator.serviceWorker.getRegistration(CAMPAIGN_PWA_SW_PATH) + return (await registration?.pushManager.getSubscription()) ?? null + } catch { + return null + } +} + +export const wasCampaignPushOptInDismissed = (): boolean => { + try { + return window.localStorage.getItem(CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY) === '1' + } catch { + return false + } +} + +export const dismissCampaignPushOptIn = (): void => { + try { + window.localStorage.setItem(CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY, '1') + } catch { + // Ignore quota / private-mode failures: the offer reappearing is a far + // smaller problem than a thrown error inside a toast callback. + } +} diff --git a/src/utilities/campaignPwa.ts b/src/utilities/campaignPwa.ts index 7d68af02..7152938f 100644 --- a/src/utilities/campaignPwa.ts +++ b/src/utilities/campaignPwa.ts @@ -5,6 +5,7 @@ export const CAMPAIGN_PWA_THEME_COLOR = '#c51414' const CAMPAIGN_PWA_BACKGROUND_COLOR = '#ffffff' export const CAMPAIGN_CACHE_PREFIX = 'campanha-' export const CAMPAIGN_PWA_INSTALL_TOAST_KEY = 'pwa-install-toast-dismissed' +export const CAMPAIGN_PUSH_OPT_IN_DISMISSED_KEY = 'campaign-push-opt-in-dismissed' export const CAMPAIGN_PWA_CLEAR_CACHES_MESSAGE = 'clear-campaign-caches' as const const MANIFEST_ICONS = [ diff --git a/tests/unit/codebaseConventions.unit.spec.ts b/tests/unit/codebaseConventions.unit.spec.ts index 9daab9f4..488dfd67 100644 --- a/tests/unit/codebaseConventions.unit.spec.ts +++ b/tests/unit/codebaseConventions.unit.spec.ts @@ -427,6 +427,9 @@ describe('src/utilities top-level is pinned', () => { 'campaignPasswordReset.ts', 'campaignPwa.ts', 'campaignPwaClient.ts', + // Push ceremony shared by the profile card and the opt-in toast (D6): + // client-only (localStorage + SW + server action), no Payload/Next. + 'campaignPushClient.ts', 'campaignRelationOptions.ts', 'campaignUserProfile.ts', 'consentContentHash.ts',