create-fedi-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +11113 -0
- package/dist/templates/base/.env.example +5 -0
- package/dist/templates/base/app/demo/page.tsx +25 -0
- package/dist/templates/base/app/globals.css +95 -0
- package/dist/templates/base/app/layout.tsx +39 -0
- package/dist/templates/base/app/page.tsx +83 -0
- package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
- package/dist/templates/base/components/providers.tsx +63 -0
- package/dist/templates/base/env.ts +10 -0
- package/dist/templates/base/hooks/useFediInternal.ts +41 -0
- package/dist/templates/base/lib/fedi-types.ts +96 -0
- package/dist/templates/base/lib/fedi.ts +18 -0
- package/dist/templates/base/lib/nostr/hooks.ts +52 -0
- package/dist/templates/base/lib/nostr/index.ts +9 -0
- package/dist/templates/base/lib/nostr/mock.ts +60 -0
- package/dist/templates/base/lib/nostr/provider.tsx +64 -0
- package/dist/templates/base/lib/utils.ts +3 -0
- package/dist/templates/base/lib/webln/hooks.ts +67 -0
- package/dist/templates/base/lib/webln/index.ts +12 -0
- package/dist/templates/base/lib/webln/mock.ts +96 -0
- package/dist/templates/base/lib/webln/provider.tsx +52 -0
- package/dist/templates/base/next.config.ts +3 -0
- package/dist/templates/base/package.json +40 -0
- package/dist/templates/base/proxy.ts +8 -0
- package/dist/templates/base/tsconfig.json +20 -0
- package/dist/templates/base/vitest.config.ts +6 -0
- package/dist/templates/base/vitest.setup.ts +40 -0
- package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
- package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
- package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
- package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-assistant/module.json +48 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
- package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
- package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
- package/dist/templates/modules/ai-chat-gated/module.json +62 -0
- package/dist/templates/modules/ai-rules/.cursorrules +8 -0
- package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
- package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
- package/dist/templates/modules/ai-rules/module.json +20 -0
- package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
- package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
- package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
- package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
- package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
- package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
- package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
- package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
- package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
- package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
- package/dist/templates/modules/database/env.supabase.ts +24 -0
- package/dist/templates/modules/database/env.turso.ts +23 -0
- package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
- package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
- package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
- package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
- package/dist/templates/modules/database/module.json +110 -0
- package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
- package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
- package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
- package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
- package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
- package/dist/templates/modules/ecash-balance/module.json +14 -0
- package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
- package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
- package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
- package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
- package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
- package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
- package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
- package/dist/templates/modules/lnurl/module.json +27 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
- package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
- package/dist/templates/modules/multispend-demo/module.json +18 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
- package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
- package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
- package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
- package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
- package/dist/templates/modules/nostr-feed/module.json +25 -0
- package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
- package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
- package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
- package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
- package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
- package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
- package/dist/templates/modules/nostr-identity/module.json +15 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
- package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
- package/dist/templates/modules/payment-gated-content/module.json +24 -0
- package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
- package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
- package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
- package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
- package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
- package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
- package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
- package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
- package/dist/templates/modules/webln-payments/module.json +17 -0
- package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
- package/package.json +29 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useIdentity } from '../lib/nostr';
|
|
5
|
+
import type { NostrEvent } from '../lib/nostr';
|
|
6
|
+
|
|
7
|
+
export function useIdentityFlow() {
|
|
8
|
+
const { pubkey, npub, displayNpub, getPublicKey, signEvent, isConnecting } = useIdentity();
|
|
9
|
+
const [localPubkey, setLocalPubkey] = useState<string | null>(null);
|
|
10
|
+
const [lastSignedEvent, setLastSignedEvent] = useState<NostrEvent | null>(null);
|
|
11
|
+
const [signError, setSignError] = useState<Error | null>(null);
|
|
12
|
+
const [isConnectingLocal, setIsConnectingLocal] = useState(false);
|
|
13
|
+
|
|
14
|
+
const activePubkey = pubkey ?? localPubkey;
|
|
15
|
+
|
|
16
|
+
async function connect() {
|
|
17
|
+
setIsConnectingLocal(true);
|
|
18
|
+
try {
|
|
19
|
+
const pk = await getPublicKey();
|
|
20
|
+
if (pk) setLocalPubkey(pk);
|
|
21
|
+
return pk;
|
|
22
|
+
} finally {
|
|
23
|
+
setIsConnectingLocal(false);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function signTextNote(content: string): Promise<NostrEvent | null> {
|
|
28
|
+
setSignError(null);
|
|
29
|
+
const signingPubkey = activePubkey ?? (await connect());
|
|
30
|
+
if (!signingPubkey) {
|
|
31
|
+
setSignError(new Error('Connect your Nostr identity first'));
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const event = await signEvent({
|
|
37
|
+
kind: 1,
|
|
38
|
+
content,
|
|
39
|
+
tags: [],
|
|
40
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
41
|
+
});
|
|
42
|
+
if (event) setLastSignedEvent(event);
|
|
43
|
+
return event;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setSignError(err instanceof Error ? err : new Error('Sign failed'));
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
pubkey: activePubkey,
|
|
52
|
+
npub,
|
|
53
|
+
displayNpub,
|
|
54
|
+
isConnected: !!activePubkey,
|
|
55
|
+
isConnecting: isConnecting || isConnectingLocal,
|
|
56
|
+
connect,
|
|
57
|
+
signTextNote,
|
|
58
|
+
lastSignedEvent,
|
|
59
|
+
signError,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { bech32 } from '@scure/base';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Derives a stable HSL color from a hex pubkey using a simple string hash.
|
|
5
|
+
*/
|
|
6
|
+
export function pubkeyToHsl(pubkey: string): { h: number; s: number; l: number } {
|
|
7
|
+
let hash = 0;
|
|
8
|
+
for (let i = 0; i < pubkey.length; i++) {
|
|
9
|
+
hash = pubkey.charCodeAt(i) + ((hash << 5) - hash);
|
|
10
|
+
}
|
|
11
|
+
return {
|
|
12
|
+
h: Math.abs(hash) % 360,
|
|
13
|
+
s: 60,
|
|
14
|
+
l: 50,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Converts a hex-encoded pubkey to bech32 npub format. */
|
|
19
|
+
export function pubkeyToNpub(hex: string): string {
|
|
20
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
21
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
22
|
+
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
|
|
23
|
+
}
|
|
24
|
+
return bech32.encodeFromBytes('npub', bytes);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Truncated npub for compact display, e.g. `npub1abc...xyz4`. */
|
|
28
|
+
export function truncateNpub(npub: string): string {
|
|
29
|
+
return npub.slice(0, 8) + '...' + npub.slice(-4);
|
|
30
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nostr-identity",
|
|
3
|
+
"description": "NIP-07 Nostr identity connection and signed message demos",
|
|
4
|
+
"dependencies": [],
|
|
5
|
+
"devDependencies": [],
|
|
6
|
+
"files": [
|
|
7
|
+
{ "src": "components/nostr/IdentityBadge.tsx", "dest": "components/nostr/IdentityBadge.tsx", "merge": "add" },
|
|
8
|
+
{ "src": "components/nostr/SignedMessage.tsx", "dest": "components/nostr/SignedMessage.tsx", "merge": "add" },
|
|
9
|
+
{ "src": "components/nostr/NostrLogin.tsx", "dest": "components/nostr/NostrLogin.tsx", "merge": "add" },
|
|
10
|
+
{ "src": "hooks/useIdentityFlow.ts", "dest": "hooks/useIdentityFlow.ts", "merge": "add" },
|
|
11
|
+
{ "src": "lib/nostr-utils.ts", "dest": "lib/nostr-utils.ts", "merge": "add" },
|
|
12
|
+
{ "src": "app/demo/nostr/page.tsx", "dest": "app/demo/nostr/page.tsx", "merge": "add" }
|
|
13
|
+
],
|
|
14
|
+
"envVars": []
|
|
15
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { generateInvoice } from '../../../../lib/payment-gate';
|
|
3
|
+
|
|
4
|
+
export async function POST(request: Request) {
|
|
5
|
+
let body: { contentId?: string; amountSats?: number; memo?: string };
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
body = (await request.json()) as typeof body;
|
|
9
|
+
} catch {
|
|
10
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { contentId, amountSats, memo } = body;
|
|
14
|
+
|
|
15
|
+
if (!contentId || typeof contentId !== 'string') {
|
|
16
|
+
return NextResponse.json({ error: 'contentId is required' }, { status: 400 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!amountSats || typeof amountSats !== 'number' || amountSats < 1) {
|
|
20
|
+
return NextResponse.json({ error: 'amountSats must be a positive number' }, { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const invoice = await generateInvoice({ contentId, amountSats, memo });
|
|
24
|
+
return NextResponse.json(invoice);
|
|
25
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import { setPaymentCookie, verifyPayment } from '../../../../lib/payment-gate';
|
|
3
|
+
|
|
4
|
+
export async function POST(request: Request) {
|
|
5
|
+
let body: { paymentId?: string; preimage?: string; contentId?: string };
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
body = (await request.json()) as typeof body;
|
|
9
|
+
} catch {
|
|
10
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { paymentId, preimage, contentId } = body;
|
|
14
|
+
|
|
15
|
+
if (!paymentId || !preimage || !contentId) {
|
|
16
|
+
return NextResponse.json(
|
|
17
|
+
{ error: 'paymentId, preimage, and contentId are required' },
|
|
18
|
+
{ status: 400 },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const result = await verifyPayment(paymentId, preimage);
|
|
23
|
+
if (!result.valid) {
|
|
24
|
+
return NextResponse.json({ error: result.reason }, { status: 402 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (result.record.contentId !== contentId) {
|
|
28
|
+
return NextResponse.json({ error: 'Content mismatch' }, { status: 403 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const response = NextResponse.json({
|
|
32
|
+
ok: true,
|
|
33
|
+
contentId: result.record.contentId,
|
|
34
|
+
paidAt: result.record.paidAt,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
setPaymentCookie(response, contentId, paymentId);
|
|
38
|
+
return response;
|
|
39
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { cookies } from 'next/headers';
|
|
4
|
+
import { checkPaymentAccess } from '../../../../lib/payment-gate';
|
|
5
|
+
import { redirect } from 'next/navigation';
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: 'Gated Article',
|
|
9
|
+
description:
|
|
10
|
+
'Proxy-protected article route for the payment-gated content demo. Requires a valid payment cookie set after Lightning verification.',
|
|
11
|
+
openGraph: {
|
|
12
|
+
title: 'Gated Article',
|
|
13
|
+
description:
|
|
14
|
+
'Proxy-protected article route for the payment-gated content demo. Requires a valid payment cookie set after Lightning verification.',
|
|
15
|
+
},
|
|
16
|
+
twitter: {
|
|
17
|
+
card: 'summary',
|
|
18
|
+
title: 'Gated Article',
|
|
19
|
+
description:
|
|
20
|
+
'Proxy-protected article route for the payment-gated content demo. Requires a valid payment cookie set after Lightning verification.',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CONTENT_ID = 'demo-article';
|
|
25
|
+
|
|
26
|
+
export default async function ProtectedArticlePage() {
|
|
27
|
+
const cookieStore = await cookies();
|
|
28
|
+
const hasAccess = await checkPaymentAccess(cookieStore, CONTENT_ID);
|
|
29
|
+
|
|
30
|
+
if (!hasAccess) {
|
|
31
|
+
redirect('/demo/payment-gated?redirect=/demo/payment-gated/article');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
36
|
+
<div
|
|
37
|
+
className="mx-auto w-full max-w-[390px] px-4 pt-6"
|
|
38
|
+
style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
|
|
39
|
+
>
|
|
40
|
+
<Link
|
|
41
|
+
href="/demo/payment-gated"
|
|
42
|
+
className="mb-6 inline-block text-xs text-[var(--color-text-muted)] transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
43
|
+
>
|
|
44
|
+
← back to demo
|
|
45
|
+
</Link>
|
|
46
|
+
|
|
47
|
+
<header className="mb-6 space-y-2">
|
|
48
|
+
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
|
|
49
|
+
Proxy-protected route
|
|
50
|
+
</h1>
|
|
51
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
52
|
+
You reached this page because <code className="font-mono text-xs">proxy.ts</code> found a
|
|
53
|
+
valid payment cookie. Without it, you would have been redirected to the paywall.
|
|
54
|
+
</p>
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<article className="space-y-3 text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
58
|
+
<p>
|
|
59
|
+
This route demonstrates network-boundary enforcement. Even if someone removes the paywall
|
|
60
|
+
component from the client bundle, the proxy still blocks unauthenticated requests to
|
|
61
|
+
protected paths.
|
|
62
|
+
</p>
|
|
63
|
+
<p>
|
|
64
|
+
In production, point protected routes at real premium content: PDF downloads, API
|
|
65
|
+
handlers, or server-rendered pages that must never leak without payment proof.
|
|
66
|
+
</p>
|
|
67
|
+
</article>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import Link from 'next/link';
|
|
3
|
+
import { cookies } from 'next/headers';
|
|
4
|
+
import { PayGate } from '../../../components/payment-gated/PayGate';
|
|
5
|
+
import { checkPaymentAccess } from '../../../lib/payment-gate';
|
|
6
|
+
|
|
7
|
+
export const metadata: Metadata = {
|
|
8
|
+
title: 'Payment Gate',
|
|
9
|
+
description:
|
|
10
|
+
'Demo of Lightning paywalled content: preview an article for free, pay sats to unlock the full text, and keep access via a signed payment cookie.',
|
|
11
|
+
openGraph: {
|
|
12
|
+
title: 'Payment Gate',
|
|
13
|
+
description:
|
|
14
|
+
'Demo of Lightning paywalled content: preview an article for free, pay sats to unlock the full text, and keep access via a signed payment cookie.',
|
|
15
|
+
},
|
|
16
|
+
twitter: {
|
|
17
|
+
card: 'summary',
|
|
18
|
+
title: 'Payment Gate',
|
|
19
|
+
description:
|
|
20
|
+
'Demo of Lightning paywalled content: preview an article for free, pay sats to unlock the full text, and keep access via a signed payment cookie.',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CONTENT_ID = 'demo-article';
|
|
25
|
+
const PRICE_SATS = 50;
|
|
26
|
+
|
|
27
|
+
const ARTICLE_PREVIEW = (
|
|
28
|
+
<article className="space-y-3 px-1 py-2">
|
|
29
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold text-[var(--color-text)]">
|
|
30
|
+
Why sats unlock content
|
|
31
|
+
</h2>
|
|
32
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
33
|
+
Lightning payments settle in seconds with cryptographic proof. A preimage returned from{' '}
|
|
34
|
+
<code className="font-mono text-xs">sendPayment()</code> is enough for your server to grant
|
|
35
|
+
access without accounts or passwords.
|
|
36
|
+
</p>
|
|
37
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
38
|
+
This preview shows the opening paragraphs. The rest of the article covers cookie design,
|
|
39
|
+
proxy checks at the network boundary, and production hardening tips…
|
|
40
|
+
</p>
|
|
41
|
+
</article>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const ARTICLE_FULL = (
|
|
45
|
+
<article className="space-y-4">
|
|
46
|
+
<h2 className="font-[family-name:var(--font-display)] text-xl font-semibold text-[var(--color-text)]">
|
|
47
|
+
Why sats unlock content
|
|
48
|
+
</h2>
|
|
49
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
50
|
+
Lightning payments settle in seconds with cryptographic proof. A preimage returned from{' '}
|
|
51
|
+
<code className="font-mono text-xs">sendPayment()</code> is enough for your server to grant
|
|
52
|
+
access without accounts or passwords.
|
|
53
|
+
</p>
|
|
54
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
55
|
+
In this template, your app generates a BOLT11 invoice on the server and stores the expected
|
|
56
|
+
preimage alongside the pending payment. When the user pays, they submit that preimage to{' '}
|
|
57
|
+
<code className="font-mono text-xs">/api/payment-gate/verify</code>. The server marks the
|
|
58
|
+
invoice paid and sets an HttpOnly cookie signed with HMAC.
|
|
59
|
+
</p>
|
|
60
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
61
|
+
<code className="font-mono text-xs">proxy.ts</code> runs before protected routes such as{' '}
|
|
62
|
+
<code className="font-mono text-xs">/demo/payment-gated/article</code>. If the cookie is
|
|
63
|
+
missing or invalid, the request is redirected back here. That keeps direct URL access gated
|
|
64
|
+
even when someone bypasses the React paywall UI.
|
|
65
|
+
</p>
|
|
66
|
+
<p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
67
|
+
For production, replace the in-memory store with your database module, connect a real Lightning
|
|
68
|
+
node for invoice creation, and rotate <code className="font-mono text-xs">PAYMENT_GATE_SECRET</code>{' '}
|
|
69
|
+
via Doppler. The client flow stays the same.
|
|
70
|
+
</p>
|
|
71
|
+
<Link
|
|
72
|
+
href="/demo/payment-gated/article"
|
|
73
|
+
className="inline-block text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
74
|
+
style={{ color: 'var(--color-accent)' }}
|
|
75
|
+
>
|
|
76
|
+
Open proxy-protected route →
|
|
77
|
+
</Link>
|
|
78
|
+
</article>
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export default async function PaymentGatedDemoPage() {
|
|
82
|
+
const cookieStore = await cookies();
|
|
83
|
+
const hasAccess = await checkPaymentAccess(cookieStore, CONTENT_ID);
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
|
|
87
|
+
<div
|
|
88
|
+
className="mx-auto w-full max-w-[390px] px-4 pt-6"
|
|
89
|
+
style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
|
|
90
|
+
>
|
|
91
|
+
<Link
|
|
92
|
+
href="/demo"
|
|
93
|
+
className="mb-6 inline-block text-xs text-[var(--color-text-muted)] transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
94
|
+
>
|
|
95
|
+
← back
|
|
96
|
+
</Link>
|
|
97
|
+
|
|
98
|
+
<header className="mb-8 space-y-2">
|
|
99
|
+
<h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
|
|
100
|
+
Payment-gated content
|
|
101
|
+
</h1>
|
|
102
|
+
<p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
|
|
103
|
+
Lock articles, downloads, or API responses behind a one-time Lightning payment. Access
|
|
104
|
+
persists in a signed cookie after verification.
|
|
105
|
+
</p>
|
|
106
|
+
</header>
|
|
107
|
+
|
|
108
|
+
{hasAccess ? (
|
|
109
|
+
<div className="space-y-4">
|
|
110
|
+
<div
|
|
111
|
+
className="rounded-lg px-3 py-2 text-xs font-semibold"
|
|
112
|
+
style={{
|
|
113
|
+
background: 'var(--color-accent-dim)',
|
|
114
|
+
color: 'var(--color-accent)',
|
|
115
|
+
borderRadius: 'var(--radius-md)',
|
|
116
|
+
}}
|
|
117
|
+
role="status"
|
|
118
|
+
>
|
|
119
|
+
Unlocked: payment verified
|
|
120
|
+
</div>
|
|
121
|
+
{ARTICLE_FULL}
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<PayGate
|
|
125
|
+
contentId={CONTENT_ID}
|
|
126
|
+
priceSats={PRICE_SATS}
|
|
127
|
+
memo="Unlock demo article"
|
|
128
|
+
preview={ARTICLE_PREVIEW}
|
|
129
|
+
/>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
5
|
+
import { PayButton } from '../webln/PayButton';
|
|
6
|
+
import { formatSats } from '../../lib/payment-history';
|
|
7
|
+
|
|
8
|
+
type TInvoiceResponse = {
|
|
9
|
+
paymentId: string;
|
|
10
|
+
invoice: string;
|
|
11
|
+
amountSats: number;
|
|
12
|
+
memo: string;
|
|
13
|
+
devPreimage?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
interface IPayGateProps {
|
|
17
|
+
contentId: string;
|
|
18
|
+
priceSats: number;
|
|
19
|
+
memo?: string;
|
|
20
|
+
preview: React.ReactNode;
|
|
21
|
+
onUnlocked?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type TGateStep = 'loading' | 'ready' | 'verifying' | 'error';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Paywall UI: blurred preview, server invoice QR, WebLN pay button, and refresh link.
|
|
28
|
+
*/
|
|
29
|
+
export function PayGate({
|
|
30
|
+
contentId,
|
|
31
|
+
priceSats,
|
|
32
|
+
memo,
|
|
33
|
+
preview,
|
|
34
|
+
onUnlocked,
|
|
35
|
+
}: IPayGateProps) {
|
|
36
|
+
const [step, setStep] = useState<TGateStep>('loading');
|
|
37
|
+
const [invoiceData, setInvoiceData] = useState<TInvoiceResponse | null>(null);
|
|
38
|
+
const [error, setError] = useState<string | null>(null);
|
|
39
|
+
const [copied, setCopied] = useState(false);
|
|
40
|
+
|
|
41
|
+
const verifyPayment = useCallback(
|
|
42
|
+
async (paymentId: string, preimage: string) => {
|
|
43
|
+
setStep('verifying');
|
|
44
|
+
setError(null);
|
|
45
|
+
|
|
46
|
+
const res = await fetch('/api/payment-gate/verify', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ paymentId, preimage, contentId }),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
const data = (await res.json()) as { error?: string };
|
|
54
|
+
setError(data.error ?? 'Verification failed');
|
|
55
|
+
setStep('error');
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
onUnlocked?.();
|
|
60
|
+
window.location.reload();
|
|
61
|
+
return true;
|
|
62
|
+
},
|
|
63
|
+
[contentId, onUnlocked],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
|
|
69
|
+
async function loadInvoice() {
|
|
70
|
+
setStep('loading');
|
|
71
|
+
setError(null);
|
|
72
|
+
|
|
73
|
+
const res = await fetch('/api/payment-gate/invoice', {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({
|
|
77
|
+
contentId,
|
|
78
|
+
amountSats: priceSats,
|
|
79
|
+
memo: memo ?? `Unlock content (${contentId})`,
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (cancelled) return;
|
|
84
|
+
|
|
85
|
+
if (!res.ok) {
|
|
86
|
+
setError('Could not create invoice');
|
|
87
|
+
setStep('error');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const data = (await res.json()) as TInvoiceResponse;
|
|
92
|
+
setInvoiceData(data);
|
|
93
|
+
setStep('ready');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
void loadInvoice();
|
|
97
|
+
return () => {
|
|
98
|
+
cancelled = true;
|
|
99
|
+
};
|
|
100
|
+
}, [contentId, priceSats, memo]);
|
|
101
|
+
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
if (
|
|
104
|
+
process.env.NODE_ENV !== 'development' ||
|
|
105
|
+
!invoiceData?.devPreimage ||
|
|
106
|
+
step !== 'ready'
|
|
107
|
+
) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const timer = window.setTimeout(() => {
|
|
112
|
+
void verifyPayment(invoiceData.paymentId, invoiceData.devPreimage!);
|
|
113
|
+
}, 5000);
|
|
114
|
+
|
|
115
|
+
return () => window.clearTimeout(timer);
|
|
116
|
+
}, [invoiceData, step, verifyPayment]);
|
|
117
|
+
|
|
118
|
+
async function handleCopy() {
|
|
119
|
+
if (!invoiceData?.invoice) return;
|
|
120
|
+
await navigator.clipboard.writeText(invoiceData.invoice);
|
|
121
|
+
setCopied(true);
|
|
122
|
+
window.setTimeout(() => setCopied(false), 2000);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleRefresh() {
|
|
126
|
+
window.location.reload();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<div className="space-y-6">
|
|
131
|
+
<div className="relative overflow-hidden rounded-xl" style={{ borderRadius: 'var(--radius-lg)' }}>
|
|
132
|
+
<div
|
|
133
|
+
className="pointer-events-none select-none blur-sm opacity-60"
|
|
134
|
+
aria-hidden
|
|
135
|
+
>
|
|
136
|
+
{preview}
|
|
137
|
+
</div>
|
|
138
|
+
<div
|
|
139
|
+
className="absolute inset-0 flex items-end justify-center bg-gradient-to-t from-[var(--color-bg)] via-[var(--color-bg)]/80 to-transparent px-4 pb-4 pt-16"
|
|
140
|
+
aria-hidden
|
|
141
|
+
/>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<div
|
|
145
|
+
className="rounded-xl p-4 space-y-4"
|
|
146
|
+
style={{
|
|
147
|
+
background: 'var(--color-surface-1)',
|
|
148
|
+
border: '1px solid var(--color-border)',
|
|
149
|
+
borderRadius: 'var(--radius-lg)',
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<div className="space-y-1">
|
|
153
|
+
<p className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
154
|
+
Unlock full article
|
|
155
|
+
</p>
|
|
156
|
+
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
|
|
157
|
+
Pay {formatSats(priceSats)} once to read the complete content. Your browser stores
|
|
158
|
+
proof of payment in a secure cookie.
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
{step === 'loading' && (
|
|
163
|
+
<p
|
|
164
|
+
className="text-xs font-mono"
|
|
165
|
+
style={{ color: 'var(--color-text-subtle)' }}
|
|
166
|
+
aria-live="polite"
|
|
167
|
+
>
|
|
168
|
+
Generating invoice…
|
|
169
|
+
</p>
|
|
170
|
+
)}
|
|
171
|
+
|
|
172
|
+
{step === 'verifying' && (
|
|
173
|
+
<p
|
|
174
|
+
className="text-xs font-mono"
|
|
175
|
+
style={{ color: 'var(--color-text-subtle)' }}
|
|
176
|
+
aria-live="polite"
|
|
177
|
+
>
|
|
178
|
+
Verifying payment…
|
|
179
|
+
</p>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{invoiceData && step !== 'loading' && (
|
|
183
|
+
<div
|
|
184
|
+
className="rounded-xl p-4 flex flex-col gap-3"
|
|
185
|
+
style={{
|
|
186
|
+
background: 'var(--color-surface-2)',
|
|
187
|
+
borderRadius: 'var(--radius-lg)',
|
|
188
|
+
}}
|
|
189
|
+
aria-label={`Invoice for ${formatSats(priceSats)}`}
|
|
190
|
+
>
|
|
191
|
+
<div className="flex items-center justify-between gap-2">
|
|
192
|
+
<span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
|
|
193
|
+
{formatSats(invoiceData.amountSats)}
|
|
194
|
+
</span>
|
|
195
|
+
{invoiceData.memo && (
|
|
196
|
+
<span
|
|
197
|
+
className="text-xs truncate max-w-[50%]"
|
|
198
|
+
style={{ color: 'var(--color-text-muted)' }}
|
|
199
|
+
>
|
|
200
|
+
{invoiceData.memo}
|
|
201
|
+
</span>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div
|
|
206
|
+
className="mx-auto flex w-full max-w-[160px] items-center justify-center rounded-lg p-3"
|
|
207
|
+
style={{ background: 'var(--color-surface-1)' }}
|
|
208
|
+
aria-label="Invoice QR code"
|
|
209
|
+
>
|
|
210
|
+
<QRCodeSVG
|
|
211
|
+
value={invoiceData.invoice}
|
|
212
|
+
size={136}
|
|
213
|
+
level="M"
|
|
214
|
+
bgColor="transparent"
|
|
215
|
+
fgColor="var(--color-text)"
|
|
216
|
+
/>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
|
|
220
|
+
{process.env.NODE_ENV === 'development'
|
|
221
|
+
? 'Simulated payment in 5 seconds (dev only)'
|
|
222
|
+
: 'Scan with a Lightning wallet or pay below'}
|
|
223
|
+
</p>
|
|
224
|
+
|
|
225
|
+
<button
|
|
226
|
+
type="button"
|
|
227
|
+
onClick={handleCopy}
|
|
228
|
+
className="w-full rounded-lg px-4 py-2 text-sm font-medium transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70"
|
|
229
|
+
style={{
|
|
230
|
+
background: 'var(--color-surface-1)',
|
|
231
|
+
color: 'var(--color-text)',
|
|
232
|
+
borderRadius: 'var(--radius-md)',
|
|
233
|
+
}}
|
|
234
|
+
aria-label={copied ? 'Invoice copied to clipboard' : 'Copy invoice to clipboard'}
|
|
235
|
+
>
|
|
236
|
+
{copied ? 'Copied!' : 'Copy invoice'}
|
|
237
|
+
</button>
|
|
238
|
+
|
|
239
|
+
<PayButton
|
|
240
|
+
invoice={invoiceData.invoice}
|
|
241
|
+
amountSats={invoiceData.amountSats}
|
|
242
|
+
memo={invoiceData.memo}
|
|
243
|
+
onSuccess={(preimage) => {
|
|
244
|
+
void verifyPayment(invoiceData.paymentId, preimage);
|
|
245
|
+
}}
|
|
246
|
+
/>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
|
|
250
|
+
{error && (
|
|
251
|
+
<p className="text-xs" style={{ color: 'var(--color-error, #ef4444)' }} role="alert">
|
|
252
|
+
{error}
|
|
253
|
+
</p>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={handleRefresh}
|
|
259
|
+
className="text-xs font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
|
|
260
|
+
style={{ color: 'var(--color-accent)' }}
|
|
261
|
+
>
|
|
262
|
+
Already paid? Refresh
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|