Add installable PWA foundation for the /campanha field app.

Scoped manifest and service worker unlock offline shell and future push, with an install toast and logout cache wipe.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-18 17:28:34 -03:00
parent b0c86cf8e3
commit 5bfa226a7e
16 changed files with 594 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

View file

@ -3,6 +3,7 @@ import React from 'react'
import { CampaignBottomNav } from '@/components/campaign/CampaignBottomNav'
import { CampaignSidebar } from '@/components/campaign/CampaignSidebar'
import { InstallPwaToast } from '@/components/campaign/InstallPwaToast'
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/Sidebar'
import { Toaster } from '@/components/ui/Toaster'
import { getCampaignUser } from '@/utilities/campaignAuth'
@ -38,6 +39,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
</div>
<CampaignBottomNav role={user.role} />
<Toaster position="top-center" />
<InstallPwaToast />
</SidebarInset>
</SidebarProvider>
)

View file

@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import { CAMPAIGN_WEB_MANIFEST } from '@/utilities/campaignPwa'
export const dynamic = 'force-static'
export function GET(): NextResponse {
return NextResponse.json(CAMPAIGN_WEB_MANIFEST, {
headers: {
'Content-Type': 'application/manifest+json; charset=utf-8',
'Cache-Control': 'no-cache',
},
})
}

View file

@ -0,0 +1,35 @@
import { WifiOff } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
export const metadata: Metadata = {
title: 'Offline | Campanha',
robots: {
index: false,
follow: false,
},
}
export default function CampaignOfflinePage() {
return (
<main className="flex min-h-svh flex-col items-center justify-center gap-6 bg-muted p-6">
<div className="flex w-full max-w-sm flex-col items-center gap-4 text-center">
<div className="flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground">
<WifiOff className="size-6" aria-hidden="true" />
</div>
<div className="space-y-2">
<h1 className="text-xl font-semibold text-foreground">Você está offline</h1>
<p className="text-sm text-muted-foreground">
Sem conexão no momento. Abra novamente quando a rede voltar, ou tente uma página que
você já visitou nesta sessão.
</p>
</div>
<Button asChild className="min-h-11">
<Link href="/campanha">Tentar novamente</Link>
</Button>
</div>
</main>
)
}

View file

@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import {
buildCampaignServiceWorkerScript,
CAMPAIGN_PWA_SCOPE,
resolveCampaignPwaBuildId,
} from '@/utilities/campaignPwa'
// Built once per deploy; build id in the script body still invalidates SW updates.
export const dynamic = 'force-static'
export function GET(): NextResponse {
const script = buildCampaignServiceWorkerScript(resolveCampaignPwaBuildId())
return new NextResponse(script, {
headers: {
'Content-Type': 'text/javascript; charset=utf-8',
'Cache-Control': 'no-cache',
'Service-Worker-Allowed': CAMPAIGN_PWA_SCOPE,
},
})
}

View file

