Add COSS toast notifications and swap in the new logo

Wire COSS's (Base UI) Sonner-style stacked toast into the app:

- Install @coss/toast (components/ui/toast.tsx) and mount <ToastProvider> in
  the root layout; add a small notify.{success,error,info,warning} helper
  (lib/toast.ts) over toastManager so callers don't repeat the toast shape.
- Toast on the events requested: clinic created (onboarding), auth actions
  (sign in / sign up / sign out / password reset request + reset), patient
  saved (create/edit), and all failure branches as error toasts (kept inline
  errors as a fallback). New auth toast strings added to the en translation.
- Replace the logo everywhere: overwrite public/temetro-logo.png with the new
  1024² square mark (same aspect ratio as the old 500², so existing 32×32
  next/image usages stay undistorted; no reference changes needed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-04 18:58:04 +03:00
parent 415961ab52
commit 2c5624e049
13 changed files with 435 additions and 13 deletions
+5 -1
View File
@@ -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);
};
+5 -1
View File
@@ -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("/");
};
+5 -1
View File
@@ -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);
};
+45 -1
View File
@@ -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);
+4 -1
View File
@@ -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
>
<I18nProvider>{children}</I18nProvider>
<ToastProvider>
<I18nProvider>{children}</I18nProvider>
</ToastProvider>
</ThemeProvider>
</body>
</html>
@@ -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);
}
+5 -1
View File
@@ -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("/");
};
+7 -1
View File
@@ -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");
};
+5 -1
View File
@@ -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("/");
};
+324
View File
@@ -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<typeof Toast.Root>,
"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<typeof Toast.Portal>;
}): React.ReactElement {
const { toasts } = Toast.useToastManager();
const swipeDirection = getSwipeDirection(position);
return (
<Toast.Portal data-slot="toast-portal" {...portalProps}>
<Toast.Viewport
className={cn(
"fixed z-60 mx-auto flex w-[calc(100%-var(--toast-inset)*2)] max-w-90 [--toast-inset:--spacing(4)] sm:[--toast-inset:--spacing(8)]",
// Vertical positioning
"data-[position*=top]:top-(--toast-inset)",
"data-[position*=bottom]:bottom-(--toast-inset)",
// Horizontal positioning
"data-[position*=left]:left-(--toast-inset)",
"data-[position*=right]:right-(--toast-inset)",
"data-[position*=center]:left-1/2 data-[position*=center]:-translate-x-1/2",
)}
data-position={position}
data-slot="toast-viewport"
>
{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 (
<Toast.Root
key={toast.id}
className={cn(
"absolute z-[calc(9999-var(--toast-index))] h-(--toast-calc-height) w-full select-none rounded-lg border bg-[color-mix(in_srgb,var(--popover),var(--color-black)_calc(1%*max(0,var(--toast-index,0))))] not-dark:bg-clip-padding text-popover-foreground shadow-lg/5 [transition:transform_.5s_cubic-bezier(.22,1,.36,1),opacity_.5s,height_.15s,background-color_.5s] before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] data-expanded:bg-popover dark:bg-[color-mix(in_srgb,var(--popover),var(--color-black)_calc(6%*max(0,var(--toast-index,0))))] dark:data-expanded:bg-popover dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
// Base positioning using data-position
"data-[position*=right]:right-0 data-[position*=right]:left-auto",
"data-[position*=left]:right-auto data-[position*=left]:left-0",
"data-[position*=center]:right-0 data-[position*=center]:left-0",
"data-[position*=top]:top-0 data-[position*=top]:bottom-auto data-[position*=top]:origin-[50%_calc(50%-50%*min(var(--toast-index,0),1))]",
"data-[position*=bottom]:top-auto data-[position*=bottom]:bottom-0 data-[position*=bottom]:origin-[50%_calc(50%+50%*min(var(--toast-index,0),1))]",
// Gap fill for hover
"after:absolute after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full",
"data-[position*=top]:after:top-full",
"data-[position*=bottom]:after:bottom-full",
// Define some variables
"[--toast-calc-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-gap:--spacing(3)] [--toast-peek:--spacing(3)] [--toast-scale:calc(max(0,1-(var(--toast-index)*.1)))] [--toast-shrink:calc(1-var(--toast-scale))]",
// Define offset-y variable
"data-[position*=top]:[--toast-calc-offset-y:calc(var(--toast-offset-y)+var(--toast-index)*var(--toast-gap)+var(--toast-swipe-movement-y))]",
"data-[position*=bottom]:[--toast-calc-offset-y:calc(var(--toast-offset-y)*-1+var(--toast-index)*var(--toast-gap)*-1+var(--toast-swipe-movement-y))]",
// Default state transform
"data-[position*=top]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-peek))+(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
"data-[position*=bottom]:transform-[translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--toast-peek))-(var(--toast-shrink)*var(--toast-calc-height))))_scale(var(--toast-scale))]",
// Limited state
"data-limited:opacity-0",
// Expanded state
"data-expanded:h-(--toast-height)",
"data-position:data-expanded:transform-[translateX(var(--toast-swipe-movement-x))_translateY(var(--toast-calc-offset-y))]",
// Starting and ending animations
"data-[position*=top]:data-starting-style:transform-[translateY(calc(-100%-var(--toast-inset)))]",
"data-[position*=bottom]:data-starting-style:transform-[translateY(calc(100%+var(--toast-inset)))]",
"data-ending-style:opacity-0",
// Ending animations (direction-aware)
"data-ending-style:not-data-limited:not-data-swipe-direction:transform-[translateY(calc(100%+var(--toast-inset)))]",
"data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
"data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
"data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
"data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
// Ending animations (expanded)
"data-expanded:data-ending-style:data-[swipe-direction=left]:transform-[translateX(calc(var(--toast-swipe-movement-x)-100%-var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+100%+var(--toast-inset)))_translateY(var(--toast-calc-offset-y))]",
"data-expanded:data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-100%-var(--toast-inset)))]",
"data-expanded:data-ending-style:data-[swipe-direction=down]:transform-[translateY(calc(var(--toast-swipe-movement-y)+100%+var(--toast-inset)))]",
upsertReplayClassName(toast),
)}
{...toastData?.rootProps}
data-position={position}
swipeDirection={swipeDirection}
toast={toast}
>
<Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm transition-opacity duration-250 data-behind:not-data-expanded:pointer-events-none data-behind:opacity-0 data-expanded:opacity-100">
<div className="flex gap-2">
{Icon && (
<div
className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
data-slot="toast-icon"
>
<Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
</div>
)}
<div className="flex flex-col gap-0.5">
<Toast.Title
className="font-medium"
data-slot="toast-title"
/>
<Toast.Description
className="text-muted-foreground"
data-slot="toast-description"
/>
</div>
</div>
{toast.actionProps && (
<Toast.Action
className={buttonVariants({ size: "xs" })}
data-slot="toast-action"
>
{toast.actionProps.children}
</Toast.Action>
)}
</Toast.Content>
</Toast.Root>
);
})}
</Toast.Viewport>
</Toast.Portal>
);
}
function AnchoredToasts({
portalProps,
}: {
portalProps?: React.ComponentProps<typeof Toast.Portal>;
}): React.ReactElement {
const { toasts } = Toast.useToastManager();
return (
<Toast.Portal data-slot="toast-portal-anchored" {...portalProps}>
<Toast.Viewport
className="outline-none"
data-slot="toast-viewport-anchored"
>
{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 (
<Toast.Positioner
key={toast.id}
className="z-50 max-w-[min(--spacing(64),var(--available-width))]"
data-slot="toast-positioner"
sideOffset={positionerProps.sideOffset ?? 4}
toast={toast}
>
<Toast.Root
className={cn(
"relative text-balance border bg-popover not-dark:bg-clip-padding text-popover-foreground text-xs transition-[scale,opacity] before:pointer-events-none before:absolute before:inset-0 before:shadow-[0_1px_--theme(--color-black/4%)] data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0 dark:before:shadow-[0_-1px_--theme(--color-white/6%)]",
tooltipStyle
? "rounded-md shadow-md/5 before:rounded-[calc(var(--radius-md)-1px)]"
: "rounded-lg shadow-lg/5 before:rounded-[calc(var(--radius-lg)-1px)]",
upsertReplayClassName(toast),
)}
{...toastData?.rootProps}
data-slot="toast-popup"
toast={toast}
>
{tooltipStyle ? (
<Toast.Content className="pointer-events-auto px-2 py-1">
<Toast.Title data-slot="toast-title" />
</Toast.Content>
) : (
<Toast.Content className="pointer-events-auto flex items-center justify-between gap-1.5 overflow-hidden px-3.5 py-3 text-sm">
<div className="flex gap-2">
{Icon && (
<div
className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
data-slot="toast-icon"
>
<Icon className="in-data-[type=loading]:animate-spin in-data-[type=error]:text-destructive in-data-[type=info]:text-info in-data-[type=success]:text-success in-data-[type=warning]:text-warning in-data-[type=loading]:opacity-80" />
</div>
)}
<div className="flex flex-col gap-0.5">
<Toast.Title
className="font-medium"
data-slot="toast-title"
/>
<Toast.Description
className="text-muted-foreground"
data-slot="toast-description"
/>
</div>
</div>
{toast.actionProps && (
<Toast.Action
className={buttonVariants({ size: "xs" })}
data-slot="toast-action"
>
{toast.actionProps.children}
</Toast.Action>
)}
</Toast.Content>
)}
</Toast.Root>
</Toast.Positioner>
);
})}
</Toast.Viewport>
</Toast.Portal>
);
}
export const toastManager: ReturnType<typeof Toast.createToastManager> =
Toast.createToastManager();
export const anchoredToastManager: ReturnType<typeof Toast.createToastManager> =
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<typeof Toast.Portal>;
}
export function ToastProvider({
children,
position = "bottom-right",
portalProps,
...props
}: ToastProviderProps): React.ReactElement {
return (
<Toast.Provider toastManager={toastManager} {...props}>
{children}
<Toasts portalProps={portalProps} position={position} />
</Toast.Provider>
);
}
export interface AnchoredToastProviderProps extends Toast.Provider.Props {
portalProps?: React.ComponentProps<typeof Toast.Portal>;
}
export function AnchoredToastProvider({
children,
portalProps,
...props
}: AnchoredToastProviderProps): React.ReactElement {
return (
<Toast.Provider toastManager={anchoredToastManager} {...props}>
{children}
<AnchoredToasts portalProps={portalProps} />
</Toast.Provider>
);
}
export { Toast as ToastPrimitive };
@@ -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": {
+16
View File
@@ -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
// <ToastProvider> 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 }),
};
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 699 KiB