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.
Files changed (133) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.js +11113 -0
  3. package/dist/templates/base/.env.example +5 -0
  4. package/dist/templates/base/app/demo/page.tsx +25 -0
  5. package/dist/templates/base/app/globals.css +95 -0
  6. package/dist/templates/base/app/layout.tsx +39 -0
  7. package/dist/templates/base/app/page.tsx +83 -0
  8. package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
  9. package/dist/templates/base/components/providers.tsx +63 -0
  10. package/dist/templates/base/env.ts +10 -0
  11. package/dist/templates/base/hooks/useFediInternal.ts +41 -0
  12. package/dist/templates/base/lib/fedi-types.ts +96 -0
  13. package/dist/templates/base/lib/fedi.ts +18 -0
  14. package/dist/templates/base/lib/nostr/hooks.ts +52 -0
  15. package/dist/templates/base/lib/nostr/index.ts +9 -0
  16. package/dist/templates/base/lib/nostr/mock.ts +60 -0
  17. package/dist/templates/base/lib/nostr/provider.tsx +64 -0
  18. package/dist/templates/base/lib/utils.ts +3 -0
  19. package/dist/templates/base/lib/webln/hooks.ts +67 -0
  20. package/dist/templates/base/lib/webln/index.ts +12 -0
  21. package/dist/templates/base/lib/webln/mock.ts +96 -0
  22. package/dist/templates/base/lib/webln/provider.tsx +52 -0
  23. package/dist/templates/base/next.config.ts +3 -0
  24. package/dist/templates/base/package.json +40 -0
  25. package/dist/templates/base/proxy.ts +8 -0
  26. package/dist/templates/base/tsconfig.json +20 -0
  27. package/dist/templates/base/vitest.config.ts +6 -0
  28. package/dist/templates/base/vitest.setup.ts +40 -0
  29. package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
  30. package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
  31. package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
  32. package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
  33. package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
  34. package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
  35. package/dist/templates/modules/ai-assistant/module.json +48 -0
  36. package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
  37. package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
  38. package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
  39. package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
  40. package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
  41. package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
  42. package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
  43. package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
  44. package/dist/templates/modules/ai-chat-gated/module.json +62 -0
  45. package/dist/templates/modules/ai-rules/.cursorrules +8 -0
  46. package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
  47. package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
  48. package/dist/templates/modules/ai-rules/module.json +20 -0
  49. package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
  50. package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
  51. package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
  52. package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
  53. package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
  54. package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
  55. package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
  56. package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
  57. package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
  58. package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
  59. package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
  60. package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
  61. package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
  62. package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
  63. package/dist/templates/modules/database/env.supabase.ts +24 -0
  64. package/dist/templates/modules/database/env.turso.ts +23 -0
  65. package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
  66. package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
  67. package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
  68. package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
  69. package/dist/templates/modules/database/module.json +110 -0
  70. package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
  71. package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
  72. package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
  73. package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
  74. package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
  75. package/dist/templates/modules/ecash-balance/module.json +14 -0
  76. package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
  77. package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
  78. package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
  79. package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
  80. package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
  81. package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
  82. package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
  83. package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
  84. package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
  85. package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
  86. package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
  87. package/dist/templates/modules/lnurl/module.json +27 -0
  88. package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
  89. package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
  90. package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
  91. package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
  92. package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
  93. package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
  94. package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
  95. package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
  96. package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
  97. package/dist/templates/modules/multispend-demo/module.json +18 -0
  98. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
  99. package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
  100. package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
  101. package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
  102. package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
  103. package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
  104. package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
  105. package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
  106. package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
  107. package/dist/templates/modules/nostr-feed/module.json +25 -0
  108. package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
  109. package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
  110. package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
  111. package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
  112. package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
  113. package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
  114. package/dist/templates/modules/nostr-identity/module.json +15 -0
  115. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
  116. package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
  117. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
  118. package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
  119. package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
  120. package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
  121. package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
  122. package/dist/templates/modules/payment-gated-content/module.json +24 -0
  123. package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
  124. package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
  125. package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
  126. package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
  127. package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
  128. package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
  129. package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
  130. package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
  131. package/dist/templates/modules/webln-payments/module.json +17 -0
  132. package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
  133. 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
+ }