diff --git a/public/campaign-icons/apple-touch-icon.png b/public/campaign-icons/apple-touch-icon.png
new file mode 100644
index 00000000..7ad1be1b
Binary files /dev/null and b/public/campaign-icons/apple-touch-icon.png differ
diff --git a/public/campaign-icons/icon-192.png b/public/campaign-icons/icon-192.png
new file mode 100644
index 00000000..1e1f8560
Binary files /dev/null and b/public/campaign-icons/icon-192.png differ
diff --git a/public/campaign-icons/icon-512.png b/public/campaign-icons/icon-512.png
new file mode 100644
index 00000000..3f73342b
Binary files /dev/null and b/public/campaign-icons/icon-512.png differ
diff --git a/public/campaign-icons/icon-maskable-512.png b/public/campaign-icons/icon-maskable-512.png
new file mode 100644
index 00000000..f149edb5
Binary files /dev/null and b/public/campaign-icons/icon-maskable-512.png differ
diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx
index 0ed58918..08b70ce4 100644
--- a/src/app/(campaign)/campanha/(app)/layout.tsx
+++ b/src/app/(campaign)/campanha/(app)/layout.tsx
@@ -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.
+
)
diff --git a/src/app/(campaign)/campanha/manifest.webmanifest/route.ts b/src/app/(campaign)/campanha/manifest.webmanifest/route.ts
new file mode 100644
index 00000000..ffc2b243
--- /dev/null
+++ b/src/app/(campaign)/campanha/manifest.webmanifest/route.ts
@@ -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',
+ },
+ })
+}
diff --git a/src/app/(campaign)/campanha/offline/page.tsx b/src/app/(campaign)/campanha/offline/page.tsx
new file mode 100644
index 00000000..d5f01ecf
--- /dev/null
+++ b/src/app/(campaign)/campanha/offline/page.tsx
@@ -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 (
+
+
+
+
+
+
+
Você está offline
+
+ Sem conexão no momento. Abra novamente quando a rede voltar, ou tente uma página que
+ você já visitou nesta sessão.
+
+
+
+
+
+ )
+}
diff --git a/src/app/(campaign)/campanha/sw.js/route.ts b/src/app/(campaign)/campanha/sw.js/route.ts
new file mode 100644
index 00000000..ac8523a2
--- /dev/null
+++ b/src/app/(campaign)/campanha/sw.js/route.ts
@@ -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,
+ },
+ })
+}
diff --git a/src/app/(campaign)/layout.tsx b/src/app/(campaign)/layout.tsx
index 0f7d9d84..01962fe3 100644
--- a/src/app/(campaign)/layout.tsx
+++ b/src/app/(campaign)/layout.tsx
@@ -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
>
{children}
+