create-fedi-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +11113 -0
- package/dist/templates/base/.env.example +5 -0
- package/dist/templates/base/app/demo/page.tsx +25 -0
- package/dist/templates/base/app/globals.css +95 -0
- package/dist/templates/base/app/layout.tsx +39 -0
- package/dist/templates/base/app/page.tsx +83 -0
- package/dist/templates/base/components/FediDevToolbar/FediDevToolbar.tsx +170 -0
- package/dist/templates/base/components/providers.tsx +63 -0
- package/dist/templates/base/env.ts +10 -0
- package/dist/templates/base/hooks/useFediInternal.ts +41 -0
- package/dist/templates/base/lib/fedi-types.ts +96 -0
- package/dist/templates/base/lib/fedi.ts +18 -0
- package/dist/templates/base/lib/nostr/hooks.ts +52 -0
- package/dist/templates/base/lib/nostr/index.ts +9 -0
- package/dist/templates/base/lib/nostr/mock.ts +60 -0
- package/dist/templates/base/lib/nostr/provider.tsx +64 -0
- package/dist/templates/base/lib/utils.ts +3 -0
- package/dist/templates/base/lib/webln/hooks.ts +67 -0
- package/dist/templates/base/lib/webln/index.ts +12 -0
- package/dist/templates/base/lib/webln/mock.ts +96 -0
- package/dist/templates/base/lib/webln/provider.tsx +52 -0
- package/dist/templates/base/next.config.ts +3 -0
- package/dist/templates/base/package.json +40 -0
- package/dist/templates/base/proxy.ts +8 -0
- package/dist/templates/base/tsconfig.json +20 -0
- package/dist/templates/base/vitest.config.ts +6 -0
- package/dist/templates/base/vitest.setup.ts +40 -0
- package/dist/templates/modules/ai-assistant/app/api/assistant/route.ts +45 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/AssistantDemoClient.tsx +70 -0
- package/dist/templates/modules/ai-assistant/app/demo/assistant/page.tsx +23 -0
- package/dist/templates/modules/ai-assistant/components/ai/Assistant.tsx +220 -0
- package/dist/templates/modules/ai-assistant/components/ai/AssistantProvider.tsx +71 -0
- package/dist/templates/modules/ai-assistant/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-assistant/module.json +48 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/invoice/route.ts +15 -0
- package/dist/templates/modules/ai-chat-gated/app/api/chat/route.ts +57 -0
- package/dist/templates/modules/ai-chat-gated/app/demo/ai-chat/page.tsx +58 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/ChatMessage.tsx +50 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/GatedChat.tsx +181 -0
- package/dist/templates/modules/ai-chat-gated/components/ai/PaymentGate.tsx +168 -0
- package/dist/templates/modules/ai-chat-gated/lib/ai/providers.ts +49 -0
- package/dist/templates/modules/ai-chat-gated/lib/chat-payment.ts +161 -0
- package/dist/templates/modules/ai-chat-gated/module.json +62 -0
- package/dist/templates/modules/ai-rules/.cursorrules +8 -0
- package/dist/templates/modules/ai-rules/.github/copilot-instructions.md +8 -0
- package/dist/templates/modules/ai-rules/CLAUDE.md +8 -0
- package/dist/templates/modules/ai-rules/module.json +20 -0
- package/dist/templates/modules/ai-rules/rules/OVERVIEW.md +56 -0
- package/dist/templates/modules/ai-rules/rules/architecture.md +108 -0
- package/dist/templates/modules/ai-rules/rules/design-system.md +94 -0
- package/dist/templates/modules/ai-rules/rules/fedi-api.md +120 -0
- package/dist/templates/modules/ai-rules/rules/nostr.md +232 -0
- package/dist/templates/modules/ai-rules/rules/patterns.md +408 -0
- package/dist/templates/modules/ai-rules/rules/testing.md +238 -0
- package/dist/templates/modules/ai-rules/rules/webln.md +241 -0
- package/dist/templates/modules/database/drizzle/supabase/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/supabase/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle/turso/0000_initial.sql +7 -0
- package/dist/templates/modules/database/drizzle/turso/meta/_journal.json +13 -0
- package/dist/templates/modules/database/drizzle.config.supabase.ts +10 -0
- package/dist/templates/modules/database/drizzle.config.turso.ts +11 -0
- package/dist/templates/modules/database/env.supabase.ts +24 -0
- package/dist/templates/modules/database/env.turso.ts +23 -0
- package/dist/templates/modules/database/lib/db/index.supabase.ts +19 -0
- package/dist/templates/modules/database/lib/db/index.turso.ts +20 -0
- package/dist/templates/modules/database/lib/db/schema.supabase.ts +13 -0
- package/dist/templates/modules/database/lib/db/schema.turso.ts +13 -0
- package/dist/templates/modules/database/module.json +110 -0
- package/dist/templates/modules/ecash-balance/app/demo/ecash/page.tsx +115 -0
- package/dist/templates/modules/ecash-balance/components/fedi/BalanceDisplay.tsx +162 -0
- package/dist/templates/modules/ecash-balance/components/fedi/FediVersionBadge.tsx +39 -0
- package/dist/templates/modules/ecash-balance/components/fedi/InstallMiniAppButton.tsx +74 -0
- package/dist/templates/modules/ecash-balance/hooks/useFediBalance.ts +65 -0
- package/dist/templates/modules/ecash-balance/module.json +14 -0
- package/dist/templates/modules/lnurl/app/api/lnurlauth/route.ts +118 -0
- package/dist/templates/modules/lnurl/app/api/lnurlp/[username]/route.ts +70 -0
- package/dist/templates/modules/lnurl/app/api/lnurlw/route.ts +57 -0
- package/dist/templates/modules/lnurl/app/demo/lnurl/page.tsx +136 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlAuth.tsx +156 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlPay.tsx +36 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlQR.tsx +96 -0
- package/dist/templates/modules/lnurl/components/lnurl/LnurlWithdraw.tsx +141 -0
- package/dist/templates/modules/lnurl/lib/lnurl-auth-verify.ts +87 -0
- package/dist/templates/modules/lnurl/lib/lnurl-store.ts +112 -0
- package/dist/templates/modules/lnurl/lib/lnurl-utils.ts +56 -0
- package/dist/templates/modules/lnurl/module.json +27 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/MultispendDemoClient.tsx +109 -0
- package/dist/templates/modules/multispend-demo/app/demo/multispend/page.tsx +23 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ApprovalVote.tsx +122 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendDemo.tsx +220 -0
- package/dist/templates/modules/multispend-demo/components/multispend/MultispendProposal.tsx +213 -0
- package/dist/templates/modules/multispend-demo/components/multispend/ProposalList.tsx +49 -0
- package/dist/templates/modules/multispend-demo/hooks/useMultispendDemo.ts +127 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-types.ts +33 -0
- package/dist/templates/modules/multispend-demo/lib/multispend-utils.ts +69 -0
- package/dist/templates/modules/multispend-demo/module.json +18 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/NostrFeedDemoClient.tsx +134 -0
- package/dist/templates/modules/nostr-feed/app/demo/nostr-feed/page.tsx +23 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NostrFeedProvider.tsx +47 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteCard.tsx +68 -0
- package/dist/templates/modules/nostr-feed/components/nostr/NoteFeed.tsx +109 -0
- package/dist/templates/modules/nostr-feed/components/nostr/PublishNote.tsx +104 -0
- package/dist/templates/modules/nostr-feed/components/nostr/ZapButton.tsx +140 -0
- package/dist/templates/modules/nostr-feed/lib/nostr/relay.ts +107 -0
- package/dist/templates/modules/nostr-feed/lib/nostr-zap.ts +159 -0
- package/dist/templates/modules/nostr-feed/module.json +25 -0
- package/dist/templates/modules/nostr-identity/app/demo/nostr/page.tsx +136 -0
- package/dist/templates/modules/nostr-identity/components/nostr/IdentityBadge.tsx +109 -0
- package/dist/templates/modules/nostr-identity/components/nostr/NostrLogin.tsx +107 -0
- package/dist/templates/modules/nostr-identity/components/nostr/SignedMessage.tsx +103 -0
- package/dist/templates/modules/nostr-identity/hooks/useIdentityFlow.ts +61 -0
- package/dist/templates/modules/nostr-identity/lib/nostr-utils.ts +30 -0
- package/dist/templates/modules/nostr-identity/module.json +15 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/invoice/route.ts +25 -0
- package/dist/templates/modules/payment-gated-content/app/api/payment-gate/verify/route.ts +39 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/article/page.tsx +71 -0
- package/dist/templates/modules/payment-gated-content/app/demo/payment-gated/page.tsx +134 -0
- package/dist/templates/modules/payment-gated-content/components/payment-gated/PayGate.tsx +267 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-gate.ts +195 -0
- package/dist/templates/modules/payment-gated-content/lib/payment-store.ts +104 -0
- package/dist/templates/modules/payment-gated-content/module.json +24 -0
- package/dist/templates/modules/payment-gated-content/proxy.ts +27 -0
- package/dist/templates/modules/webln-payments/app/demo/webln/page.tsx +176 -0
- package/dist/templates/modules/webln-payments/components/webln/InvoiceCard.tsx +170 -0
- package/dist/templates/modules/webln-payments/components/webln/PayButton.tsx +92 -0
- package/dist/templates/modules/webln-payments/components/webln/PaymentHistory.tsx +102 -0
- package/dist/templates/modules/webln-payments/hooks/__tests__/usePaymentFlow.test.tsx +182 -0
- package/dist/templates/modules/webln-payments/hooks/usePaymentFlow.ts +100 -0
- package/dist/templates/modules/webln-payments/lib/payment-history.ts +75 -0
- package/dist/templates/modules/webln-payments/module.json +17 -0
- package/dist/templates/modules/webln-payments/tests/e2e/webln-payment.spec.ts +41 -0
- package/package.json +29 -0
|
@@ -0,0 +1,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
|
+
}
|