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,112 @@
1
+ import { randomBytes } from 'crypto';
2
+
3
+ export type TLnurlAuthSession = {
4
+ k1: string;
5
+ createdAt: number;
6
+ expiresAt: number;
7
+ pubkey: string | null;
8
+ authenticatedAt: number | null;
9
+ };
10
+
11
+ export type TLnurlWithdrawSession = {
12
+ k1: string;
13
+ createdAt: number;
14
+ expiresAt: number;
15
+ withdrawnMsats: number | null;
16
+ paymentRequest: string | null;
17
+ };
18
+
19
+ const authSessions = new Map<string, TLnurlAuthSession>();
20
+ const withdrawSessions = new Map<string, TLnurlWithdrawSession>();
21
+
22
+ const AUTH_TTL_MS = 10 * 60 * 1000;
23
+ const WITHDRAW_TTL_MS = 10 * 60 * 1000;
24
+
25
+ function generateK1(): string {
26
+ return randomBytes(32).toString('hex');
27
+ }
28
+
29
+ function buildMockInvoice(amountSats: number, id: string): string {
30
+ const amountPart = amountSats.toString(16).padStart(6, '0');
31
+ return `lnbc${amountPart}n1p${id.slice(0, 40)}`;
32
+ }
33
+
34
+ export function createAuthSession(): TLnurlAuthSession {
35
+ const k1 = generateK1();
36
+ const now = Date.now();
37
+ const session: TLnurlAuthSession = {
38
+ k1,
39
+ createdAt: now,
40
+ expiresAt: now + AUTH_TTL_MS,
41
+ pubkey: null,
42
+ authenticatedAt: null,
43
+ };
44
+ authSessions.set(k1, session);
45
+ return session;
46
+ }
47
+
48
+ export function getAuthSession(k1: string): TLnurlAuthSession | null {
49
+ const session = authSessions.get(k1);
50
+ if (!session) return null;
51
+ if (Date.now() > session.expiresAt) {
52
+ authSessions.delete(k1);
53
+ return null;
54
+ }
55
+ return session;
56
+ }
57
+
58
+ export function completeAuthSession(k1: string, pubkey: string): TLnurlAuthSession | null {
59
+ const session = getAuthSession(k1);
60
+ if (!session) return null;
61
+ session.pubkey = pubkey;
62
+ session.authenticatedAt = Date.now();
63
+ authSessions.set(k1, session);
64
+ return session;
65
+ }
66
+
67
+ export function createWithdrawSession(): TLnurlWithdrawSession {
68
+ const k1 = generateK1();
69
+ const now = Date.now();
70
+ const session: TLnurlWithdrawSession = {
71
+ k1,
72
+ createdAt: now,
73
+ expiresAt: now + WITHDRAW_TTL_MS,
74
+ withdrawnMsats: null,
75
+ paymentRequest: null,
76
+ };
77
+ withdrawSessions.set(k1, session);
78
+ return session;
79
+ }
80
+
81
+ export function getWithdrawSession(k1: string): TLnurlWithdrawSession | null {
82
+ const session = withdrawSessions.get(k1);
83
+ if (!session) return null;
84
+ if (Date.now() > session.expiresAt) {
85
+ withdrawSessions.delete(k1);
86
+ return null;
87
+ }
88
+ return session;
89
+ }
90
+
91
+ export function completeWithdrawSession(
92
+ k1: string,
93
+ paymentRequest: string,
94
+ amountMsats: number,
95
+ ): TLnurlWithdrawSession | null {
96
+ const session = getWithdrawSession(k1);
97
+ if (!session) return null;
98
+ session.paymentRequest = paymentRequest;
99
+ session.withdrawnMsats = amountMsats;
100
+ withdrawSessions.set(k1, session);
101
+ return session;
102
+ }
103
+
104
+ /**
105
+ * Creates a mock BOLT11 invoice for LNURL-pay callbacks.
106
+ * Replace with your Lightning node / LND / CLN integration in production.
107
+ */
108
+ export function createLnurlPayInvoice(amountMsats: number, username: string): string {
109
+ const amountSats = Math.max(1, Math.floor(amountMsats / 1000));
110
+ const id = randomBytes(8).toString('hex');
111
+ return buildMockInvoice(amountSats, `${username}-${id}`);
112
+ }
@@ -0,0 +1,56 @@
1
+ import { bech32 } from '@scure/base';
2
+
3
+ /** LNURL metadata item: [mime type, payload]. */
4
+ export type TLnurlMetadataItem = [string, string];
5
+
6
+ /**
7
+ * Encodes a URL as an uppercase bech32 LNURL string (BIP-173, HRP `lnurl`).
8
+ */
9
+ export function encodeLnurl(url: string): string {
10
+ const bytes = new TextEncoder().encode(url);
11
+ return bech32.encodeFromBytes('lnurl', bytes).toUpperCase();
12
+ }
13
+
14
+ /**
15
+ * Decodes a bech32 LNURL string back to the underlying HTTPS URL.
16
+ */
17
+ export function decodeLnurl(lnurl: string): string {
18
+ const { words } = bech32.decode(lnurl.toLowerCase());
19
+ const bytes = bech32.fromWords(words);
20
+ return new TextDecoder().decode(bytes);
21
+ }
22
+
23
+ /**
24
+ * Builds the comma-separated metadata string required by LUD-06.
25
+ * @see https://github.com/lnurl/luds/blob/luds/06.md
26
+ */
27
+ export function buildLnurlMetadata(items: TLnurlMetadataItem[]): string {
28
+ return JSON.stringify(items);
29
+ }
30
+
31
+ /**
32
+ * Resolves the public base URL for LNURL callbacks.
33
+ * Prefer `LNURL_SERVER_URL` in production behind proxies.
34
+ */
35
+ export function getLnurlServerBaseUrl(request?: Request): string {
36
+ const fromEnv = process.env.LNURL_SERVER_URL?.trim();
37
+ if (fromEnv) return fromEnv.replace(/\/$/, '');
38
+
39
+ if (request) {
40
+ const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
41
+ const proto = request.headers.get('x-forwarded-proto') ?? 'https';
42
+ if (host) return `${proto}://${host}`;
43
+ }
44
+
45
+ return 'http://localhost:3000';
46
+ }
47
+
48
+ /** Converts millisats to sats for display. */
49
+ export function msatsToSats(msats: number): number {
50
+ return Math.floor(msats / 1000);
51
+ }
52
+
53
+ /** Converts sats to millisats for LNURL amounts. */
54
+ export function satsToMsats(sats: number): number {
55
+ return sats * 1000;
56
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "lnurl",
3
+ "description": "LNURL-pay, LNURL-auth, and LNURL-withdraw flows",
4
+ "dependencies": ["qrcode.react"],
5
+ "devDependencies": [],
6
+ "files": [
7
+ { "src": "lib/lnurl-utils.ts", "dest": "lib/lnurl-utils.ts", "merge": "add" },
8
+ { "src": "lib/lnurl-store.ts", "dest": "lib/lnurl-store.ts", "merge": "add" },
9
+ { "src": "lib/lnurl-auth-verify.ts", "dest": "lib/lnurl-auth-verify.ts", "merge": "add" },
10
+ { "src": "components/lnurl/LnurlQR.tsx", "dest": "components/lnurl/LnurlQR.tsx", "merge": "add" },
11
+ { "src": "components/lnurl/LnurlPay.tsx", "dest": "components/lnurl/LnurlPay.tsx", "merge": "add" },
12
+ { "src": "components/lnurl/LnurlAuth.tsx", "dest": "components/lnurl/LnurlAuth.tsx", "merge": "add" },
13
+ { "src": "components/lnurl/LnurlWithdraw.tsx", "dest": "components/lnurl/LnurlWithdraw.tsx", "merge": "add" },
14
+ { "src": "app/api/lnurlp/[username]/route.ts", "dest": "app/api/lnurlp/[username]/route.ts", "merge": "add" },
15
+ { "src": "app/api/lnurlauth/route.ts", "dest": "app/api/lnurlauth/route.ts", "merge": "add" },
16
+ { "src": "app/api/lnurlw/route.ts", "dest": "app/api/lnurlw/route.ts", "merge": "add" },
17
+ { "src": "app/demo/lnurl/page.tsx", "dest": "app/demo/lnurl/page.tsx", "merge": "add" }
18
+ ],
19
+ "envVars": [
20
+ {
21
+ "key": "LNURL_SERVER_URL",
22
+ "description": "Public base URL for LNURL callbacks (required behind reverse proxies)",
23
+ "example": "https://your-app.example.com",
24
+ "required": false
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,109 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useState } from 'react';
5
+ import { MultispendDemo } from '../../../components/multispend/MultispendDemo';
6
+
7
+ export function MultispendDemoClient() {
8
+ const [howItWorksOpen, setHowItWorksOpen] = useState(false);
9
+
10
+ return (
11
+ <div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
12
+ <div
13
+ className="mx-auto w-full max-w-[390px] px-4 pt-6"
14
+ style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
15
+ >
16
+ <Link
17
+ href="/demo"
18
+ 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"
19
+ >
20
+ ← back
21
+ </Link>
22
+
23
+ <header className="mb-8 space-y-2">
24
+ <h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
25
+ Multispend
26
+ </h1>
27
+ <p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
28
+ Fedi&apos;s threshold multi-signature spending for shared Stable Balance funds. No single
29
+ person controls the wallet. A configured number of voters must approve each withdrawal.
30
+ </p>
31
+ </header>
32
+
33
+ <div className="space-y-8">
34
+ <section className="space-y-3 rounded-xl px-4 py-3 text-sm leading-[1.65]"
35
+ style={{
36
+ background: 'var(--color-surface-1)',
37
+ border: '1px solid var(--color-border)',
38
+ borderRadius: 'var(--radius-lg)',
39
+ color: 'var(--color-text-muted)',
40
+ }}
41
+ >
42
+ <p>
43
+ <strong className="text-[var(--color-text)]">Why it matters:</strong> Groups (DAOs,
44
+ communities, teams) need shared treasuries without trusting one admin. Multispend
45
+ enforces collective approval before funds move.
46
+ </p>
47
+ <p>
48
+ <strong className="text-[var(--color-text)]">This demo is mocked.</strong> The real
49
+ Multispend API is not yet exposed to mini apps. Votes here use{' '}
50
+ <code className="font-mono text-xs">signEvent()</code> to show how Nostr signatures
51
+ could attest to approvals; execution is simulated locally.
52
+ </p>
53
+ </section>
54
+
55
+ <MultispendDemo />
56
+
57
+ <section className="space-y-3">
58
+ <button
59
+ type="button"
60
+ onClick={() => setHowItWorksOpen((open) => !open)}
61
+ 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"
62
+ style={{
63
+ background: 'var(--color-surface-1)',
64
+ border: '1px solid var(--color-border)',
65
+ color: 'var(--color-text)',
66
+ borderRadius: 'var(--radius-lg)',
67
+ }}
68
+ aria-expanded={howItWorksOpen}
69
+ aria-controls="multispend-how-it-works"
70
+ >
71
+ How Multispend works in Fedi
72
+ <span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
73
+ </button>
74
+
75
+ {howItWorksOpen && (
76
+ <div
77
+ id="multispend-how-it-works"
78
+ className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
79
+ style={{
80
+ background: 'var(--color-surface-1)',
81
+ border: '1px solid var(--color-border)',
82
+ color: 'var(--color-text-muted)',
83
+ borderRadius: 'var(--radius-lg)',
84
+ }}
85
+ >
86
+ <p>
87
+ A group creates a Multispend wallet with up to 21 assigned{' '}
88
+ <strong className="text-[var(--color-text)]">voters</strong> and an{' '}
89
+ <strong className="text-[var(--color-text)]">approval threshold</strong> (e.g.
90
+ 2-of-3). Voters and threshold are fixed at creation.
91
+ </p>
92
+ <p>
93
+ Members deposit into the shared fund. To spend, a member submits a withdrawal
94
+ request. Voters approve or reject. Once the threshold is met, Fedi processes the
95
+ withdrawal automatically. Funds move to the requester&apos;s personal wallet.
96
+ </p>
97
+ <p>
98
+ Mini apps cannot yet create Multispend groups or submit real withdrawal requests.
99
+ This module demonstrates the approval UX pattern and Nostr-signed votes so you
100
+ can design flows before the API lands.
101
+ </p>
102
+ </div>
103
+ )}
104
+ </section>
105
+ </div>
106
+ </div>
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,23 @@
1
+ import type { Metadata } from 'next';
2
+ import { MultispendDemoClient } from './MultispendDemoClient';
3
+
4
+ export const metadata: Metadata = {
5
+ title: 'Multispend',
6
+ description:
7
+ 'Mock Multispend workflow for Fedi mini apps. Create spending proposals, collect Nostr-signed approvals, and simulate threshold execution for shared wallets.',
8
+ openGraph: {
9
+ title: 'Multispend',
10
+ description:
11
+ 'Mock Multispend workflow for Fedi mini apps. Create spending proposals, collect Nostr-signed approvals, and simulate threshold execution for shared wallets.',
12
+ },
13
+ twitter: {
14
+ card: 'summary',
15
+ title: 'Multispend',
16
+ description:
17
+ 'Mock Multispend workflow for Fedi mini apps. Create spending proposals, collect Nostr-signed approvals, and simulate threshold execution for shared wallets.',
18
+ },
19
+ };
20
+
21
+ export default function MultispendDemoPage() {
22
+ return <MultispendDemoClient />;
23
+ }
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useIdentity } from '../../lib/nostr';
5
+ import type { NostrEvent } from '../../lib/nostr';
6
+ import { MULTISPEND_VOTE_KIND, type TVoteDecision } from '../../lib/multispend-types';
7
+
8
+ interface IApprovalVoteProps {
9
+ proposalId: string;
10
+ voterPubkey: string;
11
+ currentPubkey: string | null;
12
+ disabled?: boolean;
13
+ onVote: (vote: TVoteDecision, signedEvent: NostrEvent) => void;
14
+ }
15
+
16
+ export function ApprovalVote({
17
+ proposalId,
18
+ voterPubkey,
19
+ currentPubkey,
20
+ disabled = false,
21
+ onVote,
22
+ }: IApprovalVoteProps) {
23
+ const { signEvent, isConnecting } = useIdentity();
24
+ const [isVoting, setIsVoting] = useState(false);
25
+ const [voteError, setVoteError] = useState<Error | null>(null);
26
+ const [lastVote, setLastVote] = useState<TVoteDecision | null>(null);
27
+
28
+ const isCurrentVoter = currentPubkey === voterPubkey;
29
+
30
+ async function castVote(vote: TVoteDecision) {
31
+ if (!currentPubkey || !isCurrentVoter) return;
32
+
33
+ setIsVoting(true);
34
+ setVoteError(null);
35
+
36
+ try {
37
+ const signedEvent = await signEvent({
38
+ kind: MULTISPEND_VOTE_KIND,
39
+ content: JSON.stringify({ proposalId, vote }),
40
+ tags: [
41
+ ['d', proposalId],
42
+ ['vote', vote],
43
+ ],
44
+ created_at: Math.floor(Date.now() / 1000),
45
+ });
46
+
47
+ if (!signedEvent) {
48
+ setVoteError(new Error('Could not sign vote. Connect your Nostr identity first.'));
49
+ return;
50
+ }
51
+
52
+ setLastVote(vote);
53
+ onVote(vote, signedEvent);
54
+ } catch (err) {
55
+ setVoteError(err instanceof Error ? err : new Error('Vote signing failed'));
56
+ } finally {
57
+ setIsVoting(false);
58
+ }
59
+ }
60
+
61
+ if (!isCurrentVoter) {
62
+ return null;
63
+ }
64
+
65
+ if (lastVote) {
66
+ return (
67
+ <p
68
+ className="text-xs font-medium"
69
+ style={{ color: lastVote === 'approve' ? 'var(--color-accent)' : 'var(--color-error, #ef4444)' }}
70
+ role="status"
71
+ >
72
+ You {lastVote === 'approve' ? 'approved' : 'rejected'} this proposal via Nostr signature.
73
+ </p>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <div className="flex flex-col gap-2">
79
+ <div className="flex flex-wrap gap-2">
80
+ <button
81
+ type="button"
82
+ onClick={() => castVote('approve')}
83
+ disabled={disabled || isVoting || isConnecting}
84
+ className="rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
85
+ style={{
86
+ background: 'var(--color-accent)',
87
+ color: 'var(--color-primary-foreground)',
88
+ borderRadius: 'var(--radius-md)',
89
+ }}
90
+ aria-label="Approve spending proposal"
91
+ >
92
+ {isVoting ? 'Signing…' : 'Approve'}
93
+ </button>
94
+ <button
95
+ type="button"
96
+ onClick={() => castVote('reject')}
97
+ disabled={disabled || isVoting || isConnecting}
98
+ className="rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
99
+ style={{
100
+ background: 'var(--color-surface-1)',
101
+ border: '1px solid var(--color-border)',
102
+ color: 'var(--color-text)',
103
+ borderRadius: 'var(--radius-md)',
104
+ }}
105
+ aria-label="Reject spending proposal"
106
+ >
107
+ Reject
108
+ </button>
109
+ </div>
110
+ <p className="text-xs leading-[1.65] text-[var(--color-text-subtle)]">
111
+ Votes are signed with{' '}
112
+ <code className="font-mono text-[11px]">signEvent()</code> (kind {MULTISPEND_VOTE_KIND}).
113
+ Mock co-voters auto-approve after your signature so you can complete the demo flow.
114
+ </p>
115
+ {voteError && (
116
+ <p className="text-xs text-[var(--color-error,#ef4444)]" role="alert">
117
+ {voteError.message}
118
+ </p>
119
+ )}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,220 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { useIdentity } from '../../lib/nostr';
5
+ import { IdentityBadge } from '../nostr/IdentityBadge';
6
+ import { useMultispendDemo } from '../../hooks/useMultispendDemo';
7
+ import { ProposalList } from './ProposalList';
8
+
9
+ export function MultispendDemo() {
10
+ const { pubkey, getPublicKey, isConnecting } = useIdentity();
11
+ const [localPubkey, setLocalPubkey] = useState<string | null>(null);
12
+ const [amountSats, setAmountSats] = useState('25000');
13
+ const [description, setDescription] = useState('');
14
+ const [createError, setCreateError] = useState<string | null>(null);
15
+
16
+ const {
17
+ proposals,
18
+ openProposals,
19
+ lastSignedEvent,
20
+ executingId,
21
+ createProposal,
22
+ recordVote,
23
+ simulateExecution,
24
+ } = useMultispendDemo();
25
+
26
+ const activePubkey = pubkey ?? localPubkey;
27
+
28
+ async function ensureConnected(): Promise<string | null> {
29
+ if (activePubkey) return activePubkey;
30
+ const pk = await getPublicKey();
31
+ if (pk) setLocalPubkey(pk);
32
+ return pk;
33
+ }
34
+
35
+ async function handleCreateProposal(event: React.FormEvent) {
36
+ event.preventDefault();
37
+ setCreateError(null);
38
+
39
+ const proposerPubkey = await ensureConnected();
40
+ if (!proposerPubkey) {
41
+ setCreateError('Connect your Nostr identity to create a proposal.');
42
+ return;
43
+ }
44
+
45
+ const parsedAmount = Number.parseInt(amountSats, 10);
46
+ if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
47
+ setCreateError('Enter a valid amount in sats.');
48
+ return;
49
+ }
50
+
51
+ if (!description.trim()) {
52
+ setCreateError('Add a description for the spending request.');
53
+ return;
54
+ }
55
+
56
+ createProposal({
57
+ amountSats: parsedAmount,
58
+ description: description.trim(),
59
+ proposerPubkey,
60
+ });
61
+
62
+ setDescription('');
63
+ setAmountSats('25000');
64
+ }
65
+
66
+ return (
67
+ <div className="space-y-8">
68
+ <section className="space-y-4">
69
+ <div className="max-w-[75ch] space-y-1.5">
70
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
71
+ Your identity
72
+ </h2>
73
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
74
+ Connect with Nostr to vote on proposals. Your pubkey becomes one of the required signers
75
+ when you create a request.
76
+ </p>
77
+ </div>
78
+ <IdentityBadge />
79
+ </section>
80
+
81
+ <section className="space-y-4">
82
+ <div className="max-w-[75ch] space-y-1.5">
83
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
84
+ Create proposal
85
+ </h2>
86
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
87
+ Mock a withdrawal request from a shared Multispend wallet. Other voters are simulated.
88
+ Only your vote uses a real Nostr signature.
89
+ </p>
90
+ </div>
91
+
92
+ <form
93
+ onSubmit={handleCreateProposal}
94
+ className="space-y-4 rounded-xl p-4"
95
+ style={{
96
+ background: 'var(--color-surface-1)',
97
+ border: '1px solid var(--color-border)',
98
+ borderRadius: 'var(--radius-lg)',
99
+ }}
100
+ >
101
+ <label className="flex flex-col gap-1.5">
102
+ <span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
103
+ Amount (sats)
104
+ </span>
105
+ <input
106
+ type="number"
107
+ min={1}
108
+ step={1}
109
+ value={amountSats}
110
+ onChange={(e) => setAmountSats(e.target.value)}
111
+ className="w-full rounded-lg px-3 py-2 text-sm"
112
+ style={{
113
+ background: 'var(--color-surface-2, var(--color-bg))',
114
+ border: '1px solid var(--color-border)',
115
+ color: 'var(--color-text)',
116
+ borderRadius: 'var(--radius-md)',
117
+ }}
118
+ />
119
+ </label>
120
+
121
+ <label className="flex flex-col gap-1.5">
122
+ <span className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
123
+ Description
124
+ </span>
125
+ <textarea
126
+ value={description}
127
+ onChange={(e) => setDescription(e.target.value)}
128
+ rows={3}
129
+ placeholder="What is this withdrawal for?"
130
+ className="w-full resize-none rounded-lg px-3 py-2 text-sm"
131
+ style={{
132
+ background: 'var(--color-surface-2, var(--color-bg))',
133
+ border: '1px solid var(--color-border)',
134
+ color: 'var(--color-text)',
135
+ borderRadius: 'var(--radius-md)',
136
+ }}
137
+ />
138
+ </label>
139
+
140
+ <p className="text-xs leading-[1.65] text-[var(--color-text-subtle)]">
141
+ New proposals use a 2-of-3 threshold with you plus two mock voters (Alice and Bob).
142
+ </p>
143
+
144
+ {createError && (
145
+ <p className="text-xs text-[var(--color-error,#ef4444)]" role="alert">
146
+ {createError}
147
+ </p>
148
+ )}
149
+
150
+ <button
151
+ type="submit"
152
+ disabled={isConnecting}
153
+ className="rounded-lg px-4 py-2 text-sm font-semibold transition-opacity duration-200 ease-[cubic-bezier(0.25,1,0.5,1)] hover:opacity-80 active:opacity-70 disabled:cursor-not-allowed disabled:opacity-40"
154
+ style={{
155
+ background: 'var(--color-accent)',
156
+ color: 'var(--color-primary-foreground)',
157
+ borderRadius: 'var(--radius-md)',
158
+ }}
159
+ >
160
+ Create proposal
161
+ </button>
162
+ </form>
163
+ </section>
164
+
165
+ <section className="space-y-4">
166
+ <div className="max-w-[75ch] space-y-1.5">
167
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
168
+ Open proposals
169
+ </h2>
170
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
171
+ Approve or reject pending requests. When enough voters agree, simulate the withdrawal
172
+ execution.
173
+ </p>
174
+ </div>
175
+ <ProposalList
176
+ proposals={openProposals}
177
+ currentPubkey={activePubkey}
178
+ onVote={(proposalId, vote, signedEvent) =>
179
+ recordVote(proposalId, activePubkey!, vote, signedEvent)
180
+ }
181
+ onExecute={simulateExecution}
182
+ executingId={executingId}
183
+ />
184
+ </section>
185
+
186
+ {proposals.some((p) => p.status === 'executed') && (
187
+ <section className="space-y-4">
188
+ <div className="max-w-[75ch] space-y-1.5">
189
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
190
+ Executed
191
+ </h2>
192
+ </div>
193
+ <ProposalList
194
+ proposals={proposals.filter((p) => p.status === 'executed')}
195
+ currentPubkey={activePubkey}
196
+ />
197
+ </section>
198
+ )}
199
+
200
+ {lastSignedEvent && (
201
+ <section className="space-y-2">
202
+ <p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
203
+ Last signed vote event
204
+ </p>
205
+ <pre
206
+ className="overflow-auto rounded-lg p-3 font-mono text-xs leading-relaxed"
207
+ style={{
208
+ background: 'var(--color-surface-1)',
209
+ border: '1px solid var(--color-border)',
210
+ color: 'var(--color-text-muted)',
211
+ borderRadius: 'var(--radius-md)',
212
+ }}
213
+ >
214
+ {JSON.stringify(lastSignedEvent, null, 2)}
215
+ </pre>
216
+ </section>
217
+ )}
218
+ </div>
219
+ );
220
+ }