mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
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:
commit
e75148f3c4
8 changed files with 387 additions and 126 deletions
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
107
src/components/campaign/shell/CampaignPushOptInToast.tsx
Normal file
107
src/components/campaign/shell/CampaignPushOptInToast.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
168
src/utilities/campaignPushClient.ts
Normal file
168
src/utilities/campaignPushClient.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue