Skip to content

Commit 48fa985

Browse files
authored
Merge pull request #86 from primev/feat/pwa
feat: add PWA support
2 parents 1dad849 + c11fbf8 commit 48fa985

7 files changed

Lines changed: 188 additions & 1 deletion

File tree

public/icons/icon-192x192.png

13.2 KB
Loading

public/icons/icon-512x512.png

67.4 KB
Loading

public/manifest.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "Fast Protocol",
3+
"short_name": "Fast",
4+
"description": "Sub-second swaps on Ethereum powered by preconfirmations",
5+
"start_url": "/",
6+
"display": "standalone",
7+
"background_color": "#030a14",
8+
"theme_color": "#3b8df8",
9+
"orientation": "portrait-primary",
10+
"icons": [
11+
{
12+
"src": "/icons/icon-192x192.png",
13+
"sizes": "192x192",
14+
"type": "image/png",
15+
"purpose": "any"
16+
},
17+
{
18+
"src": "/icons/icon-512x512.png",
19+
"sizes": "512x512",
20+
"type": "image/png",
21+
"purpose": "any"
22+
},
23+
{
24+
"src": "/icons/icon-512x512.png",
25+
"sizes": "512x512",
26+
"type": "image/png",
27+
"purpose": "maskable"
28+
}
29+
]
30+
}

public/sw.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
const CACHE_NAME = "fast-protocol-v1";
2+
3+
const PRECACHE_URLS = ["/", "/icons/icon-192x192.png", "/icons/icon-512x512.png"];
4+
5+
self.addEventListener("install", (event) => {
6+
event.waitUntil(
7+
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
8+
);
9+
self.skipWaiting();
10+
});
11+
12+
self.addEventListener("activate", (event) => {
13+
event.waitUntil(
14+
caches.keys().then((keys) =>
15+
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
16+
)
17+
);
18+
self.clients.claim();
19+
});
20+
21+
self.addEventListener("fetch", (event) => {
22+
const { request } = event;
23+
24+
// Skip non-GET and cross-origin requests
25+
if (request.method !== "GET" || !request.url.startsWith(self.location.origin)) return;
26+
27+
// Skip API routes and wallet/RPC calls
28+
if (request.url.includes("/api/") || request.url.includes("rpc")) return;
29+
30+
// Network-first for navigation (HTML pages)
31+
if (request.mode === "navigate") {
32+
event.respondWith(
33+
fetch(request)
34+
.then((response) => {
35+
const clone = response.clone();
36+
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
37+
return response;
38+
})
39+
.catch(() => caches.match(request))
40+
);
41+
return;
42+
}
43+
44+
// Cache-first for static assets
45+
if (
46+
request.destination === "image" ||
47+
request.destination === "font" ||
48+
request.destination === "style" ||
49+
request.destination === "script"
50+
) {
51+
event.respondWith(
52+
caches.match(request).then(
53+
(cached) =>
54+
cached ||
55+
fetch(request).then((response) => {
56+
const clone = response.clone();
57+
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
58+
return response;
59+
})
60+
)
61+
);
62+
}
63+
});

src/app/layout.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,13 @@ import { Providers } from "@/components/providers"
55
import { Analytics } from "@vercel/analytics/next"
66
import { SpeedInsights } from "@vercel/speed-insights/next"
77
import { getBaseUrl, SITE_URL } from "@/lib/site-config"
8+
import { ServiceWorkerRegister } from "@/components/pwa/service-worker-register"
9+
import { InstallPrompt } from "@/components/pwa/install-prompt"
810

911
export const viewport: Viewport = {
1012
width: "device-width",
1113
initialScale: 1,
14+
themeColor: "#3b8df8",
1215
}
1316

1417
export const metadata: Metadata = {
@@ -19,7 +22,11 @@ export const metadata: Metadata = {
1922
},
2023
description:
2124
"Swap tokens on Ethereum with sub-second execution powered by preconfirmations. Earn tokenized mev rewards with every trade on Fast Protocol.",
22-
icons: { icon: "/icon.png" },
25+
icons: {
26+
icon: "/icon.png",
27+
apple: "/icons/icon-192x192.png",
28+
},
29+
manifest: "/manifest.json",
2330
keywords: [
2431
"fast swaps",
2532
"ethereum swaps",
@@ -81,12 +88,19 @@ const jsonLd = {
8188
export default function RootLayout({ children }: { children: React.ReactNode }) {
8289
return (
8390
<html lang="en" className="scroll-smooth overflow-x-hidden">
91+
<head>
92+
<meta name="apple-mobile-web-app-capable" content="yes" />
93+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
94+
<meta name="apple-mobile-web-app-title" content="Fast Protocol" />
95+
</head>
8496
<body className="overflow-x-hidden">
8597
<script
8698
type="application/ld+json"
8799
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
88100
/>
89101
<Providers>{children}</Providers>
102+
<ServiceWorkerRegister />
103+
<InstallPrompt />
90104
<Analytics />
91105
<SpeedInsights />
92106
</body>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"use client"
2+
3+
import { useEffect, useState } from "react"
4+
import { Download, X } from "lucide-react"
5+
6+
interface BeforeInstallPromptEvent extends Event {
7+
prompt(): Promise<void>
8+
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>
9+
}
10+
11+
export function InstallPrompt() {
12+
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
13+
const [dismissed, setDismissed] = useState(false)
14+
15+
useEffect(() => {
16+
// Don't show if already installed or previously dismissed this session
17+
if (window.matchMedia("(display-mode: standalone)").matches) return
18+
19+
const handler = (e: Event) => {
20+
e.preventDefault()
21+
setDeferredPrompt(e as BeforeInstallPromptEvent)
22+
}
23+
24+
window.addEventListener("beforeinstallprompt", handler)
25+
return () => window.removeEventListener("beforeinstallprompt", handler)
26+
}, [])
27+
28+
if (!deferredPrompt || dismissed) return null
29+
30+
const handleInstall = async () => {
31+
await deferredPrompt.prompt()
32+
const { outcome } = await deferredPrompt.userChoice
33+
if (outcome === "accepted") {
34+
setDeferredPrompt(null)
35+
}
36+
setDismissed(true)
37+
}
38+
39+
return (
40+
<div className="fixed bottom-4 left-4 right-4 z-50 mx-auto max-w-sm animate-in slide-in-from-bottom-4 duration-300">
41+
<div className="flex items-center gap-3 rounded-xl border border-border/50 bg-card/95 p-4 shadow-lg backdrop-blur-sm">
42+
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
43+
<Download className="h-5 w-5 text-primary" />
44+
</div>
45+
<div className="flex-1 min-w-0">
46+
<p className="text-sm font-medium text-foreground">Install Fast Protocol</p>
47+
<p className="text-xs text-muted-foreground">Add to home screen for the best experience</p>
48+
</div>
49+
<button
50+
onClick={handleInstall}
51+
className="shrink-0 rounded-lg bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
52+
>
53+
Install
54+
</button>
55+
<button
56+
onClick={() => setDismissed(true)}
57+
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
58+
aria-label="Dismiss"
59+
>
60+
<X className="h-4 w-4" />
61+
</button>
62+
</div>
63+
</div>
64+
)
65+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"use client"
2+
3+
import { useEffect } from "react"
4+
5+
export function ServiceWorkerRegister() {
6+
useEffect(() => {
7+
if ("serviceWorker" in navigator) {
8+
navigator.serviceWorker.register("/sw.js").catch(() => {
9+
// Service worker registration failed — non-critical
10+
})
11+
}
12+
}, [])
13+
14+
return null
15+
}

0 commit comments

Comments
 (0)