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,213 @@
1
+ 'use client';
2
+
3
+ import { formatSats } from '../../lib/fedi';
4
+ import { pubkeyToHsl, pubkeyToNpub, truncateNpub } from '../../lib/nostr-utils';
5
+ import type { NostrEvent } from '../../lib/nostr';
6
+ import type { TMultispendProposal, TVoteDecision } from '../../lib/multispend-types';
7
+ import { getApprovalCount, getMockVoterLabel } from '../../lib/multispend-utils';
8
+ import { ApprovalVote } from './ApprovalVote';
9
+
10
+ interface IMultispendProposalProps {
11
+ proposal: TMultispendProposal;
12
+ currentPubkey?: string | null;
13
+ onVote?: (vote: TVoteDecision, signedEvent: NostrEvent) => void;
14
+ onExecute?: () => void;
15
+ isExecuting?: boolean;
16
+ }
17
+
18
+ const STATUS_LABELS: Record<TMultispendProposal['status'], string> = {
19
+ open: 'Awaiting votes',
20
+ approved: 'Threshold met',
21
+ rejected: 'Rejected',
22
+ executed: 'Executed',
23
+ };
24
+
25
+ export function MultispendProposal({
26
+ proposal,
27
+ currentPubkey = null,
28
+ onVote,
29
+ onExecute,
30
+ isExecuting = false,
31
+ }: IMultispendProposalProps) {
32
+ const approvalCount = getApprovalCount(proposal);
33
+ const progress = Math.min(100, (approvalCount / proposal.threshold) * 100);
34
+
35
+ return (
36
+ <article
37
+ className="space-y-4 rounded-xl p-4"
38
+ style={{
39
+ background: 'var(--color-surface-1)',
40
+ border: '1px solid var(--color-border)',
41
+ borderRadius: 'var(--radius-lg)',
42
+ }}
43
+ aria-label={`Spending proposal: ${proposal.description}`}
44
+ >
45
+ <header className="flex items-start justify-between gap-3">
46
+ <div className="min-w-0 space-y-1">
47
+ <p className="font-[family-name:var(--font-display)] text-lg font-semibold leading-tight text-[var(--color-text)]">
48
+ {formatSats(proposal.amountSats)}
49
+ </p>
50
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
51
+ {proposal.description}
52
+ </p>
53
+ </div>
54
+ <span
55
+ className="shrink-0 rounded px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"
56
+ style={{
57
+ background:
58
+ proposal.status === 'executed'
59
+ ? 'var(--color-accent-dim)'
60
+ : proposal.status === 'rejected'
61
+ ? 'color-mix(in srgb, var(--color-error, #ef4444) 15%, transparent)'
62
+ : 'var(--color-surface-2, var(--color-bg))',
63
+ color:
64
+ proposal.status === 'executed'
65
+ ? 'var(--color-accent)'
66
+ : proposal.status === 'rejected'
67
+ ? 'var(--color-error, #ef4444)'
68
+ : 'var(--color-text-muted)',
69
+ border: '1px solid var(--color-border)',
70
+ }}
71
+ >
72
+ {STATUS_LABELS[proposal.status]}
73
+ </span>
74
+ </header>
75
+
76
+ <div className="space-y-2">
77
+ <div className="flex items-center justify-between text-xs">
78
+ <span className="font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
79
+ Approvals
80
+ </span>
81
+ <span className="font-mono text-[var(--color-text-muted)]">
82
+ {approvalCount} / {proposal.threshold} required
83
+ </span>
84
+ </div>
85
+ <div
86
+ className="h-2 overflow-hidden rounded-full"
87
+ style={{ background: 'var(--color-surface-2, var(--color-bg))' }}
88
+ role="progressbar"
89
+ aria-valuenow={approvalCount}
90
+ aria-valuemin={0}
91
+ aria-valuemax={proposal.threshold}
92
+ aria-label={`${approvalCount} of ${proposal.threshold} approvals collected`}
93
+ >
94
+ <div
95
+ className="h-full rounded-full transition-[width] duration-300 ease-[cubic-bezier(0.25,1,0.5,1)]"
96
+ style={{
97
+ width: `${progress}%`,
98
+ background: 'var(--color-accent)',
99
+ }}
100
+ />
101
+ </div>
102
+ </div>
103
+
104
+ <div className="space-y-2">
105
+ <p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-subtle)]">
106
+ Required signers
107
+ </p>
108
+ <ul className="space-y-2" aria-label="Required signers">
109
+ {proposal.requiredSigners.map((signerPubkey) => {
110
+ const approved = proposal.approvals.includes(signerPubkey);
111
+ const rejected = proposal.rejections.includes(signerPubkey);
112
+ const isYou = currentPubkey === signerPubkey;
113
+ const { h, s, l } = pubkeyToHsl(signerPubkey);
114
+ const label = isYou ? 'You' : getMockVoterLabel(signerPubkey);
115
+ const npub = truncateNpub(pubkeyToNpub(signerPubkey));
116
+
117
+ return (
118
+ <li
119
+ key={signerPubkey}
120
+ className="flex items-center gap-3 rounded-lg px-3 py-2"
121
+ style={{
122
+ background: 'var(--color-surface-2, var(--color-bg))',
123
+ border: '1px solid var(--color-border)',
124
+ borderRadius: 'var(--radius-md)',
125
+ }}
126
+ >
127
+ <span
128
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
129
+ style={{
130
+ background: `hsl(${h}, ${s}%, ${l}%)`,
131
+ color: 'var(--color-primary-foreground)',
132
+ }}
133
+ aria-hidden
134
+ >
135
+ {label.slice(0, 1)}
136
+ </span>
137
+ <div className="min-w-0 flex-1">
138
+ <p className="text-sm font-medium text-[var(--color-text)]">
139
+ {label}
140
+ {isYou && (
141
+ <span className="ml-1.5 text-xs font-normal text-[var(--color-text-muted)]">
142
+ (your key)
143
+ </span>
144
+ )}
145
+ </p>
146
+ <p className="truncate font-mono text-xs text-[var(--color-text-subtle)]">{npub}</p>
147
+ </div>
148
+ <span
149
+ className="shrink-0 text-xs font-semibold"
150
+ style={{
151
+ color: approved
152
+ ? 'var(--color-accent)'
153
+ : rejected
154
+ ? 'var(--color-error, #ef4444)'
155
+ : 'var(--color-text-subtle)',
156
+ }}
157
+ >
158
+ {approved ? 'Approved' : rejected ? 'Rejected' : 'Pending'}
159
+ </span>
160
+ </li>
161
+ );
162
+ })}
163
+ </ul>
164
+ </div>
165
+
166
+ {proposal.status === 'open' &&
167
+ currentPubkey &&
168
+ proposal.requiredSigners.includes(currentPubkey) &&
169
+ !proposal.approvals.includes(currentPubkey) &&
170
+ !proposal.rejections.includes(currentPubkey) &&
171
+ onVote && (
172
+ <ApprovalVote
173
+ proposalId={proposal.id}
174
+ voterPubkey={currentPubkey}
175
+ currentPubkey={currentPubkey}
176
+ onVote={onVote}
177
+ />
178
+ )}
179
+
180
+ {proposal.status === 'approved' && onExecute && (
181
+ <button
182
+ type="button"
183
+ onClick={onExecute}
184
+ disabled={isExecuting}
185
+ className="w-full rounded-lg px-4 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:cursor-not-allowed disabled:opacity-40"
186
+ style={{
187
+ background: 'var(--color-accent)',
188
+ color: 'var(--color-primary-foreground)',
189
+ borderRadius: 'var(--radius-md)',
190
+ }}
191
+ aria-busy={isExecuting}
192
+ >
193
+ {isExecuting ? 'Executing withdrawal…' : 'Simulate execution'}
194
+ </button>
195
+ )}
196
+
197
+ {proposal.status === 'executed' && (
198
+ <p
199
+ className="rounded-lg px-3 py-2 text-xs leading-[1.65]"
200
+ style={{
201
+ background: 'var(--color-accent-dim)',
202
+ color: 'var(--color-accent)',
203
+ borderRadius: 'var(--radius-md)',
204
+ }}
205
+ role="status"
206
+ >
207
+ Withdrawal simulated. In Fedi, funds would move to the requester&apos;s wallet once the
208
+ threshold is met.
209
+ </p>
210
+ )}
211
+ </article>
212
+ );
213
+ }
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import type { NostrEvent } from '../../lib/nostr';
4
+ import type { TMultispendProposal, TVoteDecision } from '../../lib/multispend-types';
5
+ import { MultispendProposal } from './MultispendProposal';
6
+
7
+ interface IProposalListProps {
8
+ proposals: TMultispendProposal[];
9
+ currentPubkey?: string | null;
10
+ onVote?: (proposalId: string, vote: TVoteDecision, signedEvent: NostrEvent) => void;
11
+ onExecute?: (proposalId: string) => void;
12
+ executingId?: string | null;
13
+ emptyMessage?: string;
14
+ }
15
+
16
+ export function ProposalList({
17
+ proposals,
18
+ currentPubkey = null,
19
+ onVote,
20
+ onExecute,
21
+ executingId = null,
22
+ emptyMessage = 'No open proposals.',
23
+ }: IProposalListProps) {
24
+ if (proposals.length === 0) {
25
+ return (
26
+ <p className="text-sm leading-[1.65] text-[var(--color-text-subtle)]">{emptyMessage}</p>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <ul className="space-y-4" aria-label="Multispend proposals">
32
+ {proposals.map((proposal) => (
33
+ <li key={proposal.id}>
34
+ <MultispendProposal
35
+ proposal={proposal}
36
+ currentPubkey={currentPubkey}
37
+ onVote={
38
+ onVote
39
+ ? (vote, signedEvent) => onVote(proposal.id, vote, signedEvent)
40
+ : undefined
41
+ }
42
+ onExecute={onExecute ? () => onExecute(proposal.id) : undefined}
43
+ isExecuting={executingId === proposal.id}
44
+ />
45
+ </li>
46
+ ))}
47
+ </ul>
48
+ );
49
+ }
@@ -0,0 +1,127 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useState } from 'react';
4
+ import type { NostrEvent } from '../lib/nostr';
5
+ import {
6
+ createProposalId,
7
+ getInitialProposals,
8
+ hasVoted,
9
+ isThresholdMet,
10
+ MOCK_VOTERS,
11
+ } from '../lib/multispend-utils';
12
+ import type { TMultispendProposal, TMultispendVote, TProposalStatus, TVoteDecision } from '../lib/multispend-types';
13
+
14
+ export function useMultispendDemo() {
15
+ const [proposals, setProposals] = useState<TMultispendProposal[]>(() => getInitialProposals());
16
+ const [votes, setVotes] = useState<TMultispendVote[]>([]);
17
+ const [lastSignedEvent, setLastSignedEvent] = useState<NostrEvent | null>(null);
18
+ const [executingId, setExecutingId] = useState<string | null>(null);
19
+
20
+ const createProposal = useCallback(
21
+ (input: { amountSats: number; description: string; proposerPubkey: string }) => {
22
+ const signers = [
23
+ input.proposerPubkey,
24
+ ...MOCK_VOTERS.map((v) => v.pubkey).filter((pk) => pk !== input.proposerPubkey),
25
+ ].slice(0, 3);
26
+
27
+ const threshold = Math.min(2, signers.length);
28
+ const proposal: TMultispendProposal = {
29
+ id: createProposalId(),
30
+ amountSats: input.amountSats,
31
+ description: input.description.trim(),
32
+ requiredSigners: signers,
33
+ threshold,
34
+ approvals: [],
35
+ rejections: [],
36
+ status: 'open',
37
+ createdAt: Date.now(),
38
+ proposerPubkey: input.proposerPubkey,
39
+ };
40
+
41
+ setProposals((current) => [proposal, ...current]);
42
+ return proposal;
43
+ },
44
+ [],
45
+ );
46
+
47
+ const recordVote = useCallback(
48
+ (proposalId: string, voterPubkey: string, vote: TVoteDecision, signedEvent: NostrEvent) => {
49
+ setLastSignedEvent(signedEvent);
50
+ setVotes((current) => [
51
+ ...current.filter((v) => !(v.proposalId === proposalId && v.voterPubkey === voterPubkey)),
52
+ { proposalId, voterPubkey, vote, signedEvent },
53
+ ]);
54
+
55
+ setProposals((current) =>
56
+ current.map((proposal) => {
57
+ if (proposal.id !== proposalId || proposal.status !== 'open') return proposal;
58
+ if (hasVoted(proposal, voterPubkey)) return proposal;
59
+
60
+ let approvals =
61
+ vote === 'approve'
62
+ ? [...proposal.approvals, voterPubkey]
63
+ : proposal.approvals;
64
+ let rejections =
65
+ vote === 'reject'
66
+ ? [...proposal.rejections, voterPubkey]
67
+ : proposal.rejections;
68
+
69
+ // Demo: mock co-voters auto-approve after a real Nostr signature
70
+ if (vote === 'approve' && approvals.length < proposal.threshold) {
71
+ const mockCoSigner = proposal.requiredSigners.find(
72
+ (pk) =>
73
+ pk !== voterPubkey &&
74
+ MOCK_VOTERS.some((m) => m.pubkey === pk) &&
75
+ !approvals.includes(pk) &&
76
+ !rejections.includes(pk),
77
+ );
78
+ if (mockCoSigner) {
79
+ approvals = [...approvals, mockCoSigner];
80
+ }
81
+ }
82
+
83
+ const allVoted =
84
+ approvals.length + rejections.length >= proposal.requiredSigners.length;
85
+ const rejected =
86
+ rejections.length > proposal.requiredSigners.length - proposal.threshold;
87
+
88
+ let status: TProposalStatus = proposal.status;
89
+ if (isThresholdMet({ ...proposal, approvals })) {
90
+ status = 'approved';
91
+ } else if (rejected || (allVoted && !isThresholdMet({ ...proposal, approvals }))) {
92
+ status = 'rejected';
93
+ }
94
+
95
+ return { ...proposal, approvals, rejections, status };
96
+ }),
97
+ );
98
+ },
99
+ [],
100
+ );
101
+
102
+ const simulateExecution = useCallback(async (proposalId: string) => {
103
+ setExecutingId(proposalId);
104
+ await new Promise((resolve) => window.setTimeout(resolve, 1200));
105
+ setProposals((current) =>
106
+ current.map((proposal) =>
107
+ proposal.id === proposalId && proposal.status === 'approved'
108
+ ? { ...proposal, status: 'executed' }
109
+ : proposal,
110
+ ),
111
+ );
112
+ setExecutingId(null);
113
+ }, []);
114
+
115
+ const openProposals = proposals.filter((p) => p.status === 'open' || p.status === 'approved');
116
+
117
+ return {
118
+ proposals,
119
+ openProposals,
120
+ votes,
121
+ lastSignedEvent,
122
+ executingId,
123
+ createProposal,
124
+ recordVote,
125
+ simulateExecution,
126
+ };
127
+ }
@@ -0,0 +1,33 @@
1
+ import type { NostrEvent } from '../lib/nostr';
2
+
3
+ export type TProposalStatus = 'open' | 'approved' | 'rejected' | 'executed';
4
+
5
+ export type TVoteDecision = 'approve' | 'reject';
6
+
7
+ /** Demo kind for Multispend approval votes signed via Nostr. */
8
+ export const MULTISPEND_VOTE_KIND = 38383;
9
+
10
+ export type TMultispendProposal = {
11
+ id: string;
12
+ amountSats: number;
13
+ description: string;
14
+ requiredSigners: string[];
15
+ threshold: number;
16
+ approvals: string[];
17
+ rejections: string[];
18
+ status: TProposalStatus;
19
+ createdAt: number;
20
+ proposerPubkey: string;
21
+ };
22
+
23
+ export type TMultispendVote = {
24
+ proposalId: string;
25
+ voterPubkey: string;
26
+ vote: TVoteDecision;
27
+ signedEvent: NostrEvent;
28
+ };
29
+
30
+ export type TMockVoter = {
31
+ pubkey: string;
32
+ label: string;
33
+ };
@@ -0,0 +1,69 @@
1
+ import type { TMockVoter, TMultispendProposal } from './multispend-types';
2
+
3
+ /** Mock voter pubkeys for the demo. Real Multispend uses Fedi group members. */
4
+ export const MOCK_VOTERS: TMockVoter[] = [
5
+ {
6
+ pubkey: '79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798',
7
+ label: 'Alice',
8
+ },
9
+ {
10
+ pubkey: 'c6047f9441ed7d6d3045406e95e0aa2944fef797332e9cb0f5c85d8d280e47bd',
11
+ label: 'Bob',
12
+ },
13
+ {
14
+ pubkey: 'f9308a3192592395af7d9b875b252fe1840d59a1363c88b9b5c6a7767632b87',
15
+ label: 'Carol',
16
+ },
17
+ ];
18
+
19
+ export function getMockVoterLabel(pubkey: string): string {
20
+ const voter = MOCK_VOTERS.find((v) => v.pubkey === pubkey);
21
+ return voter?.label ?? 'Voter';
22
+ }
23
+
24
+ export function createProposalId(): string {
25
+ return `prop-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
26
+ }
27
+
28
+ export function getApprovalCount(proposal: TMultispendProposal): number {
29
+ return proposal.approvals.length;
30
+ }
31
+
32
+ export function isThresholdMet(proposal: TMultispendProposal): boolean {
33
+ return proposal.approvals.length >= proposal.threshold;
34
+ }
35
+
36
+ export function hasVoted(proposal: TMultispendProposal, pubkey: string): boolean {
37
+ return proposal.approvals.includes(pubkey) || proposal.rejections.includes(pubkey);
38
+ }
39
+
40
+ export function getInitialProposals(): TMultispendProposal[] {
41
+ const [alice, bob, carol] = MOCK_VOTERS;
42
+
43
+ return [
44
+ {
45
+ id: 'prop-seed-lunch',
46
+ amountSats: 42_000,
47
+ description: 'Team lunch: reimburse catering for the sprint retro',
48
+ requiredSigners: [alice.pubkey, bob.pubkey, carol.pubkey],
49
+ threshold: 2,
50
+ approvals: [],
51
+ rejections: [],
52
+ status: 'open',
53
+ createdAt: Date.now() - 3_600_000,
54
+ proposerPubkey: alice.pubkey,
55
+ },
56
+ {
57
+ id: 'prop-seed-supplies',
58
+ amountSats: 15_000,
59
+ description: 'Shared office supplies: printer paper and markers',
60
+ requiredSigners: [alice.pubkey, bob.pubkey, carol.pubkey],
61
+ threshold: 2,
62
+ approvals: [alice.pubkey, bob.pubkey],
63
+ rejections: [],
64
+ status: 'approved',
65
+ createdAt: Date.now() - 86_400_000,
66
+ proposerPubkey: bob.pubkey,
67
+ },
68
+ ];
69
+ }
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "multispend-demo",
3
+ "description": "Fedi multispend shared wallet UI",
4
+ "dependencies": [],
5
+ "devDependencies": [],
6
+ "files": [
7
+ { "src": "lib/multispend-types.ts", "dest": "lib/multispend-types.ts", "merge": "add" },
8
+ { "src": "lib/multispend-utils.ts", "dest": "lib/multispend-utils.ts", "merge": "add" },
9
+ { "src": "hooks/useMultispendDemo.ts", "dest": "hooks/useMultispendDemo.ts", "merge": "add" },
10
+ { "src": "components/multispend/ApprovalVote.tsx", "dest": "components/multispend/ApprovalVote.tsx", "merge": "add" },
11
+ { "src": "components/multispend/MultispendProposal.tsx", "dest": "components/multispend/MultispendProposal.tsx", "merge": "add" },
12
+ { "src": "components/multispend/ProposalList.tsx", "dest": "components/multispend/ProposalList.tsx", "merge": "add" },
13
+ { "src": "components/multispend/MultispendDemo.tsx", "dest": "components/multispend/MultispendDemo.tsx", "merge": "add" },
14
+ { "src": "app/demo/multispend/page.tsx", "dest": "app/demo/multispend/page.tsx", "merge": "add" },
15
+ { "src": "app/demo/multispend/MultispendDemoClient.tsx", "dest": "app/demo/multispend/MultispendDemoClient.tsx", "merge": "add" }
16
+ ],
17
+ "envVars": []
18
+ }
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useState } from 'react';
5
+ import { NostrFeedProvider } from '../../../components/nostr/NostrFeedProvider';
6
+ import { NoteFeed } from '../../../components/nostr/NoteFeed';
7
+ import { PublishNote } from '../../../components/nostr/PublishNote';
8
+ import { DEFAULT_RELAYS } from '../../../lib/nostr/relay';
9
+
10
+ export function NostrFeedDemoClient() {
11
+ const [howItWorksOpen, setHowItWorksOpen] = useState(false);
12
+
13
+ return (
14
+ <div className="min-h-dvh bg-[var(--color-bg)] font-[family-name:var(--font-body)] text-[var(--color-text)]">
15
+ <div
16
+ className="mx-auto w-full max-w-[390px] px-4 pt-6"
17
+ style={{ paddingBottom: 'max(5rem, env(safe-area-inset-bottom, 20px))' }}
18
+ >
19
+ <Link
20
+ href="/demo"
21
+ 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"
22
+ >
23
+ ← back
24
+ </Link>
25
+
26
+ <header className="mb-8 space-y-2">
27
+ <h1 className="font-[family-name:var(--font-display)] text-2xl font-bold leading-tight text-[var(--color-text)]">
28
+ Nostr feed
29
+ </h1>
30
+ <p className="max-w-[75ch] text-sm leading-[1.65] text-[var(--color-text-muted)]">
31
+ Read and post kind-1 notes on public relays. Zaps combine WebLN payments with NIP-57
32
+ zap receipts published back to the relay.
33
+ </p>
34
+ </header>
35
+
36
+ <NostrFeedProvider>
37
+ <div className="space-y-8">
38
+ <section className="space-y-3 rounded-xl px-4 py-3 text-sm leading-[1.65]"
39
+ style={{
40
+ background: 'var(--color-surface-1)',
41
+ border: '1px solid var(--color-border)',
42
+ borderRadius: 'var(--radius-lg)',
43
+ color: 'var(--color-text-muted)',
44
+ }}
45
+ >
46
+ <p>
47
+ Connected to{' '}
48
+ <span className="font-mono text-xs text-[var(--color-text)]">
49
+ {DEFAULT_RELAYS.join(', ')}
50
+ </span>
51
+ . Override with{' '}
52
+ <code className="font-mono text-xs">NEXT_PUBLIC_NOSTR_RELAY</code> (comma-separated
53
+ URLs).
54
+ </p>
55
+ </section>
56
+
57
+ <section className="space-y-4">
58
+ <div className="max-w-[75ch] space-y-1.5">
59
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
60
+ Publish
61
+ </h2>
62
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
63
+ Signs a kind-1 text note with your NIP-07 key and publishes to relays.
64
+ </p>
65
+ </div>
66
+ <PublishNote />
67
+ </section>
68
+
69
+ <section className="space-y-4">
70
+ <div className="max-w-[75ch] space-y-1.5">
71
+ <h2 className="font-[family-name:var(--font-display)] text-xl font-semibold leading-tight tracking-tight text-[var(--color-text)]">
72
+ Live feed
73
+ </h2>
74
+ <p className="text-sm leading-[1.65] text-[var(--color-text-muted)]">
75
+ Subscribes to recent notes from the last 24 hours, then streams new events.
76
+ </p>
77
+ </div>
78
+ <NoteFeed />
79
+ </section>
80
+
81
+ <section className="space-y-3">
82
+ <button
83
+ type="button"
84
+ onClick={() => setHowItWorksOpen((open) => !open)}
85
+ 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"
86
+ style={{
87
+ background: 'var(--color-surface-1)',
88
+ border: '1px solid var(--color-border)',
89
+ color: 'var(--color-text)',
90
+ borderRadius: 'var(--radius-lg)',
91
+ }}
92
+ aria-expanded={howItWorksOpen}
93
+ aria-controls="nostr-feed-how-it-works"
94
+ >
95
+ How the feed works
96
+ <span aria-hidden>{howItWorksOpen ? '−' : '+'}</span>
97
+ </button>
98
+
99
+ {howItWorksOpen && (
100
+ <div
101
+ id="nostr-feed-how-it-works"
102
+ className="space-y-3 rounded-lg px-4 py-3 text-sm leading-[1.65]"
103
+ style={{
104
+ background: 'var(--color-surface-1)',
105
+ border: '1px solid var(--color-border)',
106
+ color: 'var(--color-text-muted)',
107
+ borderRadius: 'var(--radius-lg)',
108
+ }}
109
+ >
110
+ <p>
111
+ <strong className="text-[var(--color-text)]">Relays</strong> are WebSocket
112
+ servers that store and forward Nostr events. This demo uses{' '}
113
+ <code className="font-mono text-xs">nostr-tools</code> with reconnect and
114
+ heartbeat pings.
115
+ </p>
116
+ <p>
117
+ <strong className="text-[var(--color-text)]">Zaps (NIP-57)</strong> look up the
118
+ author&apos;s <code className="font-mono text-xs">lud16</code> from their
119
+ profile, request a Lightning invoice with a signed zap request, pay via WebLN,
120
+ then publish a kind-9735 zap receipt.
121
+ </p>
122
+ <p>
123
+ Zaps only work when the note author has a Lightning address configured and
124
+ WebLN is available in Fedi.
125
+ </p>
126
+ </div>
127
+ )}
128
+ </section>
129
+ </div>
130
+ </NostrFeedProvider>
131
+ </div>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,23 @@
1
+ import type { Metadata } from 'next';
2
+ import { NostrFeedDemoClient } from './NostrFeedDemoClient';
3
+
4
+ export const metadata: Metadata = {
5
+ title: 'Nostr feed',
6
+ description:
7
+ 'Live Nostr social feed for Fedi mini apps. Read kind-1 notes from public relays, publish signed posts, and zap creators with WebLN and NIP-57.',
8
+ openGraph: {
9
+ title: 'Nostr feed',
10
+ description:
11
+ 'Live Nostr social feed for Fedi mini apps. Read kind-1 notes from public relays, publish signed posts, and zap creators with WebLN and NIP-57.',
12
+ },
13
+ twitter: {
14
+ card: 'summary',
15
+ title: 'Nostr feed',
16
+ description:
17
+ 'Live Nostr social feed for Fedi mini apps. Read kind-1 notes from public relays, publish signed posts, and zap creators with WebLN and NIP-57.',
18
+ },
19
+ };
20
+
21
+ export default function NostrFeedDemoPage() {
22
+ return <NostrFeedDemoClient />;
23
+ }