mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
b0c86cf8e3
commit
5bfa226a7e
16 changed files with 594 additions and 2 deletions
BIN
public/campaign-icons/apple-touch-icon.png
Normal file
BIN
public/campaign-icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/campaign-icons/icon-192.png
Normal file
BIN
public/campaign-icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
BIN
public/campaign-icons/icon-512.png
Normal file
BIN
public/campaign-icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
public/campaign-icons/icon-maskable-512.png
Normal file
BIN
public/campaign-icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7 KiB |
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
14
src/app/(campaign)/campanha/manifest.webmanifest/route.ts
Normal file
14
src/app/(campaign)/campanha/manifest.webmanifest/route.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
}
|
||||
35
src/app/(campaign)/campanha/offline/page.tsx
Normal file
35
src/app/(campaign)/campanha/offline/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/app/(campaign)/campanha/sw.js/route.ts
Normal file
22
src/app/(campaign)/campanha/sw.js/route.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
150
src/components/campaign/InstallPwaToast.tsx
Normal file
150
src/components/campaign/InstallPwaToast.tsx
Normal 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 "Adicionar à Tela de Início".</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>
|
||||
)
|
||||
}
|
||||
20
src/components/campaign/RegisterServiceWorker.tsx
Normal file
20
src/components/campaign/RegisterServiceWorker.tsx
Normal 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
|
||||
}
|
||||
191
src/utilities/campaignPwa.ts
Normal file
191
src/utilities/campaignPwa.ts
Normal 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).
|
||||
`
|
||||
}
|
||||
28
src/utilities/campaignPwaClient.ts
Normal file
28
src/utilities/campaignPwaClient.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
35
tests/e2e/campaign-pwa.e2e.spec.ts
Normal file
35
tests/e2e/campaign-pwa.e2e.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
71
tests/unit/campaignPwa.unit.spec.ts
Normal file
71
tests/unit/campaignPwa.unit.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue