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
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useRef, useState, useTransition } from "react";
import { useRef, useState, useTransition } from "react";
import {
BookOpen,
Check,
Expand All @@ -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,
Expand Down Expand Up @@ -50,13 +50,11 @@ const ERROR_TO_KEY: Record<string, string> = {
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 (
<section className="flex flex-col gap-3">
Expand Down Expand Up @@ -94,54 +92,18 @@ export function FeedbackSection({ isDemo = false }: { isDemo?: boolean }) {
onOpenChange={setOpen}
onSuccess={() => {
setOpen(false);
setShowSuccess(true);
toast.show({
icon: <Check className="size-[18px]" />,
message: t("success"),
});
}}
/>

{showSuccess ? (
<FeedbackSuccessToast
message={t("success")}
dismissLabel={t("close")}
onDone={() => setShowSuccess(false)}
/>
) : null}
</>
) : null}
</section>
);
}

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 (
<Toast
icon={<Check className="size-[18px]" />}
onDismiss={() => onDoneRef.current()}
dismissLabel={dismissLabel}
>
{message}
</Toast>
);
}

function FeedbackSheet({
open,
onOpenChange,
Expand Down
5 changes: 4 additions & 1 deletion app/[locale]/protected/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,7 +41,9 @@ export default async function ProtectedBaseLayout({
avatarUrl={profile?.avatar_url ?? null}
displayName={profile?.username ?? user.email ?? null}
/>
<main className="flex flex-1 flex-col">{children}</main>
<ToastProvider>
<main className="flex flex-1 flex-col">{children}</main>
</ToastProvider>
{nudges.length > 0 ? <NudgeToaster nudges={nudges} /> : null}
</div>
);
Expand Down
10 changes: 9 additions & 1 deletion components/rituals/define-ritual-button.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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"));

Expand Down Expand Up @@ -55,6 +57,12 @@ export function DefineRitualButton({ variant, categories, label }: Props) {
onOpenChange={setOpen}
mode="create"
categories={categories}
onSaved={(values) =>
toast.show({
icon: <Check className="size-[18px]" />,
message: t("create.saved", { name: values.name }),
})
}
/>
</>
);
Expand Down
9 changes: 8 additions & 1 deletion components/rituals/ritual-form-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -31,6 +34,7 @@ export function RitualFormSheet({
mode,
categories,
initialValues,
onSaved,
}: Props) {
const t = useTranslations("rituals");

Expand All @@ -47,7 +51,10 @@ export function RitualFormSheet({
mode={mode}
categories={categories}
initialValues={initialValues}
onSuccess={() => onOpenChange(false)}
onSuccess={(values) => {
onOpenChange(false);
onSaved?.(values);
}}
/>
</SheetContent>
</Sheet>
Expand Down
4 changes: 2 additions & 2 deletions components/rituals/ritual-form/ritual-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type Props = {
mode: Mode;
categories: RitualCategoryRow[];
initialValues?: RitualFormInitialValues;
onSuccess: () => void;
onSuccess: (values: RitualFormValues) => void;
};

const DEFAULT_CREATE_VALUES: RitualFormValues = {
Expand Down Expand Up @@ -172,7 +172,7 @@ export function RitualForm({
setActionError(result.error);
return;
}
onSuccess();
onSuccess(values);
});
};

Expand Down
78 changes: 78 additions & 0 deletions components/ui/toast-provider.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastContextValue | null>(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<ActiveToast | null>(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 (
<ToastContext.Provider value={{ show }}>
{children}
{toast ? (
<Toast
// Remount on replace so the slide-in replays.
key={toast.id}
icon={toast.icon}
onDismiss={() => setToast(null)}
dismissLabel={t("close")}
>
{toast.message}
</Toast>
) : null}
</ToastContext.Provider>
);
}
3 changes: 2 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,8 @@
}
},
"create": {
"title": "Define a ritual"
"title": "Define a ritual",
"saved": "Ritual defined · {name}"
},
"edit": {
"title": "Edit ritual"
Expand Down
3 changes: 2 additions & 1 deletion messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading