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,195 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+ import { NextRequest, NextResponse } from 'next/server';
3
+ import {
4
+ createPaymentRecord,
5
+ getPaymentById,
6
+ markPaymentPaid,
7
+ type TPaymentRecord,
8
+ } from './payment-store';
9
+
10
+ export const PAYMENT_COOKIE_NAME = 'fedi-payment-token';
11
+ export const PAYMENT_COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
12
+
13
+ export const PROTECTED_ROUTES = [
14
+ { path: '/demo/payment-gated/article', contentId: 'demo-article' },
15
+ ] as const;
16
+
17
+ type TCookieReader = {
18
+ get: (name: string) => { value: string } | undefined;
19
+ };
20
+
21
+ function getSigningSecret(): string {
22
+ return process.env.PAYMENT_GATE_SECRET ?? 'dev-payment-gate-secret';
23
+ }
24
+
25
+ function signPayload(payload: string): string {
26
+ return createHmac('sha256', getSigningSecret()).update(payload).digest('hex');
27
+ }
28
+
29
+ function buildToken(contentId: string, paymentId: string): string {
30
+ const payload = `${contentId}:${paymentId}`;
31
+ return `${payload}:${signPayload(payload)}`;
32
+ }
33
+
34
+ function parseToken(token: string, contentId: string): string | null {
35
+ const parts = token.split(':');
36
+ if (parts.length < 3) return null;
37
+
38
+ const paymentId = parts[1];
39
+ const signature = parts.slice(2).join(':');
40
+ const payload = `${contentId}:${paymentId}`;
41
+ const expected = signPayload(payload);
42
+
43
+ try {
44
+ const sigBuffer = Buffer.from(signature, 'hex');
45
+ const expectedBuffer = Buffer.from(expected, 'hex');
46
+ if (
47
+ sigBuffer.length !== expectedBuffer.length ||
48
+ !timingSafeEqual(sigBuffer, expectedBuffer)
49
+ ) {
50
+ return null;
51
+ }
52
+ } catch {
53
+ return null;
54
+ }
55
+
56
+ return paymentId;
57
+ }
58
+
59
+ function readPaymentToken(source: NextRequest | TCookieReader): string | null {
60
+ if (source instanceof NextRequest) {
61
+ return (
62
+ source.headers.get('x-payment-token') ??
63
+ source.cookies.get(PAYMENT_COOKIE_NAME)?.value ??
64
+ null
65
+ );
66
+ }
67
+
68
+ return source.get(PAYMENT_COOKIE_NAME)?.value ?? null;
69
+ }
70
+
71
+ /**
72
+ * Reads payment tokens from request cookies or the x-payment-token header.
73
+ */
74
+ export function checkPaymentCookie(
75
+ source: NextRequest | TCookieReader,
76
+ contentId: string,
77
+ ): boolean {
78
+ const token = readPaymentToken(source);
79
+ if (!token) return false;
80
+
81
+ const paymentId = parseToken(token, contentId);
82
+ if (!paymentId) return false;
83
+
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * Validates the token and confirms the underlying payment record is paid.
89
+ */
90
+ export async function checkPaymentAccess(
91
+ source: NextRequest | TCookieReader,
92
+ contentId: string,
93
+ ): Promise<boolean> {
94
+ const token = readPaymentToken(source);
95
+ if (!token) return false;
96
+
97
+ const paymentId = parseToken(token, contentId);
98
+ if (!paymentId) return false;
99
+
100
+ const record = await getPaymentById(paymentId);
101
+ return record?.contentId === contentId && record.status === 'paid';
102
+ }
103
+
104
+ export function setPaymentCookie(
105
+ response: NextResponse,
106
+ contentId: string,
107
+ paymentId: string,
108
+ ): NextResponse {
109
+ const token = buildToken(contentId, paymentId);
110
+ response.cookies.set(PAYMENT_COOKIE_NAME, token, {
111
+ httpOnly: true,
112
+ secure: process.env.NODE_ENV === 'production',
113
+ sameSite: 'lax',
114
+ path: '/',
115
+ maxAge: PAYMENT_COOKIE_MAX_AGE,
116
+ });
117
+ return response;
118
+ }
119
+
120
+ export type TGenerateInvoiceResult = {
121
+ paymentId: string;
122
+ invoice: string;
123
+ amountSats: number;
124
+ memo: string;
125
+ /** Present in development to simulate wallet payment without WebLN */
126
+ devPreimage?: string;
127
+ };
128
+
129
+ /**
130
+ * Creates a server-side invoice and stores a pending payment record.
131
+ */
132
+ export async function generateInvoice(options: {
133
+ contentId: string;
134
+ amountSats: number;
135
+ memo?: string;
136
+ }): Promise<TGenerateInvoiceResult> {
137
+ const record = await createPaymentRecord({
138
+ contentId: options.contentId,
139
+ amountSats: options.amountSats,
140
+ memo: options.memo ?? `Unlock ${options.contentId}`,
141
+ });
142
+
143
+ const result: TGenerateInvoiceResult = {
144
+ paymentId: record.id,
145
+ invoice: record.invoice,
146
+ amountSats: record.amountSats,
147
+ memo: record.memo,
148
+ };
149
+
150
+ if (process.env.NODE_ENV === 'development') {
151
+ result.devPreimage = record.preimage;
152
+ }
153
+
154
+ return result;
155
+ }
156
+
157
+ export type TVerifyPaymentResult =
158
+ | { valid: true; record: TPaymentRecord }
159
+ | { valid: false; reason: string };
160
+
161
+ /**
162
+ * Verifies a Lightning payment preimage against a pending invoice record.
163
+ */
164
+ export async function verifyPayment(
165
+ paymentId: string,
166
+ preimage: string,
167
+ ): Promise<TVerifyPaymentResult> {
168
+ const record = await getPaymentById(paymentId);
169
+ if (!record) {
170
+ return { valid: false, reason: 'Payment not found' };
171
+ }
172
+
173
+ if (record.status === 'expired') {
174
+ return { valid: false, reason: 'Invoice expired' };
175
+ }
176
+
177
+ if (record.status === 'paid') {
178
+ return { valid: true, record };
179
+ }
180
+
181
+ if (record.preimage !== preimage) {
182
+ return { valid: false, reason: 'Invalid payment proof' };
183
+ }
184
+
185
+ const paid = await markPaymentPaid(paymentId);
186
+ if (!paid) {
187
+ return { valid: false, reason: 'Unable to confirm payment' };
188
+ }
189
+
190
+ return { valid: true, record: paid };
191
+ }
192
+
193
+ export function findProtectedRoute(pathname: string) {
194
+ return PROTECTED_ROUTES.find((route) => pathname.startsWith(route.path));
195
+ }
@@ -0,0 +1,104 @@
1
+ import { randomBytes } from 'crypto';
2
+
3
+ export type TPaymentStatus = 'pending' | 'paid' | 'expired';
4
+
5
+ export type TPaymentRecord = {
6
+ id: string;
7
+ contentId: string;
8
+ invoice: string;
9
+ preimage: string;
10
+ amountSats: number;
11
+ memo: string;
12
+ status: TPaymentStatus;
13
+ createdAt: number;
14
+ paidAt: number | null;
15
+ metadata?: Record<string, string>;
16
+ };
17
+
18
+ const payments = new Map<string, TPaymentRecord>();
19
+ const INVOICE_TTL_MS = 60 * 60 * 1000;
20
+
21
+ function generateId(): string {
22
+ return randomBytes(16).toString('hex');
23
+ }
24
+
25
+ function generatePreimage(): string {
26
+ return randomBytes(32).toString('hex');
27
+ }
28
+
29
+ function buildMockInvoice(amountSats: number, paymentId: string): string {
30
+ const amountPart = amountSats.toString(16).padStart(6, '0');
31
+ return `lnbc${amountPart}n1p${paymentId.slice(0, 40)}`;
32
+ }
33
+
34
+ /**
35
+ * Creates a pending payment record. Uses in-memory storage by default;
36
+ * when DATABASE_URL is set and lib/db exists, the database module can extend this.
37
+ */
38
+ export async function createPaymentRecord(options: {
39
+ contentId: string;
40
+ amountSats: number;
41
+ memo: string;
42
+ metadata?: Record<string, string>;
43
+ }): Promise<TPaymentRecord> {
44
+ const id = generateId();
45
+ const preimage = generatePreimage();
46
+ const record: TPaymentRecord = {
47
+ id,
48
+ contentId: options.contentId,
49
+ invoice: buildMockInvoice(options.amountSats, id),
50
+ preimage,
51
+ amountSats: options.amountSats,
52
+ memo: options.memo,
53
+ status: 'pending',
54
+ createdAt: Date.now(),
55
+ paidAt: null,
56
+ metadata: options.metadata,
57
+ };
58
+
59
+ payments.set(id, record);
60
+ return record;
61
+ }
62
+
63
+ export async function getPaymentById(id: string): Promise<TPaymentRecord | null> {
64
+ const record = payments.get(id);
65
+ if (!record) return null;
66
+
67
+ if (
68
+ record.status === 'pending' &&
69
+ Date.now() - record.createdAt > INVOICE_TTL_MS
70
+ ) {
71
+ record.status = 'expired';
72
+ payments.set(id, record);
73
+ }
74
+
75
+ return record;
76
+ }
77
+
78
+ export async function getPaymentByPreimage(
79
+ preimage: string,
80
+ ): Promise<TPaymentRecord | null> {
81
+ for (const record of payments.values()) {
82
+ if (record.preimage === preimage) return record;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ export async function markPaymentPaid(id: string): Promise<TPaymentRecord | null> {
88
+ const record = await getPaymentById(id);
89
+ if (!record || record.status !== 'pending') return null;
90
+
91
+ record.status = 'paid';
92
+ record.paidAt = Date.now();
93
+ payments.set(id, record);
94
+ return record;
95
+ }
96
+
97
+ export async function hasPaidForContent(contentId: string): Promise<boolean> {
98
+ for (const record of payments.values()) {
99
+ if (record.contentId === contentId && record.status === 'paid') {
100
+ return true;
101
+ }
102
+ }
103
+ return false;
104
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "payment-gated-content",
3
+ "description": "Lock content behind a lightning payment gate",
4
+ "dependencies": [],
5
+ "devDependencies": [],
6
+ "files": [
7
+ { "src": "proxy.ts", "dest": "proxy.ts", "merge": "replace" },
8
+ { "src": "lib/payment-gate.ts", "dest": "lib/payment-gate.ts", "merge": "add" },
9
+ { "src": "lib/payment-store.ts", "dest": "lib/payment-store.ts", "merge": "add" },
10
+ { "src": "components/payment-gated/PayGate.tsx", "dest": "components/payment-gated/PayGate.tsx", "merge": "add" },
11
+ { "src": "app/api/payment-gate/invoice/route.ts", "dest": "app/api/payment-gate/invoice/route.ts", "merge": "add" },
12
+ { "src": "app/api/payment-gate/verify/route.ts", "dest": "app/api/payment-gate/verify/route.ts", "merge": "add" },
13
+ { "src": "app/demo/payment-gated/page.tsx", "dest": "app/demo/payment-gated/page.tsx", "merge": "add" },
14
+ { "src": "app/demo/payment-gated/article/page.tsx", "dest": "app/demo/payment-gated/article/page.tsx", "merge": "add" }
15
+ ],
16
+ "envVars": [
17
+ {
18
+ "key": "PAYMENT_GATE_SECRET",
19
+ "description": "HMAC secret for signing payment access cookies",
20
+ "example": "replace-with-a-long-random-string",
21
+ "required": false
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import {
3
+ checkPaymentAccess,
4
+ findProtectedRoute,
5
+ } from './lib/payment-gate';
6
+
7
+ export default async function proxy(request: NextRequest) {
8
+ const protectedRoute = findProtectedRoute(request.nextUrl.pathname);
9
+ if (!protectedRoute) {
10
+ return NextResponse.next();
11
+ }
12
+
13
+ const hasAccess = await checkPaymentAccess(request, protectedRoute.contentId);
14
+ if (hasAccess) {
15
+ return NextResponse.next();
16
+ }
17
+
18
+ const url = request.nextUrl.clone();
19
+ url.pathname = '/demo/payment-gated';
20
+ url.searchParams.set('redirect', protectedRoute.path);
21
+ url.searchParams.set('contentId', protectedRoute.contentId);
22
+ return NextResponse.redirect(url);
23
+ }
24
+
25
+ export const config = {
26
+ matcher: ['/((?!_next/static|_next/image|favicon.ico|api/payment-gate).*)'],
27
+ };
@@ -0,0 +1,176 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useCallback, useState } from 'react';
5
+ import { PayButton } from '../../../components/webln/PayButton';
6
+ import { InvoiceCard } from '../../../components/webln/InvoiceCard';
7
+ import { PaymentHistory } from '../../../components/webln/PaymentHistory';
8
+ import { usePaymentFlow } from '../../../hooks/usePaymentFlow';
9
+ import { addPaymentRecord } from '../../../lib/payment-history';
10
+
11
+ const DEMO_SATS = 21;
12
+ const DEMO_MEMO = 'demo payment';
13
+
14
+ export default function WeblnDemoPage() {
15
+ const { recordReceivedPayment } = usePaymentFlow();
16
+ const [demoInvoice, setDemoInvoice] = useState('');
17
+ const [howItWorksOpen, setHowItWorksOpen] = useState(false);
18
+
19
+ const handleInvoiceReady = useCallback((invoice: string) => {
20
+ setDemoInvoice(invoice);
21
+ }, []);
22
+
23
+ function handlePaySuccess(preimage: string) {
24
+ addPaymentRecord({
25
+ amountSats: DEMO_SATS,
26
+ memo: DEMO_MEMO,
27
+ timestamp: Date.now(),
28
+ preimage,
29
+ type: 'send',
30
+ });
31
+ }
32
+
33
+ function handleInvoicePaid(preimage: string) {
34
+ recordReceivedPayment({
35
+ amountSats: DEMO_SATS,
36
+ memo: DEMO_MEMO,
37
+ preimage,
38
+ });
39
+ }
40
+
41
+ return (
42
+ <div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
43
+ <div
44
+ className="mx-auto w-full max-w-[390px] px-4 pt-6"
45
+ style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
46
+ >
47
+ <Link
48
+ href="/demo"
49
+ 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"
50
+ >
51
+ ← back
52
+ </Link>
53
+
54
+ <header className="mb-8 space-y-2">
55
+ <h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
56
+ WebLN Payments
57
+ </h1>
58
+ <p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
59
+ Send and receive Lightning payments through the WebLN browser API. Inside Fedi, your
60
+ wallet is injected automatically. No extensions required.
61
+ </p>
62
+ </header>
63
+
64
+ <div className="space-y-8">
65
+ <section className="space-y-4">
66
+ <div className="max-w-[75ch] space-y-1.5">
67
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
68
+ Receive payment
69
+ </h2>
70
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
71
+ Creates a BOLT11 invoice via <code className="font-mono text-xs">makeInvoice()</code>.
72
+ Scan the QR code or copy the invoice string to pay.
73
+ </p>
74
+ </div>
75
+ <InvoiceCard
76
+ sats={DEMO_SATS}
77
+ memo={DEMO_MEMO}
78
+ onInvoice={handleInvoiceReady}
79
+ onPaid={handleInvoicePaid}
80
+ />
81
+ </section>
82
+
83
+ <section className="space-y-4">
84
+ <div className="max-w-[75ch] space-y-1.5">
85
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
86
+ Send payment
87
+ </h2>
88
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
89
+ Pays the invoice above via <code className="font-mono text-xs">sendPayment()</code>.
90
+ The preimage returned is cryptographic proof that payment completed.
91
+ </p>
92
+ </div>
93
+ {demoInvoice ? (
94
+ <PayButton
95
+ invoice={demoInvoice}
96
+ amountSats={DEMO_SATS}
97
+ memo={DEMO_MEMO}
98
+ onSuccess={handlePaySuccess}
99
+ />
100
+ ) : (
101
+ <p className="text-sm text-[var(--color-text-subtle)]">Waiting for invoice…</p>
102
+ )}
103
+ </section>
104
+
105
+ <section className="space-y-4">
106
+ <div className="max-w-[75ch] space-y-1.5">
107
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
108
+ Payment history
109
+ </h2>
110
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
111
+ Recent payments stored in your browser. Useful for demos, not a substitute for
112
+ server-side records in production.
113
+ </p>
114
+ </div>
115
+ <PaymentHistory />
116
+ </section>
117
+
118
+ <section className="space-y-3">
119
+ <button
120
+ type="button"
121
+ onClick={() => setHowItWorksOpen((open) => !open)}
122
+ className="flex w-full items-center justify-between rounded-lg px-4 py-3 text-left text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80"
123
+ style={{
124
+ background: 'var(--color-surface-1)',
125
+ border: '1px solid var(--color-border)',
126
+ color: 'var(--color-text)',
127
+ borderRadius: 'var(--radius-lg)',
128
+ }}
129
+ aria-expanded={howItWorksOpen}
130
+ aria-controls="webln-how-it-works"
131
+ >
132
+ How this works
133
+ <span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
134
+ </button>
135
+
136
+ {howItWorksOpen && (
137
+ <div
138
+ id="webln-how-it-works"
139
+ className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
140
+ style={{
141
+ background: 'var(--color-surface-1)',
142
+ border: '1px solid var(--color-border)',
143
+ color: 'var(--color-text-muted)',
144
+ borderRadius: 'var(--radius-lg)',
145
+ }}
146
+ >
147
+ <p>
148
+ <strong style={{ color: 'var(--color-text)' }}>WebLN</strong> is a JavaScript
149
+ standard that lets web apps talk to a Lightning wallet. Fedi injects{' '}
150
+ <code className="font-mono text-xs">window.webln</code> into every Mini App
151
+ WebView before your page loads.
152
+ </p>
153
+ <p>
154
+ To send sats, call <code className="font-mono text-xs">sendPayment(invoice)</code>{' '}
155
+ with a BOLT11 string. To receive, call{' '}
156
+ <code className="font-mono text-xs">makeInvoice({'{ amount, defaultMemo }'})</code>{' '}
157
+ and share the returned payment request.
158
+ </p>
159
+ <p>
160
+ On success, <code className="font-mono text-xs">sendPayment</code> returns a{' '}
161
+ <strong style={{ color: 'var(--color-text)' }}>preimage</strong>, a 32-byte hex
162
+ string that proves the payment happened. Your server can verify it before
163
+ unlocking content or granting access.
164
+ </p>
165
+ <p>
166
+ Outside Fedi, <code className="font-mono text-xs">window.webln</code> is undefined.
167
+ In development, this project uses a mock provider so you can test flows locally.
168
+ </p>
169
+ </div>
170
+ )}
171
+ </section>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ );
176
+ }
@@ -0,0 +1,170 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { QRCodeSVG } from 'qrcode.react';
5
+ import { usePayment } from '../../lib/webln';
6
+ import { formatSats } from '../../lib/payment-history';
7
+
8
+ interface IInvoiceCardProps {
9
+ sats: number;
10
+ memo?: string;
11
+ onPaid?: (preimage: string) => void;
12
+ onInvoice?: (invoice: string) => void;
13
+ }
14
+
15
+ function mockPreimage(): string {
16
+ let result = '';
17
+ for (let i = 0; i < 64; i++) {
18
+ result += Math.floor(Math.random() * 16).toString(16);
19
+ }
20
+ return result;
21
+ }
22
+
23
+ export function InvoiceCard({ sats, memo, onPaid, onInvoice }: IInvoiceCardProps) {
24
+ const { makeInvoice, isCreatingInvoice, paymentError, lastInvoice } = usePayment();
25
+ const [copied, setCopied] = useState(false);
26
+ const [isPaid, setIsPaid] = useState(false);
27
+ const [paidPreimage, setPaidPreimage] = useState<string | null>(null);
28
+
29
+ useEffect(() => {
30
+ makeInvoice({ amount: String(sats), defaultMemo: memo ?? '' });
31
+ // eslint-disable-next-line react-hooks/exhaustive-deps
32
+ }, [sats, memo]);
33
+
34
+ useEffect(() => {
35
+ if (lastInvoice) {
36
+ onInvoice?.(lastInvoice);
37
+ }
38
+ }, [lastInvoice, onInvoice]);
39
+
40
+ useEffect(() => {
41
+ if (!lastInvoice || isPaid || isCreatingInvoice) return;
42
+
43
+ if (process.env.NODE_ENV === 'development') {
44
+ const timer = window.setTimeout(() => {
45
+ const preimage = mockPreimage();
46
+ setPaidPreimage(preimage);
47
+ setIsPaid(true);
48
+ onPaid?.(preimage);
49
+ }, 5000);
50
+ return () => window.clearTimeout(timer);
51
+ }
52
+ }, [lastInvoice, isPaid, isCreatingInvoice, onPaid]);
53
+
54
+ async function handleCopy() {
55
+ if (!lastInvoice) return;
56
+ await navigator.clipboard.writeText(lastInvoice);
57
+ setCopied(true);
58
+ window.setTimeout(() => setCopied(false), 2000);
59
+ }
60
+
61
+ return (
62
+ <div
63
+ className="rounded-xl p-4 flex flex-col gap-3"
64
+ style={{
65
+ background: 'var(--color-surface-1)',
66
+ border: '1px solid var(--color-border)',
67
+ borderRadius: 'var(--radius-lg)',
68
+ }}
69
+ aria-label={`Invoice for ${formatSats(sats)}`}
70
+ >
71
+ <div className="flex items-center justify-between gap-2">
72
+ <span className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
73
+ {formatSats(sats)}
74
+ </span>
75
+ {memo && (
76
+ <span className="text-xs truncate max-w-[50%]" style={{ color: 'var(--color-text-muted)' }}>
77
+ {memo}
78
+ </span>
79
+ )}
80
+ </div>
81
+
82
+ {isCreatingInvoice && (
83
+ <p
84
+ className="text-xs font-mono"
85
+ style={{ color: 'var(--color-text-subtle)' }}
86
+ aria-live="polite"
87
+ >
88
+ Generating invoice…
89
+ </p>
90
+ )}
91
+
92
+ {isPaid && (
93
+ <div
94
+ className="flex items-center gap-2 rounded-lg px-3 py-2 transition-opacity duration-300 ease-[cubic-bezier(0.25,1,0.5,1)] opacity-100"
95
+ style={{
96
+ background: 'var(--color-accent-dim)',
97
+ borderRadius: 'var(--radius-md)',
98
+ }}
99
+ role="status"
100
+ aria-label={`Invoice paid: ${formatSats(sats)}`}
101
+ >
102
+ <span
103
+ className="flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold"
104
+ style={{ background: 'var(--color-accent)', color: 'var(--color-primary-foreground)' }}
105
+ aria-hidden
106
+ >
107
+
108
+ </span>
109
+ <span className="text-sm font-semibold" style={{ color: 'var(--color-accent)' }}>
110
+ Paid
111
+ </span>
112
+ </div>
113
+ )}
114
+
115
+ {lastInvoice && !isCreatingInvoice && !isPaid && (
116
+ <>
117
+ <div
118
+ className="mx-auto flex w-full max-w-[160px] items-center justify-center rounded-lg p-3"
119
+ style={{ background: 'var(--color-surface-2)' }}
120
+ aria-label="Invoice QR code"
121
+ >
122
+ <QRCodeSVG
123
+ value={lastInvoice}
124
+ size={136}
125
+ level="M"
126
+ bgColor="transparent"
127
+ fgColor="var(--color-text)"
128
+ />
129
+ </div>
130
+
131
+ <p className="text-center text-xs" style={{ color: 'var(--color-text-subtle)' }}>
132
+ {process.env.NODE_ENV === 'development'
133
+ ? 'Simulated payment in 5 seconds (dev only)'
134
+ : 'Waiting for payment…'}
135
+ </p>
136
+
137
+ <button
138
+ type="button"
139
+ onClick={handleCopy}
140
+ 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"
141
+ style={{
142
+ background: 'var(--color-surface-2)',
143
+ color: 'var(--color-text)',
144
+ borderRadius: 'var(--radius-md)',
145
+ }}
146
+ aria-label={copied ? 'Invoice copied to clipboard' : 'Copy invoice to clipboard'}
147
+ >
148
+ {copied ? 'Copied!' : 'Copy invoice'}
149
+ </button>
150
+ </>
151
+ )}
152
+
153
+ {isPaid && paidPreimage && (
154
+ <p className="font-mono text-xs" style={{ color: 'var(--color-text-muted)' }}>
155
+ {paidPreimage.slice(0, 12)}…{paidPreimage.slice(-8)}
156
+ </p>
157
+ )}
158
+
159
+ {paymentError && (
160
+ <p
161
+ className="text-xs"
162
+ style={{ color: 'var(--color-error, #ef4444)' }}
163
+ role="alert"
164
+ >
165
+ {paymentError.message}
166
+ </p>
167
+ )}
168
+ </div>
169
+ );
170
+ }