diff --git a/frontend/app/(auth)/forgot-password/page.tsx b/frontend/app/(auth)/forgot-password/page.tsx index e64ec57..c3b421e 100644 --- a/frontend/app/(auth)/forgot-password/page.tsx +++ b/frontend/app/(auth)/forgot-password/page.tsx @@ -7,6 +7,7 @@ import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; export default function ForgotPasswordPage() { const [email, setEmail] = useState(""); @@ -27,9 +28,12 @@ export default function ForgotPasswordPage() { setSubmitting(false); if (err) { - setError(err.message ?? "Could not send the reset email."); + const message = err.message ?? "Could not send the reset email."; + setError(message); + notify.error("Couldn't send reset link", message); return; } + notify.success("Reset link sent", "Check your inbox for the link."); setSent(true); }; diff --git a/frontend/app/(auth)/onboarding/page.tsx b/frontend/app/(auth)/onboarding/page.tsx index 67155a1..bdcea5d 100644 --- a/frontend/app/(auth)/onboarding/page.tsx +++ b/frontend/app/(auth)/onboarding/page.tsx @@ -7,6 +7,7 @@ import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; function slugify(value: string): string { return value @@ -45,12 +46,15 @@ export default function OnboardingPage() { ); if (createErr || !org) { - setError(createErr?.message ?? "Could not create the clinic."); + const message = createErr?.message ?? "Could not create the clinic."; + setError(message); + notify.error("Couldn't create clinic", message); setSubmitting(false); return; } await authClient.organization.setActive({ organizationId: org.id }); + notify.success("Clinic created", `${org.name} is ready.`); router.push("/"); }; diff --git a/frontend/app/(auth)/reset-password/page.tsx b/frontend/app/(auth)/reset-password/page.tsx index c45a87d..7259e56 100644 --- a/frontend/app/(auth)/reset-password/page.tsx +++ b/frontend/app/(auth)/reset-password/page.tsx @@ -8,6 +8,7 @@ import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; const MIN_PASSWORD = 12; @@ -46,9 +47,12 @@ function ResetPasswordInner() { }); setSubmitting(false); if (err) { - setError(err.message ?? "Could not reset your password."); + const message = err.message ?? "Could not reset your password."; + setError(message); + notify.error("Couldn't reset password", message); return; } + notify.success("Password updated", "Redirecting you to sign in…"); setDone(true); setTimeout(() => router.push("/login"), 1500); }; diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 2a35832..5f741e4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -59,7 +59,51 @@ background-position: -200% 0; } } -} + --animate-toast-success-odd: toast-success-odd 0.32s cubic-bezier(0.5, 1, 0.89, 1); + --animate-toast-success-even: toast-success-even 0.32s cubic-bezier(0.5, 1, 0.89, 1); + --animate-toast-error-odd: toast-error-odd 0.28s cubic-bezier(0.5, 1, 0.89, 1); + --animate-toast-error-even: toast-error-even 0.28s cubic-bezier(0.5, 1, 0.89, 1) +; + @keyframes toast-success-odd { + 0% { + scale: 1;} + 30% { + scale: 1.025;} + 60% { + scale: 0.99;} + 100% { + scale: 1;}} + @keyframes toast-error-odd { + 0% { + translate: 0 0;} + 25% { + translate: -3px 0;} + 50% { + translate: 3px 0;} + 75% { + translate: -3px 0;} + 100% { + translate: 0 0;}} + @keyframes toast-success-even { + 0% { + scale: 1;} + 30% { + scale: 1.025;} + 60% { + scale: 0.99;} + 100% { + scale: 1;}} + @keyframes toast-error-even { + 0% { + translate: 0 0;} + 25% { + translate: -3px 0;} + 50% { + translate: 3px 0;} + 75% { + translate: -3px 0;} + 100% { + translate: 0 0;}}} :root { --card: var(--color-white); diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b673e65..8a1072a 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { cn } from "@/lib/utils"; import { ThemeProvider } from "@/components/theme-provider"; import { I18nProvider } from "@/components/i18n-provider"; +import { ToastProvider } from "@/components/ui/toast"; // COSS font-variable contract: --font-sans, --font-heading, --font-mono. const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); @@ -48,7 +49,9 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - {children} + + {children} + diff --git a/frontend/components/chat/patient-form-dialog.tsx b/frontend/components/chat/patient-form-dialog.tsx index 742cfbc..cb2473e 100644 --- a/frontend/components/chat/patient-form-dialog.tsx +++ b/frontend/components/chat/patient-form-dialog.tsx @@ -30,6 +30,7 @@ import { type Patient, updatePatient, } from "@/lib/patients"; +import { notify } from "@/lib/toast"; type PatientFormDialogProps = { open: boolean; @@ -289,14 +290,17 @@ export function PatientFormDialog({ : await createPatient(built); if (isEdit) { onSaved?.(saved); + notify.success("Record updated", `${saved.name}'s chart was saved.`); } else { onCreated?.(saved.fileNumber); + notify.success("Patient added", `${saved.name} (${saved.fileNumber}).`); } onOpenChange(false); } catch (err) { - setError( - err instanceof Error ? err.message : "Could not save the patient.", - ); + const message = + err instanceof Error ? err.message : "Could not save the patient."; + setError(message); + notify.error("Couldn't save patient", message); } finally { setSubmitting(false); } diff --git a/frontend/components/login-form.tsx b/frontend/components/login-form.tsx index ebba4b1..9244510 100644 --- a/frontend/components/login-form.tsx +++ b/frontend/components/login-form.tsx @@ -16,6 +16,7 @@ import { import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; import { cn } from "@/lib/utils"; export function LoginForm({ @@ -42,10 +43,13 @@ export function LoginForm({ }); if (err) { - setError(err.message ?? t("auth.login.error")); + const message = err.message ?? t("auth.login.error"); + setError(message); + notify.error(t("auth.login.errorTitle"), message); setSubmitting(false); return; } + notify.success(t("auth.login.welcomeBack")); router.push("/"); }; diff --git a/frontend/components/sidebar-02/nav-user.tsx b/frontend/components/sidebar-02/nav-user.tsx index 34ba6a0..c888577 100644 --- a/frontend/components/sidebar-02/nav-user.tsx +++ b/frontend/components/sidebar-02/nav-user.tsx @@ -29,6 +29,7 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; // Open-source repo (placeholder). const REPO_URL = "https://github.com/temetro/temetro"; @@ -69,7 +70,12 @@ export function NavUser() { const initials = initialsFromName(name); const signOut = async () => { - await authClient.signOut(); + const { error } = await authClient.signOut(); + if (error) { + notify.error("Sign out failed", error.message ?? "Please try again."); + return; + } + notify.success("Signed out"); router.push("/login"); }; diff --git a/frontend/components/signup-form.tsx b/frontend/components/signup-form.tsx index 0d443d7..5ae6b80 100644 --- a/frontend/components/signup-form.tsx +++ b/frontend/components/signup-form.tsx @@ -16,6 +16,7 @@ import { import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; import { cn } from "@/lib/utils"; const MIN_PASSWORD = 12; @@ -56,12 +57,15 @@ export function SignupForm({ }); if (err) { - setError(err.message ?? t("auth.signup.error")); + const message = err.message ?? t("auth.signup.error"); + setError(message); + notify.error(t("auth.signup.errorTitle"), message); setSubmitting(false); return; } // Verification isn't enforced yet — the user is signed in, so go to // clinic onboarding. + notify.success(t("auth.signup.created"), t("auth.signup.createdHint")); router.push("/"); }; diff --git a/frontend/components/ui/toast.tsx b/frontend/components/ui/toast.tsx new file mode 100644 index 0000000..dedf6b0 --- /dev/null +++ b/frontend/components/ui/toast.tsx @@ -0,0 +1,324 @@ +"use client"; + +import { Toast } from "@base-ui/react/toast"; +import { + CircleAlertIcon, + CircleCheckIcon, + InfoIcon, + LoaderCircleIcon, + TriangleAlertIcon, +} from "lucide-react"; +import type React from "react"; +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +const TOAST_ICONS = { + error: CircleAlertIcon, + info: InfoIcon, + loading: LoaderCircleIcon, + success: CircleCheckIcon, + warning: TriangleAlertIcon, +} as const; + +type SwipeDirection = "up" | "down" | "left" | "right"; + +type ToastData = { + rootProps?: Omit< + React.ComponentProps, + "children" | "className" | "swipeDirection" | "toast" + >; + tooltipStyle?: boolean; +}; + +function getSwipeDirection(position: ToastPosition): SwipeDirection[] { + const verticalDirection: SwipeDirection = position.startsWith("top") + ? "up" + : "down"; + + if (position.includes("center")) { + return [verticalDirection]; + } + + if (position.includes("left")) { + return ["left", verticalDirection]; + } + + return ["right", verticalDirection]; +} + +function upsertReplayClassName(toast: { + type?: string; + updateKey?: number; +}): string | undefined { + const k = toast.updateKey ?? 0; + if (k <= 0) return undefined; + const isEven = k % 2 === 0; + if (toast.type === "error") { + return isEven ? "animate-toast-error-even" : "animate-toast-error-odd"; + } + return isEven ? "animate-toast-success-even" : "animate-toast-success-odd"; +} + +function Toasts({ + position, + portalProps, +}: { + position: ToastPosition; + portalProps?: React.ComponentProps; +}): React.ReactElement { + const { toasts } = Toast.useToastManager(); + const swipeDirection = getSwipeDirection(position); + + return ( + + + {toasts.map((toast) => { + const Icon = toast.type + ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] + : null; + const toastData = toast.data as ToastData | undefined; + + return ( + + +
+ {Icon && ( +
+ +
+ )} + +
+ + +
+
+ {toast.actionProps && ( + + {toast.actionProps.children} + + )} +
+
+ ); + })} +
+
+ ); +} + +function AnchoredToasts({ + portalProps, +}: { + portalProps?: React.ComponentProps; +}): React.ReactElement { + const { toasts } = Toast.useToastManager(); + + return ( + + + {toasts.map((toast) => { + const Icon = toast.type + ? TOAST_ICONS[toast.type as keyof typeof TOAST_ICONS] + : null; + const toastData = toast.data as ToastData | undefined; + const tooltipStyle = toastData?.tooltipStyle ?? false; + const positionerProps = toast.positionerProps; + + if (!positionerProps?.anchor) { + return null; + } + + return ( + + + {tooltipStyle ? ( + + + + ) : ( + +
+ {Icon && ( +
+ +
+ )} + +
+ + +
+
+ {toast.actionProps && ( + + {toast.actionProps.children} + + )} +
+ )} +
+
+ ); + })} +
+
+ ); +} + +export const toastManager: ReturnType = + Toast.createToastManager(); + +export const anchoredToastManager: ReturnType = + Toast.createToastManager(); + +export type ToastPosition = + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; + +export interface ToastProviderProps extends Toast.Provider.Props { + position?: ToastPosition; + portalProps?: React.ComponentProps; +} + +export function ToastProvider({ + children, + position = "bottom-right", + portalProps, + ...props +}: ToastProviderProps): React.ReactElement { + return ( + + {children} + + + ); +} + +export interface AnchoredToastProviderProps extends Toast.Provider.Props { + portalProps?: React.ComponentProps; +} + +export function AnchoredToastProvider({ + children, + portalProps, + ...props +}: AnchoredToastProviderProps): React.ReactElement { + return ( + + {children} + + + ); +} + +export { Toast as ToastPrimitive }; diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 101b61f..63fbf4f 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -18,7 +18,9 @@ "submitting": "Signing in…", "noAccount": "Don't have an account?", "signUpLink": "Sign up", - "error": "Could not sign in. Check your email and password and try again." + "error": "Could not sign in. Check your email and password and try again.", + "errorTitle": "Sign in failed", + "welcomeBack": "Welcome back" }, "signup": { "title": "Create your account", @@ -36,7 +38,10 @@ "submitting": "Creating account…", "haveAccount": "Already have an account?", "signInLink": "Sign in", - "error": "Could not create your account." + "error": "Could not create your account.", + "errorTitle": "Sign up failed", + "created": "Account created", + "createdHint": "Set up your clinic to get started." } }, "nav": { diff --git a/frontend/lib/toast.ts b/frontend/lib/toast.ts new file mode 100644 index 0000000..fa71747 --- /dev/null +++ b/frontend/lib/toast.ts @@ -0,0 +1,16 @@ +import { toastManager } from "@/components/ui/toast"; + +// Thin wrapper over COSS's (Base UI) toast manager so callers don't repeat the +// `{ type, title, description }` shape. COSS ships its own Sonner-style stacked +// toast — this is the app-wide notification entry point. The matching +// lives in app/layout.tsx. +export const notify = { + success: (title: string, description?: string) => + toastManager.add({ type: "success", title, description }), + error: (title: string, description?: string) => + toastManager.add({ type: "error", title, description }), + info: (title: string, description?: string) => + toastManager.add({ type: "info", title, description }), + warning: (title: string, description?: string) => + toastManager.add({ type: "warning", title, description }), +}; diff --git a/frontend/public/temetro-logo.png b/frontend/public/temetro-logo.png index 5e59d06..5d9c278 100644 Binary files a/frontend/public/temetro-logo.png and b/frontend/public/temetro-logo.png differ