Skip to content

Commit 97fdd6d

Browse files
authored
Merge pull request #60 from favourawaku/main
Adds PWA support, invoice release notifications, and optimistic payment UI for StellarSplit (issues #5#7).
2 parents 1fc8f5c + ae95060 commit 97fdd6d

9 files changed

Lines changed: 355 additions & 2 deletions

File tree

next.config.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
/** @type {import('next').NextConfig} */
22
const nextConfig = {
3+
async headers() {
4+
return [
5+
{
6+
source: "/sw.js",
7+
headers: [
8+
{ key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
9+
{ key: "Service-Worker-Allowed", value: "/" },
10+
],
11+
},
12+
];
13+
},
314
webpack: (config, { isServer }) => {
415
if (!isServer) {
516
// sodium-native is a Node.js native module — exclude from browser bundle

public/icons/icon-192.png

414 Bytes
Loading

public/icons/icon-512.png

1.46 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": "StellarSplit",
3+
"short_name": "StellarSplit",
4+
"description": "On-chain invoice splitting on Stellar",
5+
"start_url": "/",
6+
"scope": "/",
7+
"display": "standalone",
8+
"background_color": "#030712",
9+
"theme_color": "#4f46e5",
10+
"icons": [
11+
{
12+
"src": "/icons/icon-192.png",
13+
"sizes": "192x192",
14+
"type": "image/png",
15+
"purpose": "any"
16+
},
17+
{
18+
"src": "/icons/icon-512.png",
19+
"sizes": "512x512",
20+
"type": "image/png",
21+
"purpose": "any"
22+
},
23+
{
24+
"src": "/icons/icon-512.png",
25+
"sizes": "512x512",
26+
"type": "image/png",
27+
"purpose": "maskable"
28+
}
29+
]
30+
}

public/offline.html

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<meta name="theme-color" content="#4f46e5" />
7+
<title>Offline — StellarSplit</title>
8+
<style>
9+
* {
10+
box-sizing: border-box;
11+
margin: 0;
12+
padding: 0;
13+
}
14+
body {
15+
min-height: 100vh;
16+
display: flex;
17+
align-items: center;
18+
justify-content: center;
19+
padding: 1.5rem;
20+
font-family: system-ui, -apple-system, sans-serif;
21+
background: #030712;
22+
color: #f3f4f6;
23+
text-align: center;
24+
}
25+
.card {
26+
max-width: 22rem;
27+
width: 100%;
28+
}
29+
h1 {
30+
font-size: 1.75rem;
31+
font-weight: 700;
32+
margin-bottom: 0.5rem;
33+
}
34+
h1 span {
35+
color: #818cf8;
36+
}
37+
p {
38+
color: #9ca3af;
39+
font-size: 0.95rem;
40+
line-height: 1.5;
41+
margin-bottom: 1.5rem;
42+
}
43+
a {
44+
display: inline-block;
45+
padding: 0.75rem 1.25rem;
46+
border-radius: 0.5rem;
47+
background: #4f46e5;
48+
color: #fff;
49+
font-weight: 600;
50+
text-decoration: none;
51+
font-size: 0.9rem;
52+
}
53+
a:hover {
54+
background: #6366f1;
55+
}
56+
</style>
57+
</head>
58+
<body>
59+
<div class="card">
60+
<h1>Stellar<span>Split</span></h1>
61+
<p>
62+
You&rsquo;re offline. Cached pages may still work&mdash;reconnect to sync
63+
invoices and payments.
64+
</p>
65+
<a href="/">Back to home</a>
66+
</div>
67+
</body>
68+
</html>

public/sw.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const CACHE_NAME = "stellarsplit-v1";
2+
const PRECACHE_URLS = ["/", "/dashboard", "/offline.html", "/manifest.json"];
3+
4+
self.addEventListener("install", (event) => {
5+
event.waitUntil(
6+
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
7+
);
8+
self.skipWaiting();
9+
});
10+
11+
self.addEventListener("activate", (event) => {
12+
event.waitUntil(
13+
caches.keys().then((keys) =>
14+
Promise.all(
15+
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
16+
)
17+
)
18+
);
19+
self.clients.claim();
20+
});
21+
22+
self.addEventListener("fetch", (event) => {
23+
const { request } = event;
24+
25+
if (request.method !== "GET") return;
26+
27+
if (request.mode === "navigate") {
28+
event.respondWith(
29+
fetch(request)
30+
.then((response) => {
31+
const copy = response.clone();
32+
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
33+
return response;
34+
})
35+
.catch(async () => {
36+
const cached = await caches.match(request);
37+
if (cached) return cached;
38+
const offline = await caches.match("/offline.html");
39+
if (offline) return offline;
40+
return new Response("Offline", { status: 503 });
41+
})
42+
);
43+
return;
44+
}
45+
46+
event.respondWith(
47+
caches.match(request).then(
48+
(cached) =>
49+
cached ||
50+
fetch(request).then((response) => {
51+
if (response.ok && request.url.startsWith(self.location.origin)) {
52+
const copy = response.clone();
53+
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
54+
}
55+
return response;
56+
})
57+
)
58+
);
59+
});

src/app/invoice/[id]/page.tsx

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
import { useEffect, useRef, useState } from "react";
44
import { splitClient } from "@/lib/stellar";
55
import { getFreighterPublicKey } from "@/lib/freighter";
6+
import {
7+
isSubscribedToInvoice,
8+
notifyInvoiceReleased,
9+
requestNotificationPermission,
10+
subscribeToInvoice,
11+
} from "@/lib/notifications";
612
import { formatAmount, parseAmount } from "@stellar-split/sdk";
713
import PaymentProgress from "@/components/PaymentProgress";
814
import InstallmentPanel from "@/components/InstallmentPanel";
@@ -23,6 +29,23 @@ interface Props {
2329
params: { id: string };
2430
}
2531

32+
type InvoicePayment = Payment & { pending?: boolean; clientKey?: string };
33+
type InvoiceView = Omit<Invoice, "payments"> & { payments: InvoicePayment[] };
34+
35+
function mergeWithServer(server: Invoice, local: InvoiceView | null): InvoiceView {
36+
const pending = (local?.payments ?? []).filter((p) => p.pending);
37+
const unmatchedPending = pending.filter(
38+
(p) =>
39+
!server.payments.some((sp) => sp.payer === p.payer && sp.amount === p.amount)
40+
);
41+
return {
42+
...server,
43+
payments: [...server.payments, ...unmatchedPending],
44+
funded:
45+
server.funded + unmatchedPending.reduce((sum, p) => sum + p.amount, 0n),
46+
};
47+
}
48+
2649
/**
2750
* Invoice detail page — shows status, payment progress, Pay button,
2851
* reminder system, and webhook configuration (creator only).
@@ -98,17 +121,40 @@ export default function InvoiceDetailPage({ params }: Props) {
98121
const handlePay = async (e: React.FormEvent) => {
99122
e.preventDefault();
100123
if (!publicKey || !invoice) return;
124+
const amount = parseAmount(payAmount);
125+
const clientKey = `opt-${Date.now()}`;
101126
setError(null);
127+
setInvoice((prev) => {
128+
if (!prev) return prev;
129+
return {
130+
...prev,
131+
funded: prev.funded + amount,
132+
payments: [
133+
...prev.payments,
134+
{ payer: publicKey, amount, pending: true, clientKey },
135+
],
136+
};
137+
});
102138
setPaying(true);
103139
try {
104140
const result = await splitClient.pay({
105141
payer: publicKey,
106142
invoiceId: id,
107-
amount: parseAmount(payAmount),
143+
amount,
108144
});
109145
setTxHash(result.txHash);
110146
await load();
111147
} catch (err) {
148+
setInvoice((prev) => {
149+
if (!prev) return prev;
150+
const pending = prev.payments.find((p) => p.clientKey === clientKey);
151+
if (!pending?.pending) return prev;
152+
return {
153+
...prev,
154+
funded: prev.funded - pending.amount,
155+
payments: prev.payments.filter((p) => p.clientKey !== clientKey),
156+
};
157+
});
112158
setError(String(err));
113159
} finally {
114160
setPaying(false);
@@ -204,6 +250,59 @@ export default function InvoiceDetailPage({ params }: Props) {
204250
</p>
205251
</section>
206252

253+
{/* Release notifications */}
254+
<section className="mb-8">
255+
<button
256+
type="button"
257+
onClick={handleNotifyMe}
258+
disabled={notifySubscribed}
259+
className="w-full sm:w-auto px-4 py-2 rounded-lg border border-gray-700 hover:border-indigo-500 text-sm font-semibold transition-colors disabled:opacity-60 disabled:cursor-default"
260+
>
261+
{notifySubscribed ? "Notifications enabled" : "Notify me"}
262+
</button>
263+
{notifyDenied && (
264+
<p className="text-gray-400 text-sm mt-2">
265+
Notifications are blocked. Enable them in your browser settings to get
266+
alerts when this invoice is released.
267+
</p>
268+
)}
269+
</section>
270+
271+
{/* Payments */}
272+
<section className="mb-8">
273+
<h2 className="text-lg font-semibold mb-3">
274+
Payments ({invoice.payments.length})
275+
</h2>
276+
{invoice.payments.length === 0 ? (
277+
<p className="text-gray-500 text-sm">No payments yet.</p>
278+
) : (
279+
<ul className="flex flex-col gap-2">
280+
{invoice.payments.map((p, i) => (
281+
<li
282+
key={p.clientKey ?? `${p.payer}-${i}`}
283+
className="flex flex-wrap items-center justify-between gap-2 bg-gray-900 rounded-lg px-4 py-2 text-sm"
284+
>
285+
<span className="font-mono text-gray-300 truncate max-w-[55%]">
286+
{p.payer}
287+
</span>
288+
<div className="flex items-center gap-2 shrink-0">
289+
<span className="text-indigo-300">{formatAmount(p.amount)} USDC</span>
290+
{p.pending && (
291+
<span className="inline-flex items-center gap-1 text-xs text-amber-300 bg-amber-950/60 px-2 py-0.5 rounded-full">
292+
<span
293+
className="inline-block h-3 w-3 rounded-full border-2 border-amber-300 border-t-transparent animate-spin"
294+
aria-hidden
295+
/>
296+
Confirming…
297+
</span>
298+
)}
299+
</div>
300+
</li>
301+
))}
302+
</ul>
303+
)}
304+
</section>
305+
207306
{/* Recipients */}
208307
<section aria-labelledby="recipients-heading" className="mb-8">
209308
<h2 id="recipients-heading" className="text-lg font-semibold mb-3">Recipients</h2>

src/app/layout.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
1-
import type { Metadata } from "next";
1+
import type { Metadata, Viewport } from "next";
2+
import Script from "next/script";
23
import "./globals.css";
34
import NotificationCenter from "@/components/NotificationCenter";
45

56
export const metadata: Metadata = {
67
title: "StellarSplit — On-chain Invoice Splitting",
78
description:
89
"Create on-chain invoices on Stellar where multiple payers each owe a share. USDC auto-routes to recipients when fully funded.",
10+
manifest: "/manifest.json",
11+
appleWebApp: {
12+
capable: true,
13+
statusBarStyle: "black-translucent",
14+
title: "StellarSplit",
15+
},
16+
icons: {
17+
icon: "/icons/icon-192.png",
18+
apple: "/icons/icon-192.png",
19+
},
20+
};
21+
22+
export const viewport: Viewport = {
23+
themeColor: "#4f46e5",
24+
width: "device-width",
25+
initialScale: 1,
926
};
1027

1128
export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -27,6 +44,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
2744
</div>
2845
</header>
2946
{children}
47+
<Script id="register-sw" strategy="afterInteractive">
48+
{`if ("serviceWorker" in navigator) {
49+
window.addEventListener("load", function () {
50+
navigator.serviceWorker.register("/sw.js");
51+
});
52+
}`}
53+
</Script>
3054
</body>
3155
</html>
3256
);

0 commit comments

Comments
 (0)