Skip to content

Commit 0ae5332

Browse files
author
Fernando Ledesma
committed
added mdk-nextjs example
0 parents  commit 0ae5332

19 files changed

Lines changed: 13103 additions & 0 deletions

.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
node_modules
2+
npm-debug.log*
3+
yarn-debug.log*
4+
yarn-error.log*
5+
pnpm-debug.log*
6+
.deno
7+
.tmp
8+
.temp
9+
.cache
10+
.next
11+
dist
12+
coverage
13+
.DS_Store
14+
.env
15+
.env.*
16+
!.env.example
17+
.eslintcache
18+
*.tsbuildinfo
19+
.idea
20+
.vscode
21+
*.tgz
22+
.secrets
23+
.envrc
24+
.direnv

mdk-nextjs-demo/.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
MDK_ACCESS_TOKEN=your_api_key
2+
MDK_MNEMONIC=your_mnemonic_words
3+
4+
# Optional overrides
5+
# MDK_API_BASE_URL=
6+
# MDK_NETWORK=mainnet
7+
# MDK_VSS_URL=
8+
# MDK_ESPLORA_URL=
9+
# MDK_RGS_URL=
10+
# MDK_LSP_NODE_ID=
11+
# MDK_LSP_ADDRESS=
12+
# WITHDRAWAL_BOLT_11=
13+
# WITHDRAWAL_BOLT_12=
14+
# WITHDRAWAL_LNURL=

mdk-nextjs-demo/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.vercel
2+
.env*.local