@ -1,8 +1,10 @@
import type { Metadata } from 'next'
import type { Metadata, Viewport } from 'next'
import { Inter } from 'next/font/google'
import React from 'react'
import { RegisterServiceWorker } from '@/components/campaign/RegisterServiceWorker'
import { VoteEstimateFocusProvider } from '@/components/campaign/VoteEstimateFocusProvider'
import { CAMPAIGN_PWA_MANIFEST_PATH, CAMPAIGN_PWA_THEME_COLOR } from '@/utilities/campaignPwa'
import '../(frontend)/styles.css'
@ -14,6 +16,19 @@ export const metadata: Metadata = {
index: false,
follow: false,
},
manifest: CAMPAIGN_PWA_MANIFEST_PATH,
appleWebApp: {
capable: true,
title: 'Campanha',
statusBarStyle: 'default',
},
icons: {
apple: '/campaign-icons/apple-touch-icon.png',
},
}
export const viewport: Viewport = {
themeColor: CAMPAIGN_PWA_THEME_COLOR,
}
export default function CampanhaRootLayout({ children }: { children: React.ReactNode }) {
@ -26,6 +41,7 @@ export default function CampanhaRootLayout({ children }: { children: React.React
>
<body className="bg-background bg-none text-foreground antialiased">
<VoteEstimateFocusProvider>{children}</VoteEstimateFocusProvider>
<RegisterServiceWorker />
</body>
</html>
)

View file

@ -1,5 +1,6 @@
'use client'
import type { FormEvent } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
@ -21,6 +22,7 @@ import {
useSidebar,
} from '@/components/ui/Sidebar'
import type { CampaignUser } from '@/payload-types'
import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient'
export type CampaignSidebarUser = {
name: string
@ -38,6 +40,12 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar()
const handleLogout = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
await clearCampaignPwaCaches()
await logoutCampaign()
}
return (
<Sidebar collapsible="none" className="h-svh shrink-0 border-r border-sidebar-border">
<SidebarHeader className="border-b border-sidebar-border">
@ -82,7 +90,7 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
) : null}
</div>
<form action={logoutCampaign}>
<form action={logoutCampaign} onSubmit={handleLogout}>
<Button
type="submit"
variant="outline"

View file

@ -0,0 +1,150 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import { CAMPAIGN_PWA_INSTALL_TOAST_KEY } from '@/utilities/campaignPwa'
type BeforeInstallPromptEvent = Event & {
prompt: () => Promise<void>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
}
const TOAST_ID = 'campaign-pwa-install'
const isMobileViewport = (): boolean =>
window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(max-width: 768px)').matches
const isStandaloneDisplay = (): boolean => {
const standaloneMedia = window.matchMedia('(display-mode: standalone)').matches
const iosStandalone = Boolean((navigator as Navigator & { standalone?: boolean }).standalone)
return standaloneMedia || iosStandalone
}
const wasDismissedThisSession = (): boolean => {
try {
return sessionStorage.getItem(CAMPAIGN_PWA_INSTALL_TOAST_KEY) === '1'
} catch {
// sessionStorage can throw in private mode.
return false
}
}
const markDismissedThisSession = (): void => {
try {
sessionStorage.setItem(CAMPAIGN_PWA_INSTALL_TOAST_KEY, '1')
} catch {
// Ignore quota / private-mode failures.
}
}
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 dismissInstallToast = (): void => {
markDismissedThisSession()
toast.dismiss(TOAST_ID)
}
export const InstallPwaToast = () => {
const deferredPromptRef = useRef<BeforeInstallPromptEvent | null>(null)
const [iosDrawerOpen, setIosDrawerOpen] = useState(false)
useEffect(() => {
const onBeforeInstallPrompt = (event: Event) => {
event.preventDefault()
deferredPromptRef.current = event as BeforeInstallPromptEvent
}
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt)
return () => window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt)
}, [])
useEffect(() => {
if (!isMobileViewport() || isStandaloneDisplay() || wasDismissedThisSession()) return
const installAndroid = async () => {
const promptEvent = deferredPromptRef.current
if (!promptEvent) return
try {
await promptEvent.prompt()
const choice = await promptEvent.userChoice
if (choice.outcome === 'accepted') dismissInstallToast()
} catch {
// Native install prompt dismissed or rejected.
}
}
// Wait briefly so `beforeinstallprompt` can arrive after SW registration.
const timer = window.setTimeout(() => {
if (isStandaloneDisplay() || wasDismissedThisSession()) return
const hasPrompt = deferredPromptRef.current != null
if (!hasPrompt && !isIosSafari()) return
toast.message('Instale o app da campanha', {
id: TOAST_ID,
description: 'Acesse mais rápido e receba avisos',
duration: Infinity,
closeButton: true,
onDismiss: markDismissedThisSession,
action: {
label: hasPrompt ? 'Instalar' : 'Como instalar',
onClick: () => {
if (hasPrompt) {
void installAndroid()
return
}
setIosDrawerOpen(true)
},
},
})
}, 1200)
return () => {
window.clearTimeout(timer)
toast.dismiss(TOAST_ID)
}
}, [])
return (
<Drawer open={iosDrawerOpen} onOpenChange={setIosDrawerOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Como instalar no iPhone</DrawerTitle>
<DrawerDescription>
No Safari, use o menu de compartilhamento para adicionar o app à Tela de Início.
</DrawerDescription>
</DrawerHeader>
<ol className="list-decimal space-y-2 px-4 pb-2 text-left text-sm text-foreground">
<li>Toque no botão Compartilhar (quadrado com seta para cima).</li>
<li>Role e toque em &quot;Adicionar à Tela de Início&quot;.</li>
<li>Confirme tocando em Adicionar.</li>
</ol>
<DrawerFooter>
<DrawerClose
render={
<Button type="button" variant="outline" className="w-full" onClick={dismissInstallToast} />
}
>
Entendi
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}

View file

@ -0,0 +1,20 @@
'use client'
import { useEffect } from 'react'
import { CAMPAIGN_PWA_SCOPE, CAMPAIGN_PWA_SW_PATH } from '@/utilities/campaignPwa'
export const RegisterServiceWorker = () => {
useEffect(() => {
if (process.env.NODE_ENV !== 'production') return
if (!('serviceWorker' in navigator)) return
void navigator.serviceWorker
.register(CAMPAIGN_PWA_SW_PATH, { scope: CAMPAIGN_PWA_SCOPE })
.catch(() => {
// Registration failures must never break the campaign UI.
})
}, [])
return null
}

View file

@ -0,0 +1,191 @@
export const CAMPAIGN_PWA_SCOPE = '/campanha'
export const CAMPAIGN_PWA_MANIFEST_PATH = '/campanha/manifest.webmanifest'
export const CAMPAIGN_PWA_SW_PATH = '/campanha/sw.js'
export const CAMPAIGN_PWA_THEME_COLOR = '#c51414'
export 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_PWA_CLEAR_CACHES_MESSAGE = 'clear-campaign-caches' as const
const MANIFEST_ICONS = [
{
src: '/campaign-icons/icon-192.png',
sizes: '192x192',
type: 'image/png' as const,
purpose: 'any' as const,
},
{
src: '/campaign-icons/icon-512.png',
sizes: '512x512',
type: 'image/png' as const,
purpose: 'any' as const,
},
{
src: '/campaign-icons/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png' as const,
purpose: 'maskable' as const,
},
]
/** Precache list: manifest icons + apple-touch (not in the web manifest). */
export const CAMPAIGN_PWA_ICON_PATHS = [
...MANIFEST_ICONS.map((icon) => icon.src),
'/campaign-icons/apple-touch-icon.png',
] as const
export type CampaignWebManifest = {
name: string
short_name: string
description: string
start_url: string
scope: string
display: 'standalone'
lang: string
theme_color: string
background_color: string
icons: typeof MANIFEST_ICONS
}
export const CAMPAIGN_WEB_MANIFEST: CampaignWebManifest = {
name: 'Campanha Jorge Solla',
short_name: 'Campanha',
description: 'Ferramenta de campo da campanha Jorge Solla',
start_url: CAMPAIGN_PWA_SCOPE,
scope: CAMPAIGN_PWA_SCOPE,
display: 'standalone',
lang: 'pt-BR',
theme_color: CAMPAIGN_PWA_THEME_COLOR,
background_color: CAMPAIGN_PWA_BACKGROUND_COLOR,
icons: MANIFEST_ICONS,
}
export const resolveCampaignPwaBuildId = (
env: Record<string, string | undefined> = process.env,
): string => env.VERCEL_GIT_COMMIT_SHA || env.VERCEL_DEPLOYMENT_ID || 'dev'
export const buildCampaignServiceWorkerScript = (buildId: string): string => {
const cacheName = `${CAMPAIGN_CACHE_PREFIX}${buildId}`
const precache = JSON.stringify([
`${CAMPAIGN_PWA_SCOPE}/login`,
`${CAMPAIGN_PWA_SCOPE}/offline`,
...CAMPAIGN_PWA_ICON_PATHS,
])
return `/* Campaign PWA service worker — build ${buildId} */
const CACHE_NAME = ${JSON.stringify(cacheName)};
const CACHE_PREFIX = ${JSON.stringify(CAMPAIGN_CACHE_PREFIX)};
const PRECACHE_URLS = ${precache};
const CLEAR_CACHES_MESSAGE = ${JSON.stringify(CAMPAIGN_PWA_CLEAR_CACHES_MESSAGE)};
const SCOPE = ${JSON.stringify(CAMPAIGN_PWA_SCOPE)};
const OFFLINE_URL = SCOPE + '/offline';
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener('message', (event) => {
if (event.data && event.data.type === CLEAR_CACHES_MESSAGE) {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key.startsWith(CACHE_PREFIX)).map((key) => caches.delete(key))),
),
);
}
});
const isCampaignPath = (pathname) =>
pathname === SCOPE || pathname.startsWith(SCOPE + '/');
const isInvitePath = (pathname) => pathname.startsWith(SCOPE + '/convite');
const isCampaignIconPath = (pathname) => pathname.startsWith('/campaign-icons/');
const isRscRequest = (request) =>
request.headers.get('RSC') === '1' || request.headers.has('Next-Router-State-Tree');
const cacheFirst = async (request) => {
const cached = await caches.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response && response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
};
const networkFirst = async (request, { navigate = false } = {}) => {
try {
const response = await fetch(request);
if (response && response.ok && !isRscRequest(request)) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
if (!navigate) throw new Error('offline');
const offline = await caches.match(OFFLINE_URL);
if (offline) return offline;
return new Response('Offline', {
status: 503,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
};
self.addEventListener('fetch', (event) => {
const request = event.request;
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Invite pages are force-no-store and must never be cached.
if (isInvitePath(url.pathname)) return;
if (isCampaignIconPath(url.pathname)) {
event.respondWith(cacheFirst(request));
return;
}
if (!isCampaignPath(url.pathname)) return;
if (request.mode === 'navigate') {
event.respondWith(networkFirst(request, { navigate: true }));
return;
}
// RSC/Flight GETs: network-only (never write personalized payloads to Cache Storage).
if (isRscRequest(request)) return;
event.respondWith(networkFirst(request));
});
// Push / notificationclick handlers land in D2 (notifications.md).
`
}

View file

@ -0,0 +1,28 @@
import {
CAMPAIGN_CACHE_PREFIX,
CAMPAIGN_PWA_CLEAR_CACHES_MESSAGE,
} from '@/utilities/campaignPwa'
/** Best-effort Cache API wipe on logout. SW cannot be messaged from a server action. */
export const clearCampaignPwaCaches = async (): Promise<void> => {
try {
if (!('caches' in window)) return
const keys = await caches.keys()
await Promise.all(
keys
.filter((key) => key.startsWith(CAMPAIGN_CACHE_PREFIX))
.map((key) => caches.delete(key)),
)
} catch {
// Cache API can throw in private mode / when storage is unavailable.
}
// Notify an active SW so it can drop any in-flight cache work; page already wiped Storage.
try {
navigator.serviceWorker?.controller?.postMessage({
type: CAMPAIGN_PWA_CLEAR_CACHES_MESSAGE,
})
} catch {
// No controller, or postMessage rejected — safe to ignore.
}
}

View file

@ -0,0 +1,35 @@
/* eslint-disable check-file/filename-naming-convention */
import { expect, test } from '@playwright/test'
test.describe('Campaign PWA foundation', () => {
test('exposes manifest link and serves installable assets', async ({ page, request }) => {
await page.goto('/campanha/login')
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute(
'href',
'/campanha/manifest.webmanifest',
)
const manifestResponse = await request.get('/campanha/manifest.webmanifest')
expect(manifestResponse.status()).toBe(200)
expect(manifestResponse.headers()['content-type']).toContain('application/manifest+json')
await expect(manifestResponse.json()).resolves.toMatchObject({
start_url: '/campanha',
scope: '/campanha',
display: 'standalone',
})
const swResponse = await request.get('/campanha/sw.js')
expect(swResponse.status()).toBe(200)
expect(swResponse.headers()['content-type']).toContain('text/javascript')
expect(swResponse.headers()['service-worker-allowed']).toBe('/campanha')
const swBody = await swResponse.text()
expect(swBody).toContain(`SCOPE + '/convite'`)
expect(swBody).toContain('/campanha/offline')
expect(swBody).toContain('isRscRequest')
await page.goto('/campanha/offline')
await expect(page.getByRole('heading', { name: 'Você está offline' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Tentar novamente' })).toBeVisible()
})
})

View file

@ -0,0 +1,71 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { GET as getManifest } from '@/app/(campaign)/campanha/manifest.webmanifest/route'
import { GET as getServiceWorker } from '@/app/(campaign)/campanha/sw.js/route'
import {
buildCampaignServiceWorkerScript,
CAMPAIGN_CACHE_PREFIX,
CAMPAIGN_PWA_CLEAR_CACHES_MESSAGE,
CAMPAIGN_PWA_SCOPE,
CAMPAIGN_PWA_THEME_COLOR,
CAMPAIGN_WEB_MANIFEST,
resolveCampaignPwaBuildId,
} from '@/utilities/campaignPwa'
describe('campaign PWA helpers', () => {
it('builds a manifest scoped to /campanha without a trailing slash', () => {
expect(CAMPAIGN_WEB_MANIFEST.start_url).toBe(CAMPAIGN_PWA_SCOPE)
expect(CAMPAIGN_WEB_MANIFEST.scope).toBe(CAMPAIGN_PWA_SCOPE)
expect(CAMPAIGN_WEB_MANIFEST.start_url.endsWith('/')).toBe(false)
expect(CAMPAIGN_WEB_MANIFEST.scope.endsWith('/')).toBe(false)
expect(CAMPAIGN_WEB_MANIFEST.display).toBe('standalone')
expect(CAMPAIGN_WEB_MANIFEST.theme_color).toBe(CAMPAIGN_PWA_THEME_COLOR)
expect(CAMPAIGN_WEB_MANIFEST.icons.some((icon) => icon.purpose === 'maskable')).toBe(true)
})
it('resolves a stable build id from Vercel env with a local fallback', () => {
expect(resolveCampaignPwaBuildId({ VERCEL_GIT_COMMIT_SHA: 'abc123' })).toBe('abc123')
expect(resolveCampaignPwaBuildId({ VERCEL_DEPLOYMENT_ID: 'dpl_1' })).toBe('dpl_1')
expect(resolveCampaignPwaBuildId({})).toBe('dev')
})
it('embeds invite exclusion, RSC skip, cache prefix, and logout message in the SW script', () => {
const script = buildCampaignServiceWorkerScript('sha-test')
expect(script).toContain(`${CAMPAIGN_CACHE_PREFIX}sha-test`)
expect(script).toContain(`SCOPE + '/convite'`)
expect(script).toContain(CAMPAIGN_PWA_CLEAR_CACHES_MESSAGE)
expect(script).toContain(`${CAMPAIGN_PWA_SCOPE}/offline`)
expect(script).toContain('isRscRequest')
expect(script).toContain('skipWaiting')
expect(script).toContain('clients.claim')
})
})
describe('campaign PWA route handlers', () => {
it('serves the web manifest with the expected headers and body', async () => {
const response = getManifest()
const body = await response.json()
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('application/manifest+json')
expect(response.headers.get('Cache-Control')).toBe('no-cache')
expect(body.scope).toBe('/campanha')
expect(body.start_url).toBe('/campanha')
})
it('serves the service worker with Service-Worker-Allowed scoped to /campanha', async () => {
const response = getServiceWorker()
const body = await response.text()
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/javascript')
expect(response.headers.get('Cache-Control')).toBe('no-cache')
expect(response.headers.get('Service-Worker-Allowed')).toBe('/campanha')
expect(body).toContain('campanha-')
expect(body).toContain(`SCOPE + '/convite'`)
expect(body).toContain('isRscRequest')
})
})