Skip to content

Commit edb0543

Browse files
feat: implement create market frontend
1 parent eae642e commit edb0543

4 files changed

Lines changed: 388 additions & 0 deletions

File tree

frontend/app/create/page.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { CreateMarketForm } from "@/components/create-market-form";
2+
3+
export default function CreatePage() {
4+
return (
5+
<main className="min-h-screen bg-slate-50">
6+
<section className="mx-auto w-full max-w-2xl px-6 py-10">
7+
<div className="mb-8 flex flex-col gap-2">
8+
<h1 className="text-3xl font-semibold text-ink">Create Market</h1>
9+
<p className="text-slate-600">
10+
Set up a new prediction market and invite traders
11+
</p>
12+
</div>
13+
14+
<div className="rounded-lg border border-slate-200 bg-white p-8 shadow-sm">
15+
<CreateMarketForm />
16+
</div>
17+
</section>
18+
</main>
19+
);
20+
}
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
"use client";
2+
3+
import { useRouter } from "next/navigation";
4+
import { FormEvent, useState } from "react";
5+
import clsx from "clsx";
6+
import { useWallet } from "@/lib/wallet/use-wallet";
7+
import { createMarket } from "@/lib/contract/create-market-service";
8+
9+
interface FormState {
10+
title: string;
11+
description: string;
12+
endDateTime: string;
13+
outcomes: string[];
14+
}
15+
16+
interface FormErrors {
17+
title?: string;
18+
description?: string;
19+
endDateTime?: string;
20+
outcomes?: string;
21+
}
22+
23+
export function CreateMarketForm() {
24+
const router = useRouter();
25+
const { isConnected, publicKey } = useWallet();
26+
27+
const [formState, setFormState] = useState<FormState>({
28+
title: "",
29+
description: "",
30+
endDateTime: "",
31+
outcomes: ["", ""],
32+
});
33+
34+
const [errors, setErrors] = useState<FormErrors>({});
35+
const [isSubmitting, setIsSubmitting] = useState(false);
36+
const [submitError, setSubmitError] = useState<string | null>(null);
37+
const [successMessage, setSuccessMessage] = useState<string | null>(null);
38+
39+
const validateForm = (): boolean => {
40+
const newErrors: FormErrors = {};
41+
42+
if (!formState.title.trim()) {
43+
newErrors.title = "Title is required";
44+
}
45+
46+
if (!formState.description.trim()) {
47+
newErrors.description = "Description is required";
48+
}
49+
50+
if (!formState.endDateTime) {
51+
newErrors.endDateTime = "End date and time is required";
52+
} else {
53+
const endTime = new Date(formState.endDateTime).getTime();
54+
const now = Date.now();
55+
if (endTime <= now) {
56+
newErrors.endDateTime = "End date must be in the future";
57+
}
58+
}
59+
60+
const filledOutcomes = formState.outcomes.filter((o) => o.trim());
61+
if (filledOutcomes.length < 2) {
62+
newErrors.outcomes = "Minimum 2 outcomes required";
63+
}
64+
65+
// Check for duplicate outcomes
66+
if (filledOutcomes.length !== new Set(filledOutcomes).size) {
67+
newErrors.outcomes = "Outcomes must be unique";
68+
}
69+
70+
setErrors(newErrors);
71+
return Object.keys(newErrors).length === 0;
72+
};
73+
74+
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
75+
setFormState((prev) => ({ ...prev, title: e.target.value }));
76+
if (errors.title) setErrors((prev) => ({ ...prev, title: undefined }));
77+
};
78+
79+
const handleDescriptionChange = (
80+
e: React.ChangeEvent<HTMLTextAreaElement>
81+
) => {
82+
setFormState((prev) => ({ ...prev, description: e.target.value }));
83+
if (errors.description)
84+
setErrors((prev) => ({ ...prev, description: undefined }));
85+
};
86+
87+
const handleEndDateTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
88+
setFormState((prev) => ({ ...prev, endDateTime: e.target.value }));
89+
if (errors.endDateTime)
90+
setErrors((prev) => ({ ...prev, endDateTime: undefined }));
91+
};
92+
93+
const handleOutcomeChange = (index: number, value: string) => {
94+
const newOutcomes = [...formState.outcomes];
95+
newOutcomes[index] = value;
96+
setFormState((prev) => ({ ...prev, outcomes: newOutcomes }));
97+
if (errors.outcomes) setErrors((prev) => ({ ...prev, outcomes: undefined }));
98+
};
99+
100+
const addOutcome = () => {
101+
setFormState((prev) => ({
102+
...prev,
103+
outcomes: [...prev.outcomes, ""],
104+
}));
105+
};
106+
107+
const removeOutcome = (index: number) => {
108+
if (formState.outcomes.length > 2) {
109+
const newOutcomes = formState.outcomes.filter((_, i) => i !== index);
110+
setFormState((prev) => ({ ...prev, outcomes: newOutcomes }));
111+
}
112+
};
113+
114+
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
115+
e.preventDefault();
116+
setSubmitError(null);
117+
setSuccessMessage(null);
118+
119+
if (!validateForm()) {
120+
return;
121+
}
122+
123+
if (!isConnected) {
124+
setSubmitError("Please connect your wallet first");
125+
return;
126+
}
127+
128+
setIsSubmitting(true);
129+
130+
try {
131+
const endTime = Math.floor(new Date(formState.endDateTime).getTime() / 1000);
132+
const filledOutcomes = formState.outcomes.filter((o) => o.trim());
133+
134+
const marketId = await createMarket({
135+
title: formState.title.trim(),
136+
description: formState.description.trim(),
137+
endTime,
138+
outcomes: filledOutcomes,
139+
});
140+
141+
setSuccessMessage(`Market created successfully! ID: ${marketId}`);
142+
143+
setTimeout(() => {
144+
router.push("/markets");
145+
}, 2000);
146+
} catch (error) {
147+
const message =
148+
error instanceof Error ? error.message : "Failed to create market";
149+
setSubmitError(message);
150+
} finally {
151+
setIsSubmitting(false);
152+
}
153+
};
154+
155+
if (!isConnected) {
156+
return (
157+
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-6">
158+
<h3 className="font-semibold text-yellow-900">Wallet Connection Required</h3>
159+
<p className="mt-2 text-sm text-yellow-700">
160+
Please connect your Freighter wallet to create a new market.
161+
</p>
162+
</div>
163+
);
164+
}
165+
166+
return (
167+
<form onSubmit={handleSubmit} className="space-y-6">
168+
{submitError && (
169+
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
170+
<p className="text-sm text-red-700">{submitError}</p>
171+
</div>
172+
)}
173+
174+
{successMessage && (
175+
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
176+
<p className="text-sm text-green-700">{successMessage}</p>
177+
<p className="mt-1 text-xs text-green-600">
178+
Redirecting to markets...
179+
</p>
180+
</div>
181+
)}
182+
183+
<div>
184+
<label htmlFor="title" className="block text-sm font-medium text-ink">
185+
Market Title
186+
</label>
187+
<input
188+
id="title"
189+
type="text"
190+
placeholder="e.g., Will Bitcoin reach $100k by end of 2024?"
191+
value={formState.title}
192+
onChange={handleTitleChange}
193+
disabled={isSubmitting}
194+
className={clsx(
195+
"mt-2 w-full rounded-md border px-4 py-2 text-sm transition-colors",
196+
errors.title
197+
? "border-red-300 bg-red-50 text-red-900 placeholder-red-400 focus:border-red-500 focus:outline-none"
198+
: "border-slate-300 bg-white text-ink placeholder-slate-400 focus:border-ink focus:outline-none focus:ring-1 focus:ring-ink"
199+
)}
200+
/>
201+
{errors.title && (
202+
<p className="mt-1 text-xs text-red-600">{errors.title}</p>
203+
)}
204+
</div>
205+
206+
<div>
207+
<label
208+
htmlFor="description"
209+
className="block text-sm font-medium text-ink"
210+
>
211+
Description
212+
</label>
213+
<textarea
214+
id="description"
215+
placeholder="Provide context and details about this market..."
216+
value={formState.description}
217+
onChange={handleDescriptionChange}
218+
disabled={isSubmitting}
219+
rows={4}
220+
className={clsx(
221+
"mt-2 w-full rounded-md border px-4 py-2 text-sm transition-colors",
222+
errors.description
223+
? "border-red-300 bg-red-50 text-red-900 placeholder-red-400 focus:border-red-500 focus:outline-none"
224+
: "border-slate-300 bg-white text-ink placeholder-slate-400 focus:border-ink focus:outline-none focus:ring-1 focus:ring-ink"
225+
)}
226+
/>
227+
{errors.description && (
228+
<p className="mt-1 text-xs text-red-600">{errors.description}</p>
229+
)}
230+
</div>
231+
232+
<div>
233+
<label
234+
htmlFor="endDateTime"
235+
className="block text-sm font-medium text-ink"
236+
>
237+
Resolution End Date & Time
238+
</label>
239+
<input
240+
id="endDateTime"
241+
type="datetime-local"
242+
value={formState.endDateTime}
243+
onChange={handleEndDateTimeChange}
244+
disabled={isSubmitting}
245+
className={clsx(
246+
"mt-2 w-full rounded-md border px-4 py-2 text-sm transition-colors",
247+
errors.endDateTime
248+
? "border-red-300 bg-red-50 text-red-900 focus:border-red-500 focus:outline-none"
249+
: "border-slate-300 bg-white text-ink focus:border-ink focus:outline-none focus:ring-1 focus:ring-ink"
250+
)}
251+
/>
252+
{errors.endDateTime && (
253+
<p className="mt-1 text-xs text-red-600">{errors.endDateTime}</p>
254+
)}
255+
</div>
256+
257+
<div>
258+
<div className="mb-3 flex items-center justify-between">
259+
<label className="block text-sm font-medium text-ink">
260+
Outcomes (Minimum 2)
261+
</label>
262+
<button
263+
type="button"
264+
onClick={addOutcome}
265+
disabled={isSubmitting}
266+
className="text-xs font-medium text-ink hover:underline disabled:opacity-50"
267+
>
268+
+ Add Outcome
269+
</button>
270+
</div>
271+
272+
<div className="space-y-2">
273+
{formState.outcomes.map((outcome, index) => (
274+
<div key={index} className="flex gap-2">
275+
<input
276+
type="text"
277+
placeholder={`Outcome ${index + 1}`}
278+
value={outcome}
279+
onChange={(e) => handleOutcomeChange(index, e.target.value)}
280+
disabled={isSubmitting}
281+
className="flex-1 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm text-ink placeholder-slate-400 transition-colors focus:border-ink focus:outline-none focus:ring-1 focus:ring-ink disabled:opacity-50"
282+
/>
283+
{formState.outcomes.length > 2 && (
284+
<button
285+
type="button"
286+
onClick={() => removeOutcome(index)}
287+
disabled={isSubmitting}
288+
className="rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-600 hover:bg-red-50 hover:text-red-600 transition-colors disabled:opacity-50"
289+
>
290+
Remove
291+
</button>
292+
)}
293+
</div>
294+
))}
295+
</div>
296+
297+
{errors.outcomes && (
298+
<p className="mt-2 text-xs text-red-600">{errors.outcomes}</p>
299+
)}
300+
</div>
301+
302+
<div className="border-t border-slate-200 pt-6">
303+
<button
304+
type="submit"
305+
disabled={isSubmitting || !isConnected}
306+
className={clsx(
307+
"w-full rounded-md px-4 py-3 font-medium transition-colors",
308+
isSubmitting || !isConnected
309+
? "bg-slate-200 text-slate-500 cursor-not-allowed"
310+
: "bg-ink text-white hover:bg-slate-900"
311+
)}
312+
>
313+
{isSubmitting ? (
314+
<span className="flex items-center justify-center gap-2">
315+
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
316+
Creating Market...
317+
</span>
318+
) : (
319+
"Create Market"
320+
)}
321+
</button>
322+
</div>
323+
</form>
324+
);
325+
}

