diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 4701cd8..0fd68cc 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -166,7 +166,6 @@ export function Navbar() { color: pathname === link.href ? '#22C55E' : '#A8BCCF', }} - onClick={() => setMobileOpen(false)} > {link.label} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..709b191 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,90 @@ +import { useCallback, useMemo, useState } from 'react' +import type { ReactNode } from 'react' +import { CheckCircle2, Info, AlertTriangle, XCircle, X } from 'lucide-react' +import { clsx } from 'clsx' +import { ToastContext } from './ToastContext' +import type { ToastType } from './ToastContext' + +interface ToastItem { + id: string + type: ToastType + message: string + duration?: number +} + +const TOAST_STYLES: Record = { + success: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200', + error: 'border-rose-500/30 bg-rose-500/10 text-rose-200', + warning: 'border-amber-500/30 bg-amber-500/10 text-amber-200', + info: 'border-sky-500/30 bg-sky-500/10 text-sky-200', +} + +const TOAST_ICONS: Record = { + success: CheckCircle2, + error: XCircle, + warning: AlertTriangle, + info: Info, +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const dismissToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)) + }, []) + + const addToast = useCallback( + (type: ToastType, message: string, duration = 4000) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + setToasts((prev) => [...prev, { id, type, message, duration }]) + + window.setTimeout(() => { + dismissToast(id) + }, duration) + }, + [dismissToast], + ) + + const toast = useMemo( + () => ({ + success: (message: string, duration?: number) => addToast('success', message, duration), + error: (message: string, duration?: number) => addToast('error', message, duration), + warning: (message: string, duration?: number) => addToast('warning', message, duration), + info: (message: string, duration?: number) => addToast('info', message, duration), + }), + [addToast], + ) + + return ( + + {children} +
+ {toasts.map((toastItem) => { + const Icon = TOAST_ICONS[toastItem.type] + + return ( +
+ +

{toastItem.message}

+ +
+ ) + })} +
+
+ ) +} + diff --git a/src/components/ui/ToastContext.ts b/src/components/ui/ToastContext.ts new file mode 100644 index 0000000..fdbfc40 --- /dev/null +++ b/src/components/ui/ToastContext.ts @@ -0,0 +1,15 @@ +import { createContext } from 'react' + +export type ToastType = 'success' | 'error' | 'warning' | 'info' + +export interface ToastContextValue { + toast: { + success: (message: string, duration?: number) => void + error: (message: string, duration?: number) => void + warning: (message: string, duration?: number) => void + info: (message: string, duration?: number) => void + } + dismissToast: (id: string) => void +} + +export const ToastContext = createContext(undefined) diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..f64e92b --- /dev/null +++ b/src/hooks/useToast.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react' +import { ToastContext } from '../components/ui/ToastContext' + +export function useToast() { + const context = useContext(ToastContext) + + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + + return context +} diff --git a/src/main.tsx b/src/main.tsx index c50b66a..178844a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Router } from './router' +import { ToastProvider } from './components/ui/Toast' import './index.css' const queryClient = new QueryClient() @@ -9,7 +10,9 @@ const queryClient = new QueryClient() createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/pages/Sponsors.tsx b/src/pages/Sponsors.tsx index f3beb56..33ac7ac 100644 --- a/src/pages/Sponsors.tsx +++ b/src/pages/Sponsors.tsx @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { sponsorsService } from '../services/sponsors.service' import { useTransaction } from '../hooks/useTransaction' import { useWallet } from '../hooks/useWallet' +import { useToast } from '../hooks/useToast' import { Button } from '../components/ui/Button' import { Card } from '../components/ui/Card' import { Badge } from '../components/ui/Badge' @@ -18,6 +19,7 @@ import { export function Sponsors() { const { isConnected } = useWallet() + const { toast } = useToast() const [shares, setShares] = useState('') const [successData, setSuccessData] = useState<{ hash: string @@ -44,8 +46,10 @@ export function Sponsors() { ) setSuccessData(result) setShares('') - } catch { - // Error handled by useTransaction and shown in UI + toast.success('Withdrawal confirmed successfully.') + } catch (error) { + const message = error instanceof Error ? error.message : 'Withdrawal failed.' + toast.error(message) } } diff --git a/src/pages/VendorRegister.tsx b/src/pages/VendorRegister.tsx index 7d6cae8..0b63e79 100644 --- a/src/pages/VendorRegister.tsx +++ b/src/pages/VendorRegister.tsx @@ -1,11 +1,83 @@ +import { useState } from 'react' +import type { FormEvent } from 'react' +import { Button } from '../components/ui/Button' +import { Card } from '../components/ui/Card' +import { useToast } from '../hooks/useToast' + export function VendorRegister() { + const { toast } = useToast() + const [form, setForm] = useState({ + businessName: '', + contactEmail: '', + website: '', + }) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + + if (!form.businessName.trim() || !form.contactEmail.trim()) { + toast.error('Please fill in the business name and contact email.') + return + } + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailPattern.test(form.contactEmail)) { + toast.error('Please provide a valid email address.') + return + } + + toast.success('Vendor registration request submitted successfully.') + } + return ( -
-

