mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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("/");
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,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
|
||||
>
|
||||
<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);
|
||||
}
|
||||
|
||||
@@ -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("/");
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
|
||||
@@ -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("/");
|
||||
};
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user