frontend/src/components/header.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ export function Header() {
4343
>
4444
Markets
4545
</Link>
46+
<Link
47+
href="/create"
48+
className={clsx(
49+
"text-sm font-medium transition-colors",
50+
isActive("/create")
51+
? "text-ink border-b-2 border-ink"
52+
: "text-slate-600 hover:text-ink"
53+
)}
54+
>
55+
Create
56+
</Link>
4657
</nav>
4758
</div>
4859

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { simulateContractCall } from "./soroban-client";
2+
3+
export interface CreateMarketParams {
4+
title: string;
5+
description: string;
6+
endTime: number;
7+
outcomes: string[];
8+
}
9+
10+
export async function createMarket(params: CreateMarketParams): Promise<number> {
11+
try {
12+
const result = await simulateContractCall("create_market", [
13+
params.title,
14+
params.description,
15+
params.endTime,
16+
params.outcomes,
17+
]);
18+
19+
if (typeof result === "number") {
20+
return result;
21+
}
22+
23+
if (typeof result === "bigint") {
24+
return Number(result);
25+
}
26+
27+
throw new Error("Unexpected market ID format from contract");
28+
} catch (error) {
29+
console.error("Error creating market:", error);
30+
throw error;
31+
}
32+
}

0 commit comments

Comments
 (0)