diff --git a/app/[locale]/protected/(app)/settings/components/feedback-section.tsx b/app/[locale]/protected/(app)/settings/components/feedback-section.tsx index 568416a..7df52e7 100644 --- a/app/[locale]/protected/(app)/settings/components/feedback-section.tsx +++ b/app/[locale]/protected/(app)/settings/components/feedback-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { BookOpen, Check, @@ -22,7 +22,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import { Toast } from "@/components/ui/toast"; +import { useToast } from "@/components/ui/toast-provider"; import { FEEDBACK_BODY_MAX, FEEDBACK_SCREENSHOT_MAX_BYTES, @@ -50,13 +50,11 @@ const ERROR_TO_KEY: Record = { failed: "errorFailed", }; -const SUCCESS_TOAST_MS = 5000; - export function FeedbackSection({ isDemo = false }: { isDemo?: boolean }) { const t = useTranslations("settings.feedback"); const tHelp = useTranslations("settings.help"); const [open, setOpen] = useState(false); - const [showSuccess, setShowSuccess] = useState(false); + const toast = useToast(); return (
@@ -94,54 +92,18 @@ export function FeedbackSection({ isDemo = false }: { isDemo?: boolean }) { onOpenChange={setOpen} onSuccess={() => { setOpen(false); - setShowSuccess(true); + toast.show({ + icon: , + message: t("success"), + }); }} /> - - {showSuccess ? ( - setShowSuccess(false)} - /> - ) : null} ) : null}
); } -function FeedbackSuccessToast({ - message, - dismissLabel, - onDone, -}: { - message: string; - dismissLabel: string; - onDone: () => void; -}) { - // Keep the latest onDone without resetting the auto-dismiss timer on re-render. - const onDoneRef = useRef(onDone); - useEffect(() => { - onDoneRef.current = onDone; - }); - - useEffect(() => { - const timer = setTimeout(() => onDoneRef.current(), SUCCESS_TOAST_MS); - return () => clearTimeout(timer); - }, []); - - return ( - } - onDismiss={() => onDoneRef.current()} - dismissLabel={dismissLabel} - > - {message} - - ); -} - function FeedbackSheet({ open, onOpenChange, diff --git a/app/[locale]/protected/layout.tsx b/app/[locale]/protected/layout.tsx index b03b11f..a871744 100644 --- a/app/[locale]/protected/layout.tsx +++ b/app/[locale]/protected/layout.tsx @@ -6,6 +6,7 @@ import { getUnseenNudges } from "@/lib/data/nudges"; import { createClient } from "@/lib/supabase/server"; import { ProtectedHeader } from "@/components/layout/protected-header"; import { NudgeToaster } from "@/components/nudges/nudge-toaster"; +import { ToastProvider } from "@/components/ui/toast-provider"; export default async function ProtectedBaseLayout({ children, @@ -40,7 +41,9 @@ export default async function ProtectedBaseLayout({ avatarUrl={profile?.avatar_url ?? null} displayName={profile?.username ?? user.email ?? null} /> -
{children}
+ +
{children}
+
{nudges.length > 0 ? : null} ); diff --git a/components/rituals/define-ritual-button.tsx b/components/rituals/define-ritual-button.tsx index 192955d..fd4df41 100644 --- a/components/rituals/define-ritual-button.tsx +++ b/components/rituals/define-ritual-button.tsx @@ -1,9 +1,10 @@ "use client"; -import { Plus } from "lucide-react"; +import { Check, Plus } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import { useToast } from "@/components/ui/toast-provider"; import type { RitualCategoryRow } from "@/lib/data/rituals"; import { cn } from "@/lib/utils"; @@ -21,6 +22,7 @@ type Props = { export function DefineRitualButton({ variant, categories, label }: Props) { const t = useTranslations("rituals"); const [open, setOpen] = useState(false); + const toast = useToast(); const triggerLabel = label ?? (variant === "cta" ? t("defineFirst") : t("defineCta")); @@ -55,6 +57,12 @@ export function DefineRitualButton({ variant, categories, label }: Props) { onOpenChange={setOpen} mode="create" categories={categories} + onSaved={(values) => + toast.show({ + icon: , + message: t("create.saved", { name: values.name }), + }) + } /> ); diff --git a/components/rituals/ritual-form-sheet.tsx b/components/rituals/ritual-form-sheet.tsx index c81d87f..d433daf 100644 --- a/components/rituals/ritual-form-sheet.tsx +++ b/components/rituals/ritual-form-sheet.tsx @@ -9,6 +9,7 @@ import { SheetTitle, } from "@/components/ui/sheet"; import type { RitualCategoryRow } from "@/lib/data/rituals"; +import type { RitualFormValues } from "@/lib/data/rituals-schema"; import { RitualForm, @@ -23,6 +24,8 @@ type Props = { mode: Mode; categories: RitualCategoryRow[]; initialValues?: RitualFormInitialValues; + /** Called after a successful save, once the sheet has closed. */ + onSaved?: (values: RitualFormValues) => void; }; export function RitualFormSheet({ @@ -31,6 +34,7 @@ export function RitualFormSheet({ mode, categories, initialValues, + onSaved, }: Props) { const t = useTranslations("rituals"); @@ -47,7 +51,10 @@ export function RitualFormSheet({ mode={mode} categories={categories} initialValues={initialValues} - onSuccess={() => onOpenChange(false)} + onSuccess={(values) => { + onOpenChange(false); + onSaved?.(values); + }} /> diff --git a/components/rituals/ritual-form/ritual-form.tsx b/components/rituals/ritual-form/ritual-form.tsx index 2c2be17..81d31ca 100644 --- a/components/rituals/ritual-form/ritual-form.tsx +++ b/components/rituals/ritual-form/ritual-form.tsx @@ -41,7 +41,7 @@ type Props = { mode: Mode; categories: RitualCategoryRow[]; initialValues?: RitualFormInitialValues; - onSuccess: () => void; + onSuccess: (values: RitualFormValues) => void; }; const DEFAULT_CREATE_VALUES: RitualFormValues = { @@ -172,7 +172,7 @@ export function RitualForm({ setActionError(result.error); return; } - onSuccess(); + onSuccess(values); }); }; diff --git a/components/ui/toast-provider.tsx b/components/ui/toast-provider.tsx new file mode 100644 index 0000000..a036be8 --- /dev/null +++ b/components/ui/toast-provider.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; + +import { Toast } from "./toast"; + +const DISMISS_AFTER_MS = 5000; + +type ToastOptions = { + icon: ReactNode; + message: ReactNode; +}; + +type ActiveToast = ToastOptions & { id: number }; + +type ToastContextValue = { + show: (options: ToastOptions) => void; +}; + +const ToastContext = createContext(null); + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + throw new Error("useToast must be used within a ToastProvider"); + } + return ctx; +} + +/** + * Owns the confirmation toast for the protected app. Mounted in the layout so a + * toast outlives the component that triggered it (e.g. the empty-state CTA that + * unmounts once the first ritual exists). One toast at a time: a new one + * replaces the current one and restarts the auto-dismiss timer. + */ +export function ToastProvider({ children }: { children: ReactNode }) { + const t = useTranslations("common"); + const [toast, setToast] = useState(null); + const nextId = useRef(0); + + const show = useCallback((options: ToastOptions) => { + nextId.current += 1; + setToast({ ...options, id: nextId.current }); + }, []); + + const toastId = toast?.id; + useEffect(() => { + if (toastId === undefined) return; + const timer = setTimeout(() => setToast(null), DISMISS_AFTER_MS); + return () => clearTimeout(timer); + }, [toastId]); + + return ( + + {children} + {toast ? ( + setToast(null)} + dismissLabel={t("close")} + > + {toast.message} + + ) : null} + + ); +} diff --git a/messages/en.json b/messages/en.json index fe0e185..683b4ba 100644 --- a/messages/en.json +++ b/messages/en.json @@ -672,7 +672,8 @@ } }, "create": { - "title": "Define a ritual" + "title": "Define a ritual", + "saved": "Ritual defined · {name}" }, "edit": { "title": "Edit ritual" diff --git a/messages/fr.json b/messages/fr.json index a681e81..bbdbbd0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -672,7 +672,8 @@ } }, "create": { - "title": "Définir un rituel" + "title": "Définir un rituel", + "saved": "Rituel défini · {name}" }, "edit": { "title": "Modifier le rituel"