mdk-nextjs-demo/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# MDK Next.js Vercel Demo
2+
3+
A minimal App Router project that exercises `@moneydevkit/nextjs` on Vercel:
4+
5+
- `/` – launch a checkout with `useCheckout()`
6+
- `/checkout/[id]` – render the hosted checkout component
7+
- `/checkout/success` – verify payment with `useCheckoutSuccess()`
8+
- `/api/mdk` – unified Money Dev Kit endpoint
9+
10+
## Run locally
11+
1. Copy `.env.example` to `.env.local` and fill in your Money Dev Kit credentials.
12+
2. Install dependencies and run dev server:
13+
```bash
14+
npm install
15+
npm run dev
16+
```
17+
3. The button on the home page creates a checkout and redirects to `/checkout/<id>`.
18+
19+
## Deploy with Vercel CLI
20+
```bash
21+
npx vercel pull --yes --environment=preview --cwd=examples/mdk-nextjs-demo
22+
npx vercel build --cwd=examples/mdk-nextjs-demo
23+
npx vercel deploy --prebuilt --cwd=examples/mdk-nextjs-demo
24+
```
25+
26+
Make sure your Vercel project has the MDK_* secrets configured so the checkout can be created.
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { NextResponse } from "next/server";
2+
import crypto from "node:crypto";
3+
4+
type MockInvoice = {
5+
invoice: string;
6+
amountSats: number;
7+
amountSatsReceived?: number;
8+
expiresAt: string;
9+
fiatAmount: number;
10+
btcPrice: number;
11+
};
12+
13+
type MockCheckout = {
14+
id: string;
15+
status: "PENDING_PAYMENT" | "PAYMENT_RECEIVED";
16+
currency: "USD" | "SAT";
17+
successUrl?: string;
18+
userMetadata?: Record<string, unknown>;
19+
invoiceAmountSats: number;
20+
invoice?: MockInvoice;
21+
};
22+
23+
type MockState = {
24+
checkout: MockCheckout | null;
25+
log: string[];
26+
};
27+
28+
type GlobalForMock = {
29+
mockState?: MockState;
30+
}
31+
32+
// Persist state across Next.js module reloads in development mode
33+
const globalForMock = globalThis as GlobalForMock;
34+
35+
const mockState: MockState = globalForMock.mockState ?? {
36+
checkout: null,
37+
log: [],
38+
};
39+
40+
globalForMock.mockState = mockState;
41+
42+
function nowPlusMinutes(minutes: number) {
43+
return new Date(Date.now() + minutes * 60 * 1000).toISOString();
44+
}
45+
46+
function buildMockInvoice(amountSats: number): MockInvoice {
47+
const fiatAmount = amountSats / 10; // Pretend 10 sats = $1 (only for display)
48+
return {
49+
invoice: `lnmock${crypto.randomBytes(16).toString("hex")}`,
50+
amountSats,
51+
amountSatsReceived: 0,
52+
expiresAt: nowPlusMinutes(10),
53+
fiatAmount,
54+
btcPrice: 68000,
55+
};
56+
}
57+
58+
function createMockCheckout(params: {
59+
amount: number;
60+
currency?: "USD" | "SAT";
61+
metadata?: Record<string, unknown>;
62+
successUrl?: string;
63+
}): MockCheckout {
64+
const amountSats = params.currency === "SAT" ? params.amount : Math.max(50_000, params.amount * 4);
65+
return {
66+
id: `mock-${Date.now()}`,
67+
status: "PENDING_PAYMENT",
68+
currency: params.currency ?? "USD",
69+
successUrl: params.successUrl ?? "/checkout/success",
70+
invoiceAmountSats: amountSats,
71+
invoice: buildMockInvoice(amountSats),
72+
userMetadata: params.metadata,
73+
};
74+
}
75+
76+
function setCheckout(checkout: MockCheckout) {
77+
mockState.checkout = checkout;
78+
mockState.log = ["client.create", "client.confirm", "client.registerInvoice"];
79+
}
80+
81+
function markPaid() {
82+
if (!mockState.checkout) return;
83+
mockState.checkout.status = "PAYMENT_RECEIVED";
84+
if (mockState.checkout.invoice) {
85+
mockState.checkout.invoice.amountSatsReceived = mockState.checkout.invoice.amountSats;
86+
}
87+
}
88+
89+
function handlerFromBody(body: unknown): string | null {
90+
if (!body || typeof body !== "object") return null;
91+
const maybe = (body as any).handler ?? (body as any).route ?? (body as any).target;
92+
return typeof maybe === "string" ? maybe.toLowerCase() : null;
93+
}
94+
95+
export async function POST(request: Request) {
96+
let body: any = null;
97+
try {
98+
body = await request.json();
99+
} catch {
100+
// ignore, some callers (Cypress webhook) might send empty bodies
101+
}
102+
103+
const handler = handlerFromBody(body);
104+
105+
if (handler === "create_checkout") {
106+
const params = body?.params ?? {};
107+
const checkout = createMockCheckout({
108+
amount: Number(params.amount) || 2500,
109+
currency: params.currency === "SAT" ? "SAT" : "USD",
110+
metadata: params.metadata ?? params,
111+
successUrl: params.successUrl,
112+
});
113+
setCheckout(checkout);
114+
return NextResponse.json({ data: checkout });
115+
}
116+
117+
if (handler === "get_checkout") {
118+
const checkoutId: string | undefined = body?.checkoutId;
119+
if (!mockState.checkout || !checkoutId || mockState.checkout.id !== checkoutId) {
120+
return NextResponse.json({ error: "Checkout not found" }, { status: 404 });
121+
}
122+
mockState.log.push("client.get");
123+
return NextResponse.json({ data: mockState.checkout });
124+
}
125+
126+
if (handler === "confirm_checkout") {
127+
if (!mockState.checkout) {
128+
return NextResponse.json({ error: "No checkout to confirm" }, { status: 404 });
129+
}
130+
return NextResponse.json({ data: mockState.checkout });
131+
}
132+
133+
if (handler === "webhook" || handler === "webhooks") {
134+
markPaid();
135+
mockState.log.push("webhook.incoming-payment");
136+
return NextResponse.json({ ok: true });
137+
}
138+
139+
if (handler === "ping") {
140+
return NextResponse.json({ ok: true });
141+
}
142+
143+
return NextResponse.json({ error: "Unsupported handler" }, { status: 400 });
144+
}
145+
146+
export async function GET() {
147+
if (!mockState.checkout) {
148+
return NextResponse.json({ status: "NO_CHECKOUT", log: mockState.log });
149+
}
150+
return NextResponse.json({
151+
status: mockState.checkout.status,
152+
log: mockState.log,
153+
checkout: mockState.checkout,
154+
});
155+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { POST } from "@moneydevkit/nextjs/server/route";
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"use client";
2+
3+
import { Checkout } from "@moneydevkit/nextjs";
4+
import Link from "next/link";
5+
import { use } from "react";
6+
7+
export default function CheckoutPage({
8+
params,
9+
}: {
10+
params: Promise<{ id: string }>;
11+
}) {
12+
const { id } = use(params);
13+
14+
return (
15+
<main className="page">
16+
<div className="container narrow">
17+
<div className="card">
18+
<p className="eyebrow">Demo checkout</p>
19+
<h1 className="hero-title">Complete your purchase</h1>
20+
<p className="subtext">
21+
The Money Dev Kit checkout widget is rendered directly in this page. Use the buttons
22+
below the QR to copy the invoice or open a Lightning wallet.
23+
</p>
24+
<div className="checkout-wrapper" data-test="checkout-shell">
25+
<Checkout id={id} />
26+
</div>
27+
<p className="muted center">
28+
Need to adjust the metadata? <Link href="/">Start again</Link>.
29+
</p>
30+
</div>
31+
</div>
32+
</main>
33+
);
34+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client';
2+
3+
import { useCheckoutSuccess } from "@moneydevkit/nextjs";
4+
import Link from "next/link";
5+
import { Suspense } from "react";
6+
7+
function SuccessContent() {
8+
const { isCheckoutPaid, isCheckoutPaidLoading, metadata } = useCheckoutSuccess();
9+
10+
const customerName =
11+
typeof metadata?.customerName === "string" && metadata.customerName.trim().length > 0
12+
? metadata.customerName.trim()
13+
: "there";
14+
const note =
15+
typeof metadata?.note === "string" && metadata.note.trim().length > 0
16+
? metadata.note.trim()
17+
: "your order";
18+
19+
return (
20+
<>
21+
<p className="eyebrow">Payment status</p>
22+
<h1 className="hero-title">Thanks, {customerName}!</h1>
23+
24+
{isCheckoutPaidLoading || isCheckoutPaid === null ? (
25+
<p className="subtext">We&apos;re verifying the payment with Money Dev Kit…</p>
26+
) : isCheckoutPaid ? (
27+
<div className="stack">
28+
<p className="lead">Payment confirmed. We&apos;ll start preparing {note}.</p>
29+
<div className="pill">
30+
<span role="img" aria-label="spark"></span>
31+
<span>Checkout metadata is available for provisioning.</span>
32+
</div>
33+
</div>
34+
) : (
35+
<div className="stack">
36+
<p className="lead">We haven&apos;t seen the payment yet.</p>
37+
<p className="muted">If you paid recently, wait a moment and refresh this page.</p>
38+
</div>
39+
)}
40+
41+
<div className="stack" style={{ marginTop: "1.25rem" }}>
42+
<div className="badge">Metadata</div>
43+
<pre
44+
style={{
45+
background: "rgba(255,255,255,0.04)",
46+
padding: "1rem",
47+
borderRadius: "12px",
48+
border: "1px solid rgba(255,255,255,0.05)",
49+
overflowX: "auto",
50+
}}
51+
>
52+
{JSON.stringify(metadata, null, 2)}
53+
</pre>
54+
<Link href="/" className="button" style={{ width: "fit-content" }}>
55+
Back to start
56+
</Link>
57+
</div>
58+
</>
59+
);
60+
}
61+
62+
export default function SuccessPage() {
63+
return (
64+
<main className="page">
65+
<div className="container narrow">
66+
<div className="card">
67+
<Suspense fallback={<p className="subtext">Loading payment status…</p>}>
68+
<SuccessContent />
69+
</Suspense>
70+
</div>
71+
</div>
72+
</main>
73+
);
74+
}

0 commit comments

Comments
 (0)