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
1 change: 0 additions & 1 deletion src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ export function Navbar() {
color: pathname === link.href
? '#22C55E' : '#A8BCCF',
}}
onClick={() => setMobileOpen(false)}
>
{link.label}
</Link>
Expand Down
90 changes: 90 additions & 0 deletions src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastType, string> = {
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<ToastType, typeof CheckCircle2> = {
success: CheckCircle2,
error: XCircle,
warning: AlertTriangle,
info: Info,
}

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

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 (
<ToastContext.Provider value={{ toast, dismissToast }}>
{children}
<div className="fixed right-4 top-4 z-50 flex w-[min(420px,calc(100vw-2rem))] flex-col gap-3">
{toasts.map((toastItem) => {
const Icon = TOAST_ICONS[toastItem.type]

return (
<div
key={toastItem.id}
className={clsx(
'flex items-start gap-3 rounded-2xl border p-4 shadow-2xl backdrop-blur-sm',
TOAST_STYLES[toastItem.type],
)}
>
<Icon className="mt-0.5 h-5 w-5 shrink-0" />
<p className="flex-1 text-sm font-medium leading-5">{toastItem.message}</p>
<button
type="button"
onClick={() => dismissToast(toastItem.id)}
className="rounded-full p-1 text-current/70 transition hover:bg-black/10 hover:text-current"
aria-label="Dismiss notification"
>
<X className="h-4 w-4" />
</button>
</div>
)
})}
</div>
</ToastContext.Provider>
)
}

15 changes: 15 additions & 0 deletions src/components/ui/ToastContext.ts
Original file line number Diff line number Diff line change
@@ -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<ToastContextValue | undefined>(undefined)
12 changes: 12 additions & 0 deletions src/hooks/useToast.ts
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 4 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ 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()

createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<Router />
<ToastProvider>
<Router />
</ToastProvider>
</QueryClientProvider>
</StrictMode>,
)
8 changes: 6 additions & 2 deletions src/pages/Sponsors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,6 +19,7 @@ import {

export function Sponsors() {
const { isConnected } = useWallet()
const { toast } = useToast()
const [shares, setShares] = useState('')
const [successData, setSuccessData] = useState<{
hash: string
Expand All @@ -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)
}
}

Expand Down
84 changes: 78 additions & 6 deletions src/pages/VendorRegister.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) => {
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 (
<div className="max-w-7xl mx-auto px-6 py-12">
<h1 className="font-display font-bold text-3xl
text-text-primary mb-2">Register as Vendor</h1>
<p className="text-text-muted">
Vendor registration form coming soon.
</p>
<div className="max-w-4xl mx-auto px-6 py-12">
<div className="mb-8">
<h1 className="font-display font-bold text-3xl text-text-primary mb-2">
Register as Vendor
</h1>
<p className="text-text-muted">
Share your details and we’ll review your vendor application.
</p>
</div>

<Card>
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm text-text-secondary mb-2">Business name</label>
<input
value={form.businessName}
onChange={(e) => 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"
/>
</div>

<div>
<label className="block text-sm text-text-secondary mb-2">Contact email</label>
<input
type="email"
value={form.contactEmail}
onChange={(e) => 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"
/>
</div>

<div>
<label className="block text-sm text-text-secondary mb-2">Website (optional)</label>
<input
value={form.website}
onChange={(e) => 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"
/>
</div>

<Button type="submit" className="w-full">
Submit Registration
</Button>
</form>
</Card>
</div>
)
}
14 changes: 13 additions & 1 deletion src/pages/Vouch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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')
Expand All @@ -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)
},
})

Expand All @@ -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)
},
})

Expand Down Expand Up @@ -155,7 +167,7 @@ export function Vouch() {
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Transaction failed'
alert(message)
toast.error(message)
}
}

Expand Down
Loading