mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
- requestPermission agora é chamado sincronamente no clique: dentro do
startTransition(async ...) o navegador perdia a user activation e o Chrome
auto-negava silenciosamente ("Permissão de notificação negada" sem prompt)
- CampaignPushOptInToast no shell (app): staff com permission 'default', sem
inscrição ativa, consentimento publicado e VAPID configurado; dismiss em
localStorage; iOS só com app instalado
- cerimônia de ativação/desativação extraída para
src/utilities/campaignPushClient.ts, compartilhada com o card de perfil
- mensagens distintas para denied (reativar nas configurações do site) vs
default (prompt não exibido); pin de top-level registrado
- CHANGELOG-AGENTS.md: entrada única
232 lines
6.9 KiB
TypeScript
232 lines
6.9 KiB
TypeScript
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'
|
|
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 = [
|
|
{
|
|
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). */
|
|
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 (D2).
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) return;
|
|
let payload;
|
|
try {
|
|
payload = event.data.json();
|
|
} catch {
|
|
return;
|
|
}
|
|
const title = payload && payload.title ? String(payload.title) : 'Campanha Jorge Solla';
|
|
const body = payload && payload.body ? String(payload.body) : '';
|
|
const url = payload && payload.url ? String(payload.url) : SCOPE;
|
|
event.waitUntil(
|
|
self.registration.showNotification(title, {
|
|
body,
|
|
icon: '/campaign-icons/icon-192.png',
|
|
badge: '/campaign-icons/icon-192.png',
|
|
data: { url },
|
|
tag: url,
|
|
}),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
const url = event.notification.data && event.notification.data.url
|
|
? String(event.notification.data.url)
|
|
: SCOPE;
|
|
const target = url.startsWith('http') ? url : self.location.origin + url;
|
|
event.waitUntil(
|
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
|
for (const client of clients) {
|
|
if ('focus' in client && client.url.includes(SCOPE)) {
|
|
client.navigate(target);
|
|
return client.focus();
|
|
}
|
|
}
|
|
if (self.clients.openWindow) return self.clients.openWindow(target);
|
|
}),
|
|
);
|
|
});
|
|
`
|
|
}
|