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