Skip to content
Merged
Show file tree
Hide file tree
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
6 changes: 3 additions & 3 deletions frontend/src/app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export default function CreateMarketPage() {
return;
}

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

try {
const hash = await createMarket({
Expand Down Expand Up @@ -169,10 +169,10 @@ export default function CreateMarketPage() {
</div>
<button
type="submit"
disabled={txStatus.status === 'pending'}
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"
>
{txStatus.status === 'pending' ? '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 })} />
Expand Down
29 changes: 29 additions & 0 deletions frontend/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { useEffect } from 'react';

interface ErrorPageProps {
error: Error & { digest?: string };
reset: () => void;
}

export default function ErrorPage({ error, reset }: ErrorPageProps): JSX.Element {
useEffect(() => {
// Log to error reporting service in production
console.error(error);
}, [error]);

return (
<main className="min-h-[60vh] flex flex-col items-center justify-center px-4 text-center">
<p className="text-5xl mb-4">⚠️</p>
<h1 className="text-2xl font-bold text-white mb-2">Something went wrong</h1>
<p className="text-gray-400 mb-8">An unexpected error occurred. Please try again.</p>
<button
onClick={reset}
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold px-6 py-2.5 rounded-xl transition-colors"
>
Try again
</button>
</main>
);
}
12 changes: 6 additions & 6 deletions frontend/src/app/governance/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default function ProposalDetail({ params }: { params: { id: string } }) {

const handleVote = async (vote: VoteType) => {
if (!connectedAddress) return;
setTxStatus({ hash: null, status: 'pending', error: null });
setTxStatus({ hash: null, status: 'signing', error: null });
try {
const hash = await voteProposal(proposal.id, vote);
setTxStatus({ hash, status: 'success', error: null });
Expand All @@ -75,7 +75,7 @@ export default function ProposalDetail({ params }: { params: { id: string } }) {
};

const handleExecute = async () => {
setTxStatus({ hash: null, status: 'pending', error: null });
setTxStatus({ hash: null, status: 'signing', error: null });
try {
const hash = await executeProposal(proposal.id);
setTxStatus({ hash, status: 'success', error: null });
Expand Down Expand Up @@ -121,7 +121,7 @@ export default function ProposalDetail({ params }: { params: { id: string } }) {
{proposal.status === 'Passed' && (
<button
onClick={handleExecute}
disabled={txStatus.status === 'pending'}
disabled={['signing','broadcasting','confirming'].includes(txStatus.status)}
className="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded font-medium transition-colors"
>
Execute Proposal
Expand Down Expand Up @@ -198,21 +198,21 @@ export default function ProposalDetail({ params }: { params: { id: string } }) {
<div className="flex flex-col gap-3">
<button
onClick={() => handleVote('for')}
disabled={proposal.status !== 'Active' || hasVoted || txStatus.status === 'pending'}
disabled={proposal.status !== 'Active' || hasVoted || ['signing','broadcasting','confirming'].includes(txStatus.status)}
className="w-full py-2.5 bg-green-500/10 text-green-400 border border-green-500/50 hover:bg-green-500/20 disabled:opacity-50 disabled:cursor-not-allowed rounded font-medium transition-colors"
>
Vote For
</button>
<button
onClick={() => handleVote('against')}
disabled={proposal.status !== 'Active' || hasVoted || txStatus.status === 'pending'}
disabled={proposal.status !== 'Active' || hasVoted || ['signing','broadcasting','confirming'].includes(txStatus.status)}
className="w-full py-2.5 bg-red-500/10 text-red-400 border border-red-500/50 hover:bg-red-500/20 disabled:opacity-50 disabled:cursor-not-allowed rounded font-medium transition-colors"
>
Vote Against
</button>
<button
onClick={() => handleVote('abstain')}
disabled={proposal.status !== 'Active' || hasVoted || txStatus.status === 'pending'}
disabled={proposal.status !== 'Active' || hasVoted || ['signing','broadcasting','confirming'].includes(txStatus.status)}
className="w-full py-2.5 bg-gray-500/10 text-gray-400 border border-gray-500/50 hover:bg-gray-500/20 disabled:opacity-50 disabled:cursor-not-allowed rounded font-medium transition-colors"
>
Abstain
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/governance/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export default function NewProposalPage() {
return;
}

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

try {
const hash = await createProposal({
Expand Down Expand Up @@ -245,10 +245,10 @@ export default function NewProposalPage() {

<button
type="submit"
disabled={txStatus.status === 'pending' || isLoadingBalance || balanceILN < MIN_ILN_REQUIRED}
disabled={['signing','broadcasting','confirming'].includes(txStatus.status) || isLoadingBalance || balanceILN < MIN_ILN_REQUIRED}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded font-semibold transition-colors"
>
{txStatus.status === 'pending' ? 'Submitting...' : 'Submit Proposal'}
{['signing','broadcasting','confirming'].includes(txStatus.status) ? 'Submitting...' : 'Submit Proposal'}
</button>
</form>

Expand Down
10 changes: 9 additions & 1 deletion frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }): JSX.Element {
return (
<html lang="en" className={inter.className}>
<body className="bg-gray-950 text-white min-h-screen">
{/* Inline script runs before paint to set dark/light class — prevents flash */}
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){var t=localStorage.getItem('boxmeout_theme');var d=t?t==='dark':window.matchMedia('(prefers-color-scheme: dark)').matches;if(d)document.documentElement.classList.add('dark');})();`,
}}
/>
</head>
<body className="bg-gray-950 dark:bg-gray-950 text-white dark:text-white min-h-screen">
<Header />
{children}
</body>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Link from 'next/link';

export default function NotFound(): JSX.Element {
return (
<main className="min-h-[60vh] flex flex-col items-center justify-center px-4 text-center">
<p className="text-6xl font-black text-amber-500 mb-4">404</p>
<h1 className="text-2xl font-bold text-white mb-2">Page not found</h1>
<p className="text-gray-400 mb-8">The page you're looking for doesn't exist.</p>
<Link
href="/"
className="bg-amber-500 hover:bg-amber-400 text-black font-semibold px-6 py-2.5 rounded-xl transition-colors"
>
Back to Markets
</Link>
</main>
);
}
8 changes: 4 additions & 4 deletions frontend/src/app/payer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export default function PayerDashboard() {
const handleSettleConfirm = async () => {
if (!selectedInvoice) return;

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

try {
const hash = await markPaid(selectedInvoice.id);
Expand Down Expand Up @@ -216,17 +216,17 @@ export default function PayerDashboard() {
<div className="flex gap-3">
<button
onClick={() => setSelectedInvoice(null)}
disabled={txStatus.status === 'pending'}
disabled={['signing','broadcasting','confirming'].includes(txStatus.status)}
className="flex-1 py-2.5 bg-gray-800 hover:bg-gray-700 text-white rounded font-medium transition-colors"
>
Cancel
</button>
<button
onClick={handleSettleConfirm}
disabled={txStatus.status === 'pending'}
disabled={['signing','broadcasting','confirming'].includes(txStatus.status)}
className="flex-1 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 text-white rounded font-medium transition-colors flex justify-center items-center gap-2"
>
{txStatus.status === 'pending' ? (
{['signing','broadcasting','confirming'].includes(txStatus.status) ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Settling...
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useState, useEffect } from 'react';
import { WalletButton } from './WalletButton';
import { ThemeToggle } from './ThemeToggle';

const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK ?? 'testnet';
const IS_MAINNET = NETWORK === 'mainnet';
Expand Down Expand Up @@ -41,7 +42,7 @@ export function Header(): JSX.Element {
<button onClick={dismissBanner} className="ml-auto text-white/80 hover:text-white font-bold">✕</button>
</div>
)}
<header className="sticky top-0 z-40 bg-gray-950 border-b border-gray-800">
<header className="sticky top-0 z-40 bg-gray-950 dark:bg-gray-950 border-b border-gray-800 dark:border-gray-800">
<div className="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<Link href="/" className="font-black text-amber-500 text-xl tracking-tight">BOXMEOUT</Link>

Expand All @@ -58,6 +59,7 @@ export function Header(): JSX.Element {
{IS_MAINNET ? 'MAINNET' : 'TESTNET'}
</span>

<ThemeToggle />
<WalletButton />

{/* Mobile hamburger */}
Expand Down
49 changes: 49 additions & 0 deletions frontend/src/components/layout/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
'use client';

import { useEffect, useState } from 'react';

const LS_KEY = 'boxmeout_theme';

function getInitialTheme(): 'dark' | 'light' {
if (typeof window === 'undefined') return 'dark';
const stored = localStorage.getItem(LS_KEY);
if (stored === 'dark' || stored === 'light') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

export function ThemeToggle(): JSX.Element {
const [theme, setTheme] = useState<'dark' | 'light'>('dark');

useEffect(() => {
const initial = getInitialTheme();
setTheme(initial);
document.documentElement.classList.toggle('dark', initial === 'dark');
}, []);

const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark';
setTheme(next);
localStorage.setItem(LS_KEY, next);
document.documentElement.classList.toggle('dark', next === 'dark');
};

return (
<button
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
className="flex items-center justify-center w-9 h-9 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
>
{theme === 'dark' ? (
// Sun icon
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4.22 1.78a1 1 0 011.42 1.42l-.71.7a1 1 0 11-1.42-1.41l.71-.71zM18 9a1 1 0 110 2h-1a1 1 0 110-2h1zM4.22 15.78a1 1 0 001.42-1.42l-.71-.7a1 1 0 00-1.42 1.41l.71.71zM3 10a1 1 0 100 2H2a1 1 0 100-2h1zm1.22-5.78a1 1 0 00-1.42 1.42l.71.7a1 1 0 001.42-1.41l-.71-.71zM10 15a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zm5.78 1.78a1 1 0 001.42-1.42l-.71-.7a1 1 0 00-1.42 1.41l.71.71zM10 7a3 3 0 100 6 3 3 0 000-6z" clipRule="evenodd" />
</svg>
) : (
// Moon icon
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
);
}
96 changes: 96 additions & 0 deletions frontend/src/components/ui/TransactionStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client';

import type { TxStatus } from '../../types';
import { TX_PENDING_STATES } from '../../types';
import { stellarExplorerUrl } from '../../services/wallet';

const PENDING_LABELS: Record<string, string> = {
signing: 'Waiting for signature…',
broadcasting: 'Broadcasting to network…',
confirming: 'Confirming on Stellar…',
};

interface TransactionStatusProps {
txStatus: TxStatus;
onRetry?: () => void;
onDismiss?: () => void;
}

export function TransactionStatus({ txStatus, onRetry, onDismiss }: TransactionStatusProps): JSX.Element | null {
const { status, hash, error } = txStatus;

if (status === 'idle') return null;

const isPending = (TX_PENDING_STATES as readonly string[]).includes(status);

return (
<div
role="status"
aria-live="polite"
className="rounded-xl border p-4 flex items-start gap-3 text-sm
bg-gray-900 border-gray-700 dark:bg-gray-900 dark:border-gray-700"
>
{isPending && (
<>
<svg
className="animate-spin h-5 w-5 text-amber-400 shrink-0 mt-0.5"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<p className="text-gray-200">{PENDING_LABELS[status]}</p>
</>
)}

{status === 'success' && (
<>
<svg className="h-5 w-5 text-green-400 shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<div className="flex-1">
<p className="font-semibold text-green-400">Transaction confirmed!</p>
{hash && (
<a
href={stellarExplorerUrl('tx', hash)}
target="_blank"
rel="noopener noreferrer"
className="text-amber-400 underline break-all text-xs mt-1 block"
>
View on Stellar Explorer ↗
</a>
)}
</div>
{onDismiss && (
<button onClick={onDismiss} aria-label="Dismiss" className="text-gray-400 hover:text-white">✕</button>
)}
</>
)}

{status === 'error' && (
<>
<svg className="h-5 w-5 text-red-400 shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
<div className="flex-1">
<p className="font-semibold text-red-400">Transaction failed</p>
{error && <p className="text-gray-300 mt-0.5">{error}</p>}
{onRetry && (
<button
onClick={onRetry}
className="mt-2 text-xs bg-red-600 hover:bg-red-500 text-white px-3 py-1 rounded-lg transition-colors"
>
Retry
</button>
)}
</div>
{onDismiss && (
<button onClick={onDismiss} aria-label="Dismiss" className="text-gray-400 hover:text-white">✕</button>
)}
</>
)}
</div>
);
}
11 changes: 9 additions & 2 deletions frontend/src/components/ui/TxStatusToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,19 @@ export function TxStatusToast({ txStatus, onDismiss }: TxStatusToastProps): JSX.

if (txStatus.status === 'idle') return <></>;

const isPending = ['signing', 'broadcasting', 'confirming'].includes(txStatus.status);
const pendingLabel = txStatus.status === 'signing'
? 'Waiting for signature…'
: txStatus.status === 'broadcasting'
? 'Broadcasting…'
: 'Confirming on Stellar…';

return (
<div className="fixed bottom-4 right-4 z-50 max-w-sm w-full bg-gray-900 text-white rounded-xl shadow-xl p-4 flex items-start gap-3">
{txStatus.status === 'pending' && (
{isPending && (
<>
<span className="animate-spin text-xl">⏳</span>
<p className="text-sm">Transaction pending…</p>
<p className="text-sm">{pendingLabel}</p>
</>
)}
{txStatus.status === 'success' && (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/hooks/useBet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function useBet(market: Market): UseBetResult {
if (!xlm || xlm <= 0) return;
setError(null);
setIsSubmitting(true);
setTxStatus({ hash: null, status: 'pending', error: null });
setTxStatus({ hash: null, status: 'signing', error: null });
try {
const hash = await submitBet(market.market_id, side, xlm);
setTxStatus({ hash, status: 'success', error: null });
Expand Down
Loading