Register as Vendor

-

- Vendor registration form coming soon. -

+
+
+

+ Register as Vendor +

+

+ Share your details and we’ll review your vendor application. +

+
+ + +
+
+ + setForm((prev) => ({ ...prev, businessName: e.target.value }))} + className="w-full bg-bg border border-border rounded-xl px-4 py-3 text-text-primary focus:border-brand focus:outline-none" + placeholder="StepFi Education" + /> +
+ +
+ + setForm((prev) => ({ ...prev, contactEmail: e.target.value }))} + className="w-full bg-bg border border-border rounded-xl px-4 py-3 text-text-primary focus:border-brand focus:outline-none" + placeholder="vendor@example.com" + /> +
+ +
+ + setForm((prev) => ({ ...prev, website: e.target.value }))} + className="w-full bg-bg border border-border rounded-xl px-4 py-3 text-text-primary focus:border-brand focus:outline-none" + placeholder="https://example.com" + /> +
+ + +
+
) } diff --git a/src/pages/Vouch.tsx b/src/pages/Vouch.tsx index 7610c73..7a8159d 100644 --- a/src/pages/Vouch.tsx +++ b/src/pages/Vouch.tsx @@ -11,6 +11,7 @@ import { Spinner } from '../components/ui/Spinner' import { VouchRequestCard } from '../components/vouch/VouchRequestCard' import { VouchImpactPreview } from '../components/vouch/VouchImpactPreview' import { useWallet } from '../hooks/useWallet' +import { useToast } from '../hooks/useToast' import { STELLAR_NETWORK } from '../constants/config' import type { VouchRequest } from '../types' @@ -86,6 +87,7 @@ function ConfirmRevokeDialog({ export function Vouch() { const navigate = useNavigate() const queryClient = useQueryClient() + const { toast } = useToast() const { isConnected: walletConnected, connectFreighter } = useWallet() const [activeTab, setActiveTab] = useState<'requests' | 'active'>('requests') @@ -110,6 +112,11 @@ export function Vouch() { queryClient.invalidateQueries({ queryKey: ['vouch-requests'] }) queryClient.invalidateQueries({ queryKey: ['my-vouches'] }) setPreviewRequest(null) + toast.success('Vouch submitted successfully.') + }, + onError: (error) => { + const message = error instanceof Error ? error.message : 'Failed to submit vouch.' + toast.error(message) }, }) @@ -118,6 +125,11 @@ export function Vouch() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['my-vouches'] }) setRevokeTarget(null) + toast.success('Vouch revoked successfully.') + }, + onError: (error) => { + const message = error instanceof Error ? error.message : 'Failed to revoke vouch.' + toast.error(message) }, }) @@ -155,7 +167,7 @@ export function Vouch() { }) } catch (err) { const message = err instanceof Error ? err.message : 'Transaction failed' - alert(message) + toast.error(message) } }