Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ downloads/
eggs/
.eggs/
lib/
!frontend/src/lib/
!frontend/src/lib/**
lib64/
parts/
sdist/
Expand Down
74 changes: 74 additions & 0 deletions frontend/src/__tests__/toast-context.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ToastProvider, useToast } from '../contexts/ToastContext';

function ToastHarness() {
const toast = useToast();

return (
<div>
<button onClick={() => toast.success('Bounty published')}>Success</button>
<button onClick={() => toast.error('Submission failed')}>Error</button>
<button onClick={() => toast.warning('Verify fee first')}>Warning</button>
<button onClick={() => toast.info('Treasury copied')}>Info</button>
</div>
);
}

function renderHarness() {
return render(
<ToastProvider>
<ToastHarness />
</ToastProvider>,
);
}

beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = function scrollIntoView() {};
});

afterEach(() => {
vi.useRealTimers();
});

describe('ToastProvider', () => {
it('renders all variants as accessible alerts and stacks multiple toasts', () => {
renderHarness();

fireEvent.click(screen.getByText('Success'));
fireEvent.click(screen.getByText('Error'));
fireEvent.click(screen.getByText('Warning'));
fireEvent.click(screen.getByText('Info'));

expect(screen.getAllByRole('alert')).toHaveLength(4);
expect(screen.getByText('Bounty published')).toBeInTheDocument();
expect(screen.getByText('Submission failed')).toBeInTheDocument();
expect(screen.getByText('Verify fee first')).toBeInTheDocument();
expect(screen.getByText('Treasury copied')).toBeInTheDocument();
});

it('supports manual dismiss', async () => {
renderHarness();

fireEvent.click(screen.getByText('Success'));
fireEvent.click(screen.getByLabelText(/dismiss notification/i));

await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
});

it('auto-dismisses after five seconds by default', async () => {
vi.useFakeTimers();
renderHarness();

fireEvent.click(screen.getByText('Info'));
expect(screen.getByRole('alert')).toBeInTheDocument();

act(() => {
vi.advanceTimersByTime(5200);
});

vi.useRealTimers();
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument());
});
Comment on lines +60 to +73
});
23 changes: 19 additions & 4 deletions frontend/src/components/bounty/BountyCreateWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Check, ChevronRight, Loader2, Copy } from 'lucide-react';
import type { BountyCreatePayload } from '../../types/bounty';
import { createBounty, getTreasuryDepositInfo, verifyEscrowDeposit } from '../../api/bounties';
import { pageTransition } from '../../lib/animations';
import { useToast } from '../../contexts/ToastContext';

const PRESET_AMOUNTS = [10, 20, 50, 100, 200];
const PLATFORM_FEE_PCT = 0.05;
Expand Down Expand Up @@ -270,6 +271,7 @@ function Step3({
onSubmit: () => void;
creating: boolean;
}) {
const toast = useToast();
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState<string | null>(null);
const [copiedAddr, setCopiedAddr] = useState(false);
Expand All @@ -280,6 +282,7 @@ function Step3({
if (!state.treasury_address) return;
navigator.clipboard.writeText(state.treasury_address).then(() => {
setCopiedAddr(true);
toast.info('Treasury address copied.');
setTimeout(() => setCopiedAddr(false), 2000);
});
};
Expand All @@ -292,11 +295,16 @@ function Step3({
const result = await verifyEscrowDeposit({ bounty_id: state.bounty_id, tx_signature: state.tx_signature });
if (result.verified) {
onChange('verified', true);
toast.success('Payment verified. Your bounty is ready to publish.');
} else {
setVerifyError(result.error ?? 'Verification failed. Check your transaction signature.');
const message = result.error ?? 'Verification failed. Check your transaction signature.';
setVerifyError(message);
toast.error(message);
}
} catch {
setVerifyError('Verification failed. Try again.');
const message = 'Verification failed. Try again.';
setVerifyError(message);
toast.error(message);
} finally {
setVerifying(false);
}
Expand Down Expand Up @@ -379,6 +387,7 @@ function Step3({
}

export function BountyCreateWizard() {
const toast = useToast();
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [creating, setCreating] = useState(false);
Expand Down Expand Up @@ -422,8 +431,11 @@ export function BountyCreateWizard() {
onChange('treasury_address', depositInfo.treasury_address);
onChange('total_to_fund', depositInfo.total_to_fund);
setStep(2);
toast.info('Draft bounty created. Fund escrow to publish it.');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to create bounty. Try again.');
const message = e instanceof Error ? e.message : 'Failed to create bounty. Try again.';
setError(message);
toast.error(message);
} finally {
setCreating(false);
}
Expand All @@ -436,8 +448,11 @@ export function BountyCreateWizard() {
try {
await verifyEscrowDeposit({ bounty_id: state.bounty_id, tx_signature: state.tx_signature });
setSuccess(true);
toast.success('Bounty published. Contributors will be notified.');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to publish bounty. Try again.');
const message = e instanceof Error ? e.message : 'Failed to publish bounty. Try again.';
setError(message);
toast.error(message);
} finally {
setCreating(false);
}
Expand Down
31 changes: 26 additions & 5 deletions frontend/src/components/bounty/SubmissionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import React, { useState } from 'react';
import { Loader2, Check, Copy } from 'lucide-react';
import type { Bounty } from '../../types/bounty';
import { createSubmission, getReviewFee, verifyReviewFee } from '../../api/bounties';
import { useToast } from '../../contexts/ToastContext';

interface SubmissionFormProps {
bounty: Bounty;
onSuccess?: () => void;
}

export function SubmissionForm({ bounty, onSuccess }: SubmissionFormProps) {
const toast = useToast();
const hasRepo = bounty.has_repo ?? !!bounty.github_repo_url;
const [url, setUrl] = useState('');
const [description, setDescription] = useState('');
Expand Down Expand Up @@ -38,19 +40,34 @@ export function SubmissionForm({ bounty, onSuccess }: SubmissionFormProps) {
const result = await verifyReviewFee({ bounty_id: bounty.id, tx_signature: txSig });
if (result.verified) {
setFeeVerified(true);
toast.success('Review fee verified. You can submit your solution now.');
} else {
setError(result.error ?? 'Fee verification failed. Check your transaction signature.');
const message = result.error ?? 'Fee verification failed. Check your transaction signature.';
setError(message);
toast.error(message);
}
} catch {
setError('Fee verification failed. Try again.');
const message = 'Fee verification failed. Try again.';
setError(message);
toast.error(message);
} finally {
setVerifying(false);
}
};

const handleSubmit = async () => {
if (!url.trim()) { setError('URL is required.'); return; }
if (!feeVerified) { setError('Verify your FNDRY review fee first.'); return; }
if (!url.trim()) {
const message = 'URL is required.';
setError(message);
toast.warning(message);
return;
}
if (!feeVerified) {
const message = 'Verify your FNDRY review fee first.';
setError(message);
toast.warning(message);
return;
}
setSubmitting(true);
setError(null);
try {
Expand All @@ -61,9 +78,12 @@ export function SubmissionForm({ bounty, onSuccess }: SubmissionFormProps) {
tx_signature: txSig,
});
setSuccess(true);
toast.success('Submission received. AI review will begin shortly.');
onSuccess?.();
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Submission failed. Try again.');
const message = e instanceof Error ? e.message : 'Submission failed. Try again.';
setError(message);
toast.error(message);
} finally {
setSubmitting(false);
}
Expand All @@ -72,6 +92,7 @@ export function SubmissionForm({ bounty, onSuccess }: SubmissionFormProps) {
const copyTreasury = () => {
navigator.clipboard.writeText(TREASURY).then(() => {
setCopied(true);
toast.info('Treasury address copied.');
setTimeout(() => setCopied(false), 2000);
});
Comment on lines 93 to 97
};
Expand Down
154 changes: 154 additions & 0 deletions frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { AlertCircle, CheckCircle2, Info, TriangleAlert, X } from 'lucide-react';

export type ToastVariant = 'success' | 'error' | 'warning' | 'info';

export interface ToastOptions {
title?: string;
duration?: number;
}

interface Toast {
id: string;
message: string;
title?: string;
variant: ToastVariant;
duration: number;
}

interface ToastContextValue {
showToast: (message: string, variant?: ToastVariant, options?: ToastOptions) => string;
dismissToast: (id: string) => void;
success: (message: string, options?: ToastOptions) => string;
error: (message: string, options?: ToastOptions) => string;
warning: (message: string, options?: ToastOptions) => string;
info: (message: string, options?: ToastOptions) => string;
}

const DEFAULT_DURATION = 5000;
const ToastContext = createContext<ToastContextValue | null>(null);

const variantStyles: Record<ToastVariant, { icon: React.ElementType; border: string; iconClass: string; title: string }> = {
success: {
icon: CheckCircle2,
border: 'border-emerald-border bg-emerald-bg text-emerald',
iconClass: 'text-emerald',
title: 'Success',
},
error: {
icon: AlertCircle,
border: 'border-status-error/30 bg-status-error/10 text-status-error',
iconClass: 'text-status-error',
title: 'Error',
},
warning: {
icon: TriangleAlert,
border: 'border-status-warning/30 bg-status-warning/10 text-status-warning',
iconClass: 'text-status-warning',
title: 'Warning',
},
info: {
icon: Info,
border: 'border-status-info/30 bg-status-info/10 text-status-info',
iconClass: 'text-status-info',
title: 'Info',
},
};

function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
const styles = variantStyles[toast.variant];
const Icon = styles.icon;

React.useEffect(() => {
if (toast.duration <= 0) return;
const timeout = window.setTimeout(() => onDismiss(toast.id), toast.duration);
return () => window.clearTimeout(timeout);
}, [toast.duration, toast.id, onDismiss]);

return (
<motion.div
layout
initial={{ opacity: 0, x: 32, scale: 0.98 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 32, scale: 0.98 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
role="alert"
aria-live="assertive"
className={`w-full max-w-sm rounded-xl border bg-forge-900 shadow-2xl shadow-black/40 ${styles.border}`}
>
<div className="flex items-start gap-3 px-4 py-3">
<Icon className={`mt-0.5 h-5 w-5 flex-shrink-0 ${styles.iconClass}`} />
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-text-primary">{toast.title ?? styles.title}</p>
<p className="mt-0.5 text-sm text-text-secondary break-words">{toast.message}</p>
</div>
<button
type="button"
aria-label="Dismiss notification"
onClick={() => onDismiss(toast.id)}
className="rounded p-1 text-text-muted transition-colors hover:bg-forge-800 hover:text-text-primary"
>
<X className="h-4 w-4" />
</button>
</div>
</motion.div>
);
}

export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);

const dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);

const showToast = useCallback((message: string, variant: ToastVariant = 'info', options?: ToastOptions) => {
const id = typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`;

setToasts((current) => [
...current,
{
id,
message,
variant,
title: options?.title,
duration: options?.duration ?? DEFAULT_DURATION,
},
]);

return id;
}, []);

const value = useMemo<ToastContextValue>(() => ({
showToast,
dismissToast,
success: (message, options) => showToast(message, 'success', options),
error: (message, options) => showToast(message, 'error', options),
warning: (message, options) => showToast(message, 'warning', options),
info: (message, options) => showToast(message, 'info', options),
}), [dismissToast, showToast]);

return (
<ToastContext.Provider value={value}>
{children}
<div className="fixed right-4 top-20 z-[100] flex w-[calc(100vw-2rem)] max-w-sm flex-col gap-3 sm:right-6">
<AnimatePresence initial={false}>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={dismissToast} />
))}
</AnimatePresence>
</div>
Comment on lines +137 to +143
</ToastContext.Provider>
);
}

export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}
Loading