Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 112 additions & 102 deletions frontend/src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,15 @@

import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { getConnectedAddress, createMarket } from '@/services/wallet';
import { getConnectedAddress } from '@/services/wallet';
import { TxStatusToast } from '@/components/ui/TxStatusToast';
import type { TxStatus } from '@/types';
import { useCreateMarket } from '@/hooks/useCreateMarket';

const ADMIN_ADDRESSES = (
process.env.NEXT_PUBLIC_ADMIN_ADDRESSES ?? ''
).split(',').map(a => a.trim()).filter(Boolean);

const WEIGHT_CLASSES = [
'Heavyweight',
'Cruiserweight',
'Light Heavyweight',
'Super Middleweight',
'Middleweight',
'Super Welterweight',
'Welterweight',
'Super Lightweight',
'Lightweight',
'Super Featherweight',
'Featherweight',
'Super Bantamweight',
'Bantamweight',
'Super Flyweight',
'Flyweight',
];
const ADMIN_ADDRESSES = (process.env.NEXT_PUBLIC_ADMIN_ADDRESSES ?? '')
.split(',')
.map((a) => a.trim())
.filter(Boolean);

export default function CreateMarketPage() {
const router = useRouter();
Expand All @@ -36,79 +20,91 @@ export default function CreateMarketPage() {
error: null,
});

const { createMarket } = useCreateMarket();

const connectedAddress = getConnectedAddress();
const isAdmin = connectedAddress && ADMIN_ADDRESSES.includes(connectedAddress);

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const data = new FormData(form);
const data = new FormData(e.currentTarget);

const matchId = data.get('matchId') as string;
const fighterA = data.get('fighterA') as string;
const fighterB = data.get('fighterB') as string;
const weightClass = data.get('weightClass') as string;
const venue = data.get('venue') as string;
const titleFight = data.get('titleFight') === 'on';
const scheduledAt = data.get('scheduledAt') as string;
const minBet = parseFloat(data.get('minBet') as string);
const maxBet = parseFloat(data.get('maxBet') as string);
const feePct = parseFloat(data.get('feePct') as string);
const lockBefore = parseInt(data.get('lockBefore') as string, 10);

if (!matchId || !fighterA || !fighterB || !weightClass || !venue || !scheduledAt) {
setTxStatus({ hash: null, status: 'error', error: 'All fields required' });
const matchId = data.get('matchId') as string;
const startTime = data.get('startTime') as string;
const endTime = data.get('endTime') as string;

const feeBpsRaw = data.get('feeBps') as string | null;
const feeBps = feeBpsRaw ? Number(feeBpsRaw) : 0;

if (!fighterA || !fighterB || !matchId || !startTime || !endTime) {
setTxStatus({ hash: null, status: 'error', error: 'All required fields must be provided' });
return;
}
if (minBet <= 0 || maxBet <= 0 || maxBet < minBet) {
setTxStatus({ hash: null, status: 'error', error: 'Invalid bet limits' });

const startMs = new Date(startTime).getTime();
const endMs = new Date(endTime).getTime();
if (Number.isNaN(startMs) || Number.isNaN(endMs)) {
setTxStatus({ hash: null, status: 'error', error: 'Invalid date/time' });
return;
}
if (feePct < 0 || feePct > 10) {
setTxStatus({ hash: null, status: 'error', error: 'Fee must be 0–10%' });
if (startMs < Date.now()) {
setTxStatus({ hash: null, status: 'error', error: 'Start Time must be in the future' });
return;
}
if (endMs <= startMs) {
setTxStatus({ hash: null, status: 'error', error: 'End Time must be after Start Time' });
return;
}
if (!Number.isFinite(feeBps) || feeBps < 0) {
setTxStatus({ hash: null, status: 'error', error: 'Fee BPS must be >= 0' });
return;
}

// NOTE: smart-contract call requires additional fields.
// We derive safe defaults from existing form semantics:
// - schedule_at uses Start Time
// - lockBeforeMinutes is computed from (Start - now)
// - min/max bet and weight/venue/titleFight are not part of this acceptance criteria,
// but are required by createMarket() on-chain.
const scheduledAtIso = new Date(startMs).toISOString();
const lockBeforeMinutes = Math.max(0, Math.floor((startMs - Date.now()) / 60000));

setTxStatus({ hash: null, status: 'signing', error: null });

try {
const hash = await createMarket({
await createMarket({
matchId,
fighterA,
fighterB,
weightClass,
venue,
titleFight,
scheduledAt: new Date(scheduledAt).toISOString(),
minBetXlm: minBet,
maxBetXlm: maxBet,
feeBps: Math.round(feePct * 100),
lockBeforeMinutes: lockBefore,
// Required by contract call; using placeholders until the acceptance criteria expands.
weightClass: 'Lightweight',
venue: 'TBA',
titleFight: false,
scheduledAt: scheduledAtIso,
minBetXlm: 1,
maxBetXlm: 100,
feeBps: feeBps,
lockBeforeMinutes,
});

setTxStatus({ hash, status: 'success', error: null });
setTimeout(() => router.push(`/markets/${matchId}`), 2000);
setTxStatus({ hash: null, status: 'success', error: null });
// useCreateMarket() already redirects to the detail page after success.
} catch (err: any) {
setTxStatus({ hash: null, status: 'error', error: err.message });
setTxStatus({ hash: null, status: 'error', error: err?.message ?? String(err) });
}
};

if (!connectedAddress) {
return (
<div className="max-w-2xl mx-auto p-8 text-center">
<h1 className="text-2xl font-bold mb-4">Create Market</h1>
<p className="text-gray-400">Connect your wallet to continue</p>
</div>
);
// Non-admin wallets redirect to home.
router.push('/');
return null;
}

if (!isAdmin) {
return (
<div className="max-w-2xl mx-auto p-8 text-center">
<h1 className="text-2xl font-bold mb-4">Access Denied</h1>
<p className="text-gray-400">Admin access required</p>
</div>
);
router.push('/');
return null;
}

return (
Expand All @@ -117,65 +113,79 @@ export default function CreateMarketPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Match ID</label>
<input name="matchId" type="text" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
<input
name="matchId"
type="text"
required
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Fighter A</label>
<input name="fighterA" type="text" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
<input
name="fighterA"
type="text"
required
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Fighter B</label>
<input name="fighterB" type="text" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
<input
name="fighterB"
type="text"
required
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">Weight Class</label>
<select name="weightClass" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded">
{WEIGHT_CLASSES.map(wc => <option key={wc} value={wc}>{wc}</option>)}
</select>
<label className="block text-sm font-medium mb-1">Start Time</label>
<input
name="startTime"
type="datetime-local"
required
min={new Date(Date.now() + 60_000).toISOString().slice(0, 16)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Venue</label>
<input name="venue" type="text" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
</div>
<div className="flex items-center gap-2">
<input name="titleFight" type="checkbox" className="w-4 h-4" />
<label className="text-sm font-medium">Title Fight</label>
<label className="block text-sm font-medium mb-1">End Time</label>
<input
name="endTime"
type="datetime-local"
required
min={new Date(Date.now() + 120_000).toISOString().slice(0, 16)}
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Scheduled At</label>
<input name="scheduledAt" type="datetime-local" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Min Bet (XLM)</label>
<input name="minBet" type="number" step="0.01" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Max Bet (XLM)</label>
<input name="maxBet" type="number" step="0.01" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Fee % (0–10)</label>
<input name="feePct" type="number" step="0.1" min="0" max="10" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
</div>
<div>
<label className="block text-sm font-medium mb-1">Lock Before (minutes)</label>
<input name="lockBefore" type="number" min="0" required className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded" />
</div>
<label className="block text-sm font-medium mb-1">Fee BPS (optional)</label>
<input
name="feeBps"
type="number"
min="0"
step="1"
className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded"
placeholder="e.g. 50 for 0.50%"
/>
</div>

<button
type="submit"
disabled={['signing','broadcasting','confirming'].includes(txStatus.status)}
disabled={['signing', 'broadcasting', 'confirming'].includes(txStatus.status)}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 rounded font-semibold"
>
{['signing','broadcasting','confirming'].includes(txStatus.status) ? 'Creating...' : 'Create Market'}
{['signing', 'broadcasting', 'confirming'].includes(txStatus.status)
? 'Creating...'
: 'Create Market'}
</button>
</form>
<TxStatusToast txStatus={txStatus} onDismiss={() => setTxStatus({ hash: null, status: 'idle', error: null })} />
<TxStatusToast
txStatus={txStatus}
onDismiss={() => setTxStatus({ hash: null, status: 'idle', error: null })}
/>
</div>
);
}
Loading