Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/icons/icon-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions public/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "Fast Protocol",
"short_name": "Fast",
"description": "Sub-second swaps on Ethereum powered by preconfirmations",
"start_url": "/",
"display": "standalone",
"background_color": "#030a14",
"theme_color": "#3b8df8",
"orientation": "portrait-primary",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
63 changes: 63 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
const CACHE_NAME = "fast-protocol-v1";

const PRECACHE_URLS = ["/", "/icons/icon-192x192.png", "/icons/icon-512x512.png"];

self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});

self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
)
);
self.clients.claim();
});

self.addEventListener("fetch", (event) => {
const { request } = event;

// Skip non-GET and cross-origin requests
if (request.method !== "GET" || !request.url.startsWith(self.location.origin)) return;

// Skip API routes and wallet/RPC calls
if (request.url.includes("/api/") || request.url.includes("rpc")) return;

// Network-first for navigation (HTML pages)
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
})
.catch(() => caches.match(request))
);
return;
}

// Cache-first for static assets
if (
request.destination === "image" ||
request.destination === "font" ||
request.destination === "style" ||
request.destination === "script"
) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ||
fetch(request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
return response;
})
)
);
}
});
16 changes: 15 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import { Providers } from "@/components/providers"
import { Analytics } from "@vercel/analytics/next"
import { SpeedInsights } from "@vercel/speed-insights/next"
import { getBaseUrl, SITE_URL } from "@/lib/site-config"
import { ServiceWorkerRegister } from "@/components/pwa/service-worker-register"
import { InstallPrompt } from "@/components/pwa/install-prompt"

export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: "#3b8df8",
}

export const metadata: Metadata = {
Expand All @@ -19,7 +22,11 @@ export const metadata: Metadata = {
},
description:
"Swap tokens on Ethereum with sub-second execution powered by preconfirmations. Earn tokenized mev rewards with every trade on Fast Protocol.",
icons: { icon: "/icon.png" },
icons: {
icon: "/icon.png",
apple: "/icons/icon-192x192.png",
},
manifest: "/manifest.json",
keywords: [
"fast swaps",
"ethereum swaps",
Expand Down Expand Up @@ -81,12 +88,19 @@ const jsonLd = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className="scroll-smooth overflow-x-hidden">
<head>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Fast Protocol" />
</head>
<body className="overflow-x-hidden">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<Providers>{children}</Providers>
<ServiceWorkerRegister />
<InstallPrompt />
<Analytics />
<SpeedInsights />
</body>
Expand Down
65 changes: 65 additions & 0 deletions src/components/pwa/install-prompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"use client"

import { useEffect, useState } from "react"
import { Download, X } from "lucide-react"

interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>
}

export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [dismissed, setDismissed] = useState(false)

useEffect(() => {
// Don't show if already installed or previously dismissed this session
if (window.matchMedia("(display-mode: standalone)").matches) return

const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e as BeforeInstallPromptEvent)
}

window.addEventListener("beforeinstallprompt", handler)
return () => window.removeEventListener("beforeinstallprompt", handler)
}, [])

if (!deferredPrompt || dismissed) return null

const handleInstall = async () => {
await deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === "accepted") {
setDeferredPrompt(null)
}
setDismissed(true)
}

return (
<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">
<div className="flex items-center gap-3 rounded-xl border border-border/50 bg-card/95 p-4 shadow-lg backdrop-blur-sm">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Download className="h-5 w-5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Install Fast Protocol</p>
<p className="text-xs text-muted-foreground">Add to home screen for the best experience</p>
</div>
<button
onClick={handleInstall}
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"
>
Install
</button>
<button
onClick={() => setDismissed(true)}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
)
}
15 changes: 15 additions & 0 deletions src/components/pwa/service-worker-register.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client"

import { useEffect } from "react"

export function ServiceWorkerRegister() {
useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js").catch(() => {
// Service worker registration failed — non-critical
})
}
}, [])

return null
}
Loading