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
11 changes: 10 additions & 1 deletion packages/frontend/src/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { useState } from 'react';
import Link from 'next/link';
import { auth } from '@/lib/api';
import { LogoIcon } from '@/components/nav-bar';
import { useToast } from '@/components/toast';

export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const toast = useToast();

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
Expand All @@ -19,8 +21,15 @@ export default function ForgotPasswordPage() {
try {
await auth.forgotPassword(email);
setSent(true);
toast.show({
tone: 'success',
title: 'Reset link requested',
description: 'If that email is registered, a reset link is on the way.',
});
} catch (err: any) {
setError(err.message || 'Something went wrong');
const message = err.message || 'Something went wrong';
setError(message);
toast.show({ tone: 'error', title: 'Request failed', description: message });
} finally {
setLoading(false);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/frontend/src/app/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import { AuthProvider } from '@/lib/auth-context';
import { ThemeProvider } from '@/lib/theme-context';
import { ToastProvider } from '@/components/toast';

export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
<AuthProvider>
<ToastProvider>{children}</ToastProvider>
</AuthProvider>
</ThemeProvider>
);
}
122 changes: 122 additions & 0 deletions packages/frontend/src/components/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
'use client';

/**
* Unified toast system built on @radix-ui/react-toast.
*
* Replaces the ad-hoc `setMsg(string)` pattern that pages were using to
* surface success/error feedback. Pages call `useToast().show({...})`;
* the queue is rendered by the global Toaster mounted in providers.tsx.
*
* - Auto-dismiss after 5s; the user can dismiss earlier by clicking the
* close button or by hovering and pressing Escape (Radix handles the
* keyboard interactions).
* - Tones: 'success' | 'error' | 'info' (default 'info').
* - Up to 4 toasts are visible at once; new toasts evict the oldest.
*/

import * as ToastPrimitive from '@radix-ui/react-toast';
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from 'react';

export type ToastTone = 'success' | 'error' | 'info';

export interface ToastInput {
title?: string;
description?: string;
tone?: ToastTone;
durationMs?: number;
}

interface ToastEntry extends ToastInput {
id: string;
}

interface ToastContextValue {
show: (input: ToastInput) => void;
}

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

const MAX_VISIBLE = 4;

export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
// Don't crash a page that's still wired to the old setMsg pattern;
// give a no-op so migration can be incremental.
return { show: () => {} };
}
return ctx;
}

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

const show = useCallback((input: ToastInput) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((prev) => {
const next = [...prev, { id, ...input }];
return next.length > MAX_VISIBLE ? next.slice(next.length - MAX_VISIBLE) : next;
});
}, []);

const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);

const value = useMemo(() => ({ show }), [show]);

return (
<ToastContext.Provider value={value}>
<ToastPrimitive.Provider swipeDirection="right" duration={5000}>
{children}
{toasts.map((t) => (
<ToastPrimitive.Root
key={t.id}
duration={t.durationMs ?? 5000}
onOpenChange={(open) => {
if (!open) dismiss(t.id);
}}
className={[
'mb-2 grid grid-cols-[1fr_auto] gap-3 items-start',
'rounded-lg px-4 py-3 shadow-lg border',
'bg-[var(--card)] text-[var(--foreground)]',
t.tone === 'success'
? 'border-[var(--success-border,_#22c55e)]'
: t.tone === 'error'
? 'border-[var(--destructive-border,_#ef4444)]'
: 'border-[var(--border)]',
'data-[state=open]:animate-in data-[state=open]:fade-in',
'data-[state=closed]:animate-out data-[state=closed]:fade-out',
].join(' ')}
>
<div className="min-w-0">
{t.title ? (
<ToastPrimitive.Title className="text-sm font-semibold mb-0.5">
{t.title}
</ToastPrimitive.Title>
) : null}
{t.description ? (
<ToastPrimitive.Description className="text-sm text-[var(--muted-foreground)] break-words">
{t.description}
</ToastPrimitive.Description>
) : null}
</div>
<ToastPrimitive.Close
aria-label="Dismiss"
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] text-lg leading-none"
>
×
</ToastPrimitive.Close>
</ToastPrimitive.Root>
))}
<ToastPrimitive.Viewport className="fixed bottom-4 right-4 z-50 flex w-[360px] max-w-[calc(100vw-2rem)] flex-col" />
</ToastPrimitive.Provider>
</ToastContext.Provider>
);
}
Loading