-
Notifications
You must be signed in to change notification settings - Fork 88
Expand file tree
/
Copy pathSubscriptionForm.tsx
More file actions
290 lines (258 loc) · 9.78 KB
/
Copy pathSubscriptionForm.tsx
File metadata and controls
290 lines (258 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"use client";
import { useMemo, useState } from "react";
// ---------------------------------------------------------------------------
// Wallet UX states
// disconnected – no wallet connected / access not yet granted
// connected – access granted, public key available, no pending action
// waiting_for_signature – tx sent to Freighter, awaiting user approval
// error – connection failed, user rejected, or tx error
// ---------------------------------------------------------------------------
type WalletState = "disconnected" | "connected" | "waiting_for_signature" | "error";
interface FormValues {
groupName: string;
usageCount: number | "";
}
interface FieldErrors {
groupName?: string;
usageCount?: string;
}
const GROUP_NAME_MIN = 3;
const GROUP_NAME_MAX = 64;
const USAGE_MIN = 1;
const USAGE_MAX = 10_000;
export function validateGroupName(value: string): string | undefined {
const trimmed = value.trim();
if (!trimmed) return "Group name is required.";
if (trimmed.length < GROUP_NAME_MIN) {
return `Group name must be at least ${GROUP_NAME_MIN} characters.`;
}
if (trimmed.length > GROUP_NAME_MAX) {
return `Group name must be at most ${GROUP_NAME_MAX} characters.`;
}
return undefined;
}
export function validateUsageCount(value: number | ""): string | undefined {
if (value === "" || Number.isNaN(Number(value))) {
return "Initial usages is required.";
}
const n = Number(value);
if (!Number.isInteger(n)) return "Initial usages must be a whole number.";
if (n < USAGE_MIN) return `Initial usages must be at least ${USAGE_MIN}.`;
if (n > USAGE_MAX) return `Initial usages must be at most ${USAGE_MAX.toLocaleString()}.`;
return undefined;
}
export function validateSubscriptionForm(form: FormValues): FieldErrors {
const errors: FieldErrors = {};
const groupNameError = validateGroupName(form.groupName);
const usageCountError = validateUsageCount(form.usageCount);
if (groupNameError) errors.groupName = groupNameError;
if (usageCountError) errors.usageCount = usageCountError;
return errors;
}
export default function SubscriptionForm() {
const [walletState, setWalletState] = useState<WalletState>("disconnected");
const [publicKey, setPublicKey] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [form, setForm] = useState<FormValues>({ groupName: "", usageCount: 10 });
const [touched, setTouched] = useState<{ groupName: boolean; usageCount: boolean }>({
groupName: false,
usageCount: false,
});
const [txHash, setTxHash] = useState<string | null>(null);
const errors = useMemo(() => validateSubscriptionForm(form), [form]);
const showGroupNameError = touched.groupName && Boolean(errors.groupName);
const showUsageCountError = touched.usageCount && Boolean(errors.usageCount);
const isFormValid = Object.keys(errors).length === 0;
async function connectWallet() {
setErrorMessage(null);
try {
// @ts-expect-error – freighter is injected by the browser extension
const freighter = window.freighter;
if (!freighter) throw new Error("Freighter extension not found. Please install it from freighter.app.");
const isConnected: boolean = await freighter.isConnected();
if (!isConnected) await freighter.requestAccess();
const key: string = await freighter.getPublicKey();
setPublicKey(key);
setWalletState("connected");
} catch (err: unknown) {
setErrorMessage(err instanceof Error ? err.message : String(err));
setWalletState("error");
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setTouched({ groupName: true, usageCount: true });
if (!isFormValid || walletState !== "connected" || !publicKey) return;
setErrorMessage(null);
setWalletState("waiting_for_signature");
try {
const xdrEnvelope = buildSubscriptionTx(
{ groupName: form.groupName.trim(), usageCount: Number(form.usageCount) },
publicKey
);
// @ts-expect-error – freighter is injected by the browser extension
const signed: { signedTxXdr: string } = await window.freighter.signTransaction(
xdrEnvelope,
{ network: "TESTNET", networkPassphrase: "Test SDF Network ; September 2015" }
);
const hash = await submitTransaction(signed.signedTxXdr);
setTxHash(hash);
setWalletState("connected");
} catch (err: unknown) {
setErrorMessage(err instanceof Error ? err.message : String(err));
setWalletState("error");
}
}
function retry() {
setErrorMessage(null);
setWalletState(publicKey ? "connected" : "disconnected");
}
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Create Subscription Group</h2>
<WalletStatusBanner
state={walletState}
publicKey={publicKey}
error={errorMessage}
onConnect={connectWallet}
onRetry={retry}
/>
{walletState === "connected" && (
<form onSubmit={handleSubmit} className="mt-4 space-y-4" noValidate>
<div>
<label htmlFor="groupName" className="block text-sm font-medium">
Group name
</label>
<input
id="groupName"
type="text"
required
value={form.groupName}
onChange={(e) => setForm({ ...form, groupName: e.target.value })}
onBlur={() => setTouched((t) => ({ ...t, groupName: true }))}
aria-invalid={showGroupNameError}
aria-describedby={showGroupNameError ? "groupName-error" : undefined}
className={`mt-1 block w-full border rounded px-3 py-2 text-sm ${
showGroupNameError ? "border-red-500 ring-1 ring-red-500" : "border-gray-300"
}`}
placeholder="Team Alpha Plan"
/>
{showGroupNameError && (
<p id="groupName-error" className="mt-1 text-sm text-red-600" role="alert">
{errors.groupName}
</p>
)}
</div>
<div>
<label htmlFor="usageCount" className="block text-sm font-medium">
Initial usages
</label>
<input
id="usageCount"
type="number"
min={USAGE_MIN}
max={USAGE_MAX}
required
value={form.usageCount}
onChange={(e) => {
const raw = e.target.value;
setForm({
...form,
usageCount: raw === "" ? "" : Number(raw),
});
}}
onBlur={() => setTouched((t) => ({ ...t, usageCount: true }))}
aria-invalid={showUsageCountError}
aria-describedby={showUsageCountError ? "usageCount-error" : undefined}
className={`mt-1 block w-full border rounded px-3 py-2 text-sm ${
showUsageCountError ? "border-red-500 ring-1 ring-red-500" : "border-gray-300"
}`}
/>
{showUsageCountError && (
<p id="usageCount-error" className="mt-1 text-sm text-red-600" role="alert">
{errors.usageCount}
</p>
)}
</div>
<button
type="submit"
disabled={walletState !== "connected" || !isFormValid}
className="w-full py-2 px-4 bg-blue-600 text-white rounded disabled:opacity-50"
>
Create group
</button>
</form>
)}
{txHash && (
<p className="mt-4 text-sm text-green-700">
✓ Transaction submitted:{" "}
<a
href={`https://stellar.expert/explorer/testnet/tx/${txHash}`}
target="_blank"
rel="noreferrer"
className="underline"
>
{txHash.slice(0, 12)}…
</a>
</p>
)}
</div>
);
}
interface BannerProps {
state: WalletState;
publicKey: string | null;
error: string | null;
onConnect: () => void;
onRetry: () => void;
}
function WalletStatusBanner({ state, publicKey, error, onConnect, onRetry }: BannerProps) {
switch (state) {
case "disconnected":
return (
<div className="flex items-center justify-between rounded bg-gray-100 p-3 text-sm">
<span>Connect your Freighter wallet to continue.</span>
<button
onClick={onConnect}
className="ml-4 rounded bg-blue-600 px-3 py-1 text-white text-xs"
>
Connect
</button>
</div>
);
case "connected":
return (
<div className="rounded bg-green-50 border border-green-200 p-3 text-sm text-green-800">
Wallet connected: <code className="font-mono">{publicKey}</code>
</div>
);
case "waiting_for_signature":
return (
<div className="rounded bg-yellow-50 border border-yellow-200 p-3 text-sm text-yellow-800 flex items-center gap-2">
<span className="animate-spin">⏳</span>
Check Freighter — please approve the transaction.
</div>
);
case "error":
return (
<div className="flex items-center justify-between rounded bg-red-50 border border-red-200 p-3 text-sm text-red-800">
<span>Wallet error: {error ?? "Unknown error"}. Please try again.</span>
<button
onClick={onRetry}
className="ml-4 rounded bg-red-600 px-3 py-1 text-white text-xs"
>
Retry
</button>
</div>
);
}
}
function buildSubscriptionTx(
_form: { groupName: string; usageCount: number },
_creator: string
): string {
return "AAAAAA==";
}
async function submitTransaction(_xdr: string): Promise<string> {
return "stub-tx-hash";
}