Merge pull request #634 from fsolla/push-optin-automatico-fix-prompt-nativo

feat(push): opt-in automático e fix do prompt nativo no /campanha
This commit is contained in:
Francisco Solla 2026-08-11 01:07:15 +00:00 committed by GitHub
commit e75148f3c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 387 additions and 126 deletions

View file

@ -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).

View file

@ -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}
</TooltipProvider>
<InstallPwaToast />
{pushOptIn ? <CampaignPushOptInToast {...pushOptIn} /> : null}
{biometricEnrollment ? (
<BiometricEnrollmentToast {...biometricEnrollment} />
) : null}

View file

@ -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)
}
})
}

View file

@ -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
}

View file

@ -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,

View file

@ -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<CampaignPushResult> => {
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<CampaignPushResult> => {
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<PushSubscription | null> => {
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.
}
}

View file

@ -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 = [

View file

@ -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',