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,92 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { usePayment } from '../../lib/webln';
5
+ import { formatSats, truncatePreimage } from '../../lib/payment-history';
6
+
7
+ interface IPayButtonProps {
8
+ invoice: string;
9
+ amountSats: number;
10
+ memo?: string;
11
+ onSuccess: (preimage: string) => void;
12
+ }
13
+
14
+ export function PayButton({ invoice, amountSats, memo, onSuccess }: IPayButtonProps) {
15
+ const { sendPayment, isPaying, paymentError } = usePayment();
16
+ const [paid, setPaid] = useState(false);
17
+ const [preimage, setPreimage] = useState<string | null>(null);
18
+
19
+ async function handlePay() {
20
+ const result = await sendPayment(invoice);
21
+ if (result?.preimage) {
22
+ setPreimage(result.preimage);
23
+ setPaid(true);
24
+ onSuccess(result.preimage);
25
+ }
26
+ }
27
+
28
+ if (paid && preimage) {
29
+ return (
30
+ <div
31
+ className="rounded-lg px-4 py-3 text-sm transition-opacity duration-300 ease-[cubic-bezier(0.25,1,0.5,1)] opacity-100"
32
+ style={{
33
+ background: 'var(--color-accent-dim)',
34
+ color: 'var(--color-accent)',
35
+ borderRadius: 'var(--radius-md)',
36
+ }}
37
+ role="status"
38
+ aria-label={`Payment of ${formatSats(amountSats)} sent successfully`}
39
+ >
40
+ <p className="font-semibold mb-0.5">Payment sent</p>
41
+ <p className="text-xs opacity-80 mb-1">{formatSats(amountSats)}</p>
42
+ <p className="font-mono text-xs opacity-70" aria-label={`Preimage ${truncatePreimage(preimage)}`}>
43
+ {truncatePreimage(preimage)}
44
+ </p>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ return (
50
+ <div className="flex flex-col gap-2">
51
+ <button
52
+ type="button"
53
+ onClick={handlePay}
54
+ disabled={isPaying || !invoice}
55
+ className="inline-flex items-center justify-center gap-2 rounded-lg px-5 py-2.5 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:opacity-40 disabled:cursor-not-allowed"
56
+ style={{
57
+ background: 'var(--color-accent)',
58
+ color: 'var(--color-primary-foreground)',
59
+ borderRadius: 'var(--radius-md)',
60
+ }}
61
+ aria-label={
62
+ isPaying
63
+ ? `Paying ${formatSats(amountSats)}`
64
+ : `Pay ${formatSats(amountSats)}${memo ? ` for ${memo}` : ''}`
65
+ }
66
+ aria-busy={isPaying}
67
+ >
68
+ {isPaying ? (
69
+ <>
70
+ <span
71
+ className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin"
72
+ aria-hidden
73
+ />
74
+ Paying…
75
+ </>
76
+ ) : (
77
+ `Pay ${formatSats(amountSats)}`
78
+ )}
79
+ </button>
80
+ {paymentError && (
81
+ <p
82
+ className="text-xs"
83
+ style={{ color: 'var(--color-error, #ef4444)' }}
84
+ role="alert"
85
+ aria-label={`Payment failed: ${paymentError.message}`}
86
+ >
87
+ {paymentError.message}
88
+ </p>
89
+ )}
90
+ </div>
91
+ );
92
+ }
@@ -0,0 +1,102 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import {
5
+ formatSats,
6
+ formatTimestamp,
7
+ getPaymentHistory,
8
+ PAYMENT_HISTORY_EVENT,
9
+ PAYMENT_HISTORY_KEY,
10
+ truncatePreimage,
11
+ type TPaymentRecord,
12
+ } from '../../lib/payment-history';
13
+
14
+ export function PaymentHistory() {
15
+ const [records, setRecords] = useState<TPaymentRecord[]>([]);
16
+
17
+ useEffect(() => {
18
+ function refresh() {
19
+ setRecords(getPaymentHistory());
20
+ }
21
+
22
+ refresh();
23
+ window.addEventListener(PAYMENT_HISTORY_EVENT, refresh);
24
+ window.addEventListener('storage', (event) => {
25
+ if (event.key === PAYMENT_HISTORY_KEY) refresh();
26
+ });
27
+ return () => window.removeEventListener(PAYMENT_HISTORY_EVENT, refresh);
28
+ }, []);
29
+
30
+ if (records.length === 0) {
31
+ return (
32
+ <div
33
+ className="rounded-xl p-4 text-center"
34
+ style={{
35
+ background: 'var(--color-surface-1)',
36
+ border: '1px solid var(--color-border)',
37
+ borderRadius: 'var(--radius-lg)',
38
+ }}
39
+ aria-label="No payment history"
40
+ >
41
+ <p className="text-sm font-medium mb-1" style={{ color: 'var(--color-text)' }}>
42
+ No payments yet
43
+ </p>
44
+ <p className="text-xs leading-[1.65]" style={{ color: 'var(--color-text-muted)' }}>
45
+ Send or receive a Lightning payment and it will appear here. History is stored locally
46
+ in your browser, not on a server.
47
+ </p>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ return (
53
+ <ul className="flex flex-col gap-2" aria-label="Recent payments">
54
+ {records.map((record) => (
55
+ <li
56
+ key={record.id}
57
+ className="rounded-xl p-3"
58
+ style={{
59
+ background: 'var(--color-surface-1)',
60
+ border: '1px solid var(--color-border)',
61
+ borderRadius: 'var(--radius-lg)',
62
+ }}
63
+ >
64
+ <div className="flex items-start justify-between gap-2 mb-1">
65
+ <div>
66
+ <p className="text-sm font-semibold" style={{ color: 'var(--color-text)' }}>
67
+ {formatSats(record.amountSats)}
68
+ </p>
69
+ <p className="text-xs" style={{ color: 'var(--color-text-muted)' }}>
70
+ {record.memo}
71
+ </p>
72
+ </div>
73
+ <span
74
+ className="text-xs font-medium shrink-0"
75
+ style={{
76
+ color: record.type === 'send' ? 'var(--color-text-subtle)' : 'var(--color-accent)',
77
+ }}
78
+ >
79
+ {record.type === 'send' ? 'Sent' : 'Received'}
80
+ </span>
81
+ </div>
82
+ <div className="flex items-center justify-between gap-2">
83
+ <time
84
+ className="text-xs"
85
+ style={{ color: 'var(--color-text-subtle)' }}
86
+ dateTime={new Date(record.timestamp).toISOString()}
87
+ >
88
+ {formatTimestamp(record.timestamp)}
89
+ </time>
90
+ <span
91
+ className="font-mono text-xs"
92
+ style={{ color: 'var(--color-text-muted)' }}
93
+ aria-label={`Preimage ${truncatePreimage(record.preimage)}`}
94
+ >
95
+ {truncatePreimage(record.preimage)}
96
+ </span>
97
+ </div>
98
+ </li>
99
+ ))}
100
+ </ul>
101
+ );
102
+ }
@@ -0,0 +1,182 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { renderHook, act, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { WebLNProvider, MockWebLNProvider, type RequestInvoiceResponse } from '../../lib/webln';
5
+ import { usePaymentFlow } from '../usePaymentFlow';
6
+ import {
7
+ clearPaymentHistory,
8
+ getPaymentHistory,
9
+ PAYMENT_HISTORY_KEY,
10
+ } from '../../lib/payment-history';
11
+
12
+ const VALID_INVOICE =
13
+ 'lnbc21n1p000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000';
14
+
15
+ function createWrapper(mock = new MockWebLNProvider({ paymentDelay: 0 })) {
16
+ return function Wrapper({ children }: { children: ReactNode }) {
17
+ return <WebLNProvider mockProvider={mock}>{children}</WebLNProvider>;
18
+ };
19
+ }
20
+
21
+ async function waitForProviderReady(
22
+ result: { current: ReturnType<typeof usePaymentFlow> },
23
+ ) {
24
+ await waitFor(async () => {
25
+ let invoice = null;
26
+ await act(async () => {
27
+ invoice = await result.current.createInvoice(1, 'probe');
28
+ });
29
+ expect(invoice).not.toBeNull();
30
+ });
31
+ }
32
+
33
+ describe('usePaymentFlow', () => {
34
+ beforeEach(() => {
35
+ clearPaymentHistory();
36
+ vi.clearAllMocks();
37
+ });
38
+
39
+ it('starts in idle state with empty history', () => {
40
+ const { result } = renderHook(() => usePaymentFlow(), {
41
+ wrapper: createWrapper(),
42
+ });
43
+
44
+ expect(result.current.step).toBe('idle');
45
+ expect(result.current.history).toEqual([]);
46
+ });
47
+
48
+ it('pay() records a send payment on success', async () => {
49
+ const { result } = renderHook(() => usePaymentFlow(), {
50
+ wrapper: createWrapper(),
51
+ });
52
+
53
+ await waitForProviderReady(result);
54
+
55
+ await act(async () => {
56
+ await result.current.pay(VALID_INVOICE, { amountSats: 21, memo: 'test send' });
57
+ });
58
+
59
+ expect(result.current.step).toBe('success');
60
+
61
+ const history = getPaymentHistory();
62
+ expect(history).toHaveLength(1);
63
+ expect(history[0].amountSats).toBe(21);
64
+ expect(history[0].memo).toBe('test send');
65
+ expect(history[0].type).toBe('send');
66
+ expect(history[0].preimage.length).toBeGreaterThan(0);
67
+ });
68
+
69
+ it('pay() sets error step when payment fails', async () => {
70
+ const mock = new MockWebLNProvider({
71
+ paymentDelay: 0,
72
+ shouldFail: true,
73
+ failureMessage: 'Insufficient funds',
74
+ });
75
+
76
+ const { result } = renderHook(() => usePaymentFlow(), {
77
+ wrapper: createWrapper(mock),
78
+ });
79
+
80
+ await waitForProviderReady(result);
81
+
82
+ await act(async () => {
83
+ await result.current.pay(VALID_INVOICE, { amountSats: 21 });
84
+ });
85
+
86
+ expect(result.current.step).toBe('error');
87
+ expect(getPaymentHistory()).toHaveLength(0);
88
+ });
89
+
90
+ it('createInvoice() returns an invoice and resets to idle', async () => {
91
+ const { result } = renderHook(() => usePaymentFlow(), {
92
+ wrapper: createWrapper(),
93
+ });
94
+
95
+ await waitForProviderReady(result);
96
+
97
+ let invoice: RequestInvoiceResponse | null = null;
98
+ await act(async () => {
99
+ invoice = await result.current.createInvoice(100, 'coffee');
100
+ });
101
+
102
+ expect(invoice).not.toBeNull();
103
+ expect(invoice!.paymentRequest).toMatch(/^lnbc/);
104
+
105
+ expect(result.current.step).toBe('idle');
106
+ expect(result.current.lastInvoice).toMatch(/^lnbc/);
107
+ });
108
+
109
+ it('recordReceivedPayment() adds a receive entry to history', () => {
110
+ const { result } = renderHook(() => usePaymentFlow(), {
111
+ wrapper: createWrapper(),
112
+ });
113
+
114
+ act(() => {
115
+ result.current.recordReceivedPayment({
116
+ amountSats: 50,
117
+ memo: 'received',
118
+ preimage: 'abcd'.repeat(16),
119
+ });
120
+ });
121
+
122
+ expect(result.current.history).toHaveLength(1);
123
+ expect(result.current.history[0].type).toBe('receive');
124
+ expect(result.current.step).toBe('success');
125
+ expect(window.localStorage.getItem(PAYMENT_HISTORY_KEY)).toBeTruthy();
126
+ });
127
+
128
+ it('reset() returns step to idle', async () => {
129
+ const { result } = renderHook(() => usePaymentFlow(), {
130
+ wrapper: createWrapper(),
131
+ });
132
+
133
+ await waitForProviderReady(result);
134
+
135
+ await act(async () => {
136
+ await result.current.pay(VALID_INVOICE, { amountSats: 21 });
137
+ });
138
+
139
+ act(() => {
140
+ result.current.reset();
141
+ });
142
+
143
+ expect(result.current.step).toBe('idle');
144
+ });
145
+
146
+ it('refreshHistory() reloads from localStorage', () => {
147
+ window.localStorage.setItem(
148
+ PAYMENT_HISTORY_KEY,
149
+ JSON.stringify([
150
+ {
151
+ id: '1',
152
+ amountSats: 10,
153
+ memo: 'stored',
154
+ timestamp: Date.now(),
155
+ preimage: 'ee'.repeat(32),
156
+ type: 'send',
157
+ },
158
+ ]),
159
+ );
160
+
161
+ const { result } = renderHook(() => usePaymentFlow(), {
162
+ wrapper: createWrapper(),
163
+ });
164
+
165
+ act(() => {
166
+ clearPaymentHistory();
167
+ result.current.refreshHistory();
168
+ });
169
+
170
+ expect(result.current.history).toHaveLength(0);
171
+ });
172
+ });
173
+
174
+ describe('payment-history utilities', () => {
175
+ beforeEach(() => {
176
+ clearPaymentHistory();
177
+ });
178
+
179
+ it('getPaymentHistory returns empty array when storage is empty', () => {
180
+ expect(getPaymentHistory()).toEqual([]);
181
+ });
182
+ });
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+ import { usePayment } from '../lib/webln';
5
+ import {
6
+ addPaymentRecord,
7
+ getPaymentHistory,
8
+ type TPaymentRecord,
9
+ } from '../lib/payment-history';
10
+
11
+ type TFlowStep = 'idle' | 'paying' | 'success' | 'error';
12
+
13
+ interface IPayOptions {
14
+ amountSats: number;
15
+ memo?: string;
16
+ }
17
+
18
+ interface IReceiveOptions {
19
+ amountSats: number;
20
+ memo?: string;
21
+ preimage: string;
22
+ }
23
+
24
+ /**
25
+ * Orchestrates WebLN pay/receive flows and persists results to localStorage.
26
+ */
27
+ export function usePaymentFlow() {
28
+ const {
29
+ sendPayment,
30
+ makeInvoice,
31
+ isPaying,
32
+ isCreatingInvoice,
33
+ paymentError,
34
+ lastPreimage,
35
+ lastInvoice,
36
+ } = usePayment();
37
+ const [step, setStep] = useState<TFlowStep>('idle');
38
+ const [history, setHistory] = useState<TPaymentRecord[]>(() => getPaymentHistory());
39
+
40
+ const refreshHistory = useCallback(() => {
41
+ setHistory(getPaymentHistory());
42
+ }, []);
43
+
44
+ async function pay(invoice: string, options: IPayOptions) {
45
+ setStep('paying');
46
+ const result = await sendPayment(invoice);
47
+ if (result?.preimage) {
48
+ addPaymentRecord({
49
+ amountSats: options.amountSats,
50
+ memo: options.memo ?? 'Sent payment',
51
+ timestamp: Date.now(),
52
+ preimage: result.preimage,
53
+ type: 'send',
54
+ });
55
+ refreshHistory();
56
+ setStep('success');
57
+ return result;
58
+ }
59
+ setStep('error');
60
+ return null;
61
+ }
62
+
63
+ async function createInvoice(sats: number, memo?: string) {
64
+ setStep('paying');
65
+ const result = await makeInvoice({ amount: String(sats), defaultMemo: memo ?? '' });
66
+ setStep(result ? 'idle' : 'error');
67
+ return result;
68
+ }
69
+
70
+ function recordReceivedPayment(options: IReceiveOptions) {
71
+ addPaymentRecord({
72
+ amountSats: options.amountSats,
73
+ memo: options.memo ?? 'Received payment',
74
+ timestamp: Date.now(),
75
+ preimage: options.preimage,
76
+ type: 'receive',
77
+ });
78
+ refreshHistory();
79
+ setStep('success');
80
+ }
81
+
82
+ function reset() {
83
+ setStep('idle');
84
+ }
85
+
86
+ return {
87
+ step,
88
+ pay,
89
+ createInvoice,
90
+ recordReceivedPayment,
91
+ reset,
92
+ isPaying,
93
+ isCreatingInvoice,
94
+ paymentError,
95
+ lastPreimage,
96
+ lastInvoice,
97
+ history,
98
+ refreshHistory,
99
+ };
100
+ }
@@ -0,0 +1,75 @@
1
+ export type TPaymentRecord = {
2
+ id: string;
3
+ amountSats: number;
4
+ memo: string;
5
+ timestamp: number;
6
+ preimage: string;
7
+ type: 'send' | 'receive';
8
+ };
9
+
10
+ export const PAYMENT_HISTORY_KEY = 'fedi-payment-history';
11
+ export const PAYMENT_HISTORY_EVENT = 'fedi-payment-history-updated';
12
+ const MAX_RECORDS = 50;
13
+
14
+ function notifyHistoryUpdated(): void {
15
+ if (typeof window === 'undefined') return;
16
+ window.dispatchEvent(new CustomEvent(PAYMENT_HISTORY_EVENT));
17
+ }
18
+
19
+ /**
20
+ * Reads payment history from localStorage, newest first.
21
+ */
22
+ export function getPaymentHistory(): TPaymentRecord[] {
23
+ if (typeof window === 'undefined') return [];
24
+ try {
25
+ const raw = window.localStorage.getItem(PAYMENT_HISTORY_KEY);
26
+ if (!raw) return [];
27
+ const parsed = JSON.parse(raw) as TPaymentRecord[];
28
+ return Array.isArray(parsed) ? parsed : [];
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Appends a payment record and persists to localStorage.
36
+ */
37
+ export function addPaymentRecord(
38
+ record: Omit<TPaymentRecord, 'id'>,
39
+ ): TPaymentRecord {
40
+ const entry: TPaymentRecord = {
41
+ ...record,
42
+ id: `${record.timestamp}-${record.preimage.slice(0, 8)}`,
43
+ };
44
+ const history = [entry, ...getPaymentHistory()].slice(0, MAX_RECORDS);
45
+ window.localStorage.setItem(PAYMENT_HISTORY_KEY, JSON.stringify(history));
46
+ notifyHistoryUpdated();
47
+ return entry;
48
+ }
49
+
50
+ /**
51
+ * Clears all stored payment records.
52
+ */
53
+ export function clearPaymentHistory(): void {
54
+ if (typeof window === 'undefined') return;
55
+ window.localStorage.removeItem(PAYMENT_HISTORY_KEY);
56
+ notifyHistoryUpdated();
57
+ }
58
+
59
+ export function formatSats(sats: number): string {
60
+ return `${sats.toLocaleString()} sats`;
61
+ }
62
+
63
+ export function truncatePreimage(preimage: string): string {
64
+ if (preimage.length <= 20) return preimage;
65
+ return `${preimage.slice(0, 12)}…${preimage.slice(-8)}`;
66
+ }
67
+
68
+ export function formatTimestamp(timestamp: number): string {
69
+ return new Date(timestamp).toLocaleString(undefined, {
70
+ month: 'short',
71
+ day: 'numeric',
72
+ hour: 'numeric',
73
+ minute: '2-digit',
74
+ });
75
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "webln-payments",
3
+ "description": "WebLN Lightning payment send and receive demos",
4
+ "dependencies": ["qrcode.react"],
5
+ "devDependencies": [],
6
+ "files": [
7
+ { "src": "components/webln/PayButton.tsx", "dest": "components/webln/PayButton.tsx", "merge": "add" },
8
+ { "src": "components/webln/InvoiceCard.tsx", "dest": "components/webln/InvoiceCard.tsx", "merge": "add" },
9
+ { "src": "components/webln/PaymentHistory.tsx", "dest": "components/webln/PaymentHistory.tsx", "merge": "add" },
10
+ { "src": "hooks/usePaymentFlow.ts", "dest": "hooks/usePaymentFlow.ts", "merge": "add" },
11
+ { "src": "lib/payment-history.ts", "dest": "lib/payment-history.ts", "merge": "add" },
12
+ { "src": "hooks/__tests__/usePaymentFlow.test.tsx", "dest": "hooks/__tests__/usePaymentFlow.test.tsx", "merge": "add" },
13
+ { "src": "tests/e2e/webln-payment.spec.ts", "dest": "tests/e2e/webln-payment.spec.ts", "merge": "add" },
14
+ { "src": "app/demo/webln/page.tsx", "dest": "app/demo/webln/page.tsx", "merge": "add" }
15
+ ],
16
+ "envVars": []
17
+ }
@@ -0,0 +1,41 @@
1
+ import { test, expect } from '@playwright/test';
2
+
3
+ /**
4
+ * Full WebLN payment flow using the dev MockWebLNProvider (enabled by default in Providers).
5
+ * InvoiceCard generates an invoice; PayButton pays it; success states appear in the UI.
6
+ */
7
+ test('webln demo: generate invoice, pay, and see success', async ({ page }) => {
8
+ await page.goto('/demo/webln');
9
+
10
+ await expect(page.getByRole('heading', { name: 'WebLN Payments' })).toBeVisible();
11
+
12
+ // Wait for invoice generation and QR to appear
13
+ await expect(page.getByRole('button', { name: /copy invoice/i })).toBeVisible({
14
+ timeout: 10_000,
15
+ });
16
+
17
+ // Pay the generated invoice
18
+ const payButton = page.getByRole('button', { name: /pay 21 sats/i });
19
+ await expect(payButton).toBeEnabled({ timeout: 10_000 });
20
+ await payButton.click();
21
+
22
+ // Success state with preimage
23
+ await expect(page.getByRole('status', { name: /payment of 21 sats sent successfully/i })).toBeVisible({
24
+ timeout: 10_000,
25
+ });
26
+
27
+ // Payment appears in local history
28
+ await expect(page.getByLabel('Recent payments')).toBeVisible();
29
+ await expect(page.getByText('Sent')).toBeVisible();
30
+ });
31
+
32
+ test('webln demo: how this works section expands', async ({ page }) => {
33
+ await page.goto('/demo/webln');
34
+
35
+ const toggle = page.getByRole('button', { name: 'How this works' });
36
+ await expect(toggle).toHaveAttribute('aria-expanded', 'false');
37
+
38
+ await toggle.click();
39
+ await expect(toggle).toHaveAttribute('aria-expanded', 'true');
40
+ await expect(page.getByText(/WebLN is a JavaScript standard/i)).toBeVisible();
41
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "create-fedi-app",
3
+ "version": "0.1.0",
4
+ "description": "CLI scaffolder for Fedi Bitcoin mini apps",
5
+ "bin": {
6
+ "create-fedi-app": "./dist/index.js"
7
+ },
8
+ "files": ["dist/"],
9
+ "keywords": ["fedi", "fedimint", "bitcoin", "lightning", "webln", "nostr", "mini-app", "nextjs"],
10
+ "license": "MIT",
11
+ "scripts": {
12
+ "build": "tsup && rm -rf dist/templates && cp -R ../../templates dist/templates",
13
+ "dev": "tsup --watch",
14
+ "typecheck": "tsc --noEmit",
15
+ "prepublishOnly": "bun run build && bun run typecheck"
16
+ },
17
+ "dependencies": {
18
+ "@clack/prompts": "^0.9.0",
19
+ "execa": "^9.5.0",
20
+ "fs-extra": "^11.2.0",
21
+ "picocolors": "^1.1.0",
22
+ "semver": "^7.6.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/fs-extra": "^11.0.4",
26
+ "@types/semver": "^7.5.8",
27
+ "tsup": "^8.3.0"
28
+ }
29
+ }