feat(onboarding): dashboard empty state, getting-started checklist and re-show button (#352)
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m46s
A profile without any transaction now opens a dashboard that says what to do: a welcome card with the expanded getting-started checklist and one primary action towards the import page, instead of widgets showing 0 $. With transactions, the compact checklist sits between the title and the filter panel until it is complete or dismissed. - `EmptyState` (pure, dashed card from the import source list; a `to` action renders a react-router `<Link>`, an `onClick` action a button) - `OnboardingChecklist` (pure, expanded and compact modes; undone rows link to their route, the locked Budget row shows a lock, the "Base and up" label and links to the license card; neutral budget row and hidden counter until the entitlement is known) - `DashboardEmptyState` composes both - `DashboardPage` branches on the hook `status`: loading renders the title, help and banner only; error falls back to the v0.15.0 page without the list and logs a warning; ready with zero transactions renders the empty state outside the `isLoading` veil - `useOnboardingChecklist` exposes `counters` and `error` for the page - Users settings: "Show the getting-started checklist" button in the Guide section, visible only when the preference is set, with an inline confirmation after the click - FR + EN keys `dashboard.empty.*`, `onboarding.checklist.*`, `settings.users.onboarding.*` Tests: `EmptyState.test.tsx` and `OnboardingChecklist.test.tsx` by direct call (FilterPanel pattern). No DB migration. CHANGELOG left to #357. Resolves #352 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
76fd8d376a
commit
a060f0e0e0
10 changed files with 763 additions and 4 deletions
47
src/components/dashboard/DashboardEmptyState.tsx
Normal file
47
src/components/dashboard/DashboardEmptyState.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LayoutDashboard } from "lucide-react";
|
||||||
|
import EmptyState from "../shared/EmptyState";
|
||||||
|
import OnboardingChecklist from "./OnboardingChecklist";
|
||||||
|
import type { Checklist } from "../../utils/onboardingChecklist";
|
||||||
|
|
||||||
|
interface DashboardEmptyStateProps {
|
||||||
|
checklist: Checklist;
|
||||||
|
budgetReady: boolean;
|
||||||
|
/** The user dismissed the checklist: the welcome card stays, the list goes. */
|
||||||
|
hidden: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dashboard of a profile without any transaction (#352): a welcome card with
|
||||||
|
* the expanded getting-started checklist and one primary action towards the
|
||||||
|
* import page, in place of widgets showing 0 $.
|
||||||
|
*/
|
||||||
|
export default function DashboardEmptyState({
|
||||||
|
checklist,
|
||||||
|
budgetReady,
|
||||||
|
hidden,
|
||||||
|
onHide,
|
||||||
|
}: DashboardEmptyStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<LayoutDashboard size={40} />}
|
||||||
|
title={t("dashboard.empty.title")}
|
||||||
|
description={t("dashboard.empty.description")}
|
||||||
|
primaryAction={{ label: t("dashboard.empty.cta"), to: "/import" }}
|
||||||
|
>
|
||||||
|
{!hidden && (
|
||||||
|
<OnboardingChecklist
|
||||||
|
items={checklist.items}
|
||||||
|
done={checklist.done}
|
||||||
|
total={checklist.total}
|
||||||
|
mode="expanded"
|
||||||
|
budgetReady={budgetReady}
|
||||||
|
onHide={onHide}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</EmptyState>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/components/dashboard/OnboardingChecklist.test.tsx
Normal file
183
src/components/dashboard/OnboardingChecklist.test.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
// OnboardingChecklist — unit tests (#352).
|
||||||
|
//
|
||||||
|
// FilterPanel.test.tsx pattern: no jsdom, `react-i18next` and `lucide-react`
|
||||||
|
// mocked, the component called as a function and its element tree walked.
|
||||||
|
// `t` returns the key, with the interpolation values appended, so the asserts
|
||||||
|
// read the i18n keys and never a hard-coded string.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, o?: Record<string, unknown>) =>
|
||||||
|
o ? `${key}[${Object.entries(o).map(([a, b]) => `${a}=${String(b)}`).join(",")}]` : key,
|
||||||
|
i18n: { language: "fr" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
CheckCircle2: () => null,
|
||||||
|
Circle: () => null,
|
||||||
|
Lock: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CheckCircle2, Circle, Lock } from "lucide-react";
|
||||||
|
import OnboardingChecklist, { type OnboardingChecklistProps } from "./OnboardingChecklist";
|
||||||
|
import { computeChecklist } from "../../utils/onboardingChecklist";
|
||||||
|
import type { OnboardingCounters } from "../../shared/types";
|
||||||
|
|
||||||
|
type AnyElement = ReactElement<Record<string, unknown> & { children?: ReactNode }>;
|
||||||
|
|
||||||
|
function find(node: ReactNode, pred: (el: AnyElement) => boolean): AnyElement[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
const el = node as AnyElement;
|
||||||
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function texts(node: ReactNode): string[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap(texts);
|
||||||
|
if (typeof node === "string") return [node];
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
return texts((node as AnyElement).props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaves of the walk: react-router's Link and the mocked icons keep their type.
|
||||||
|
const LEAVES = new Set<unknown>([Link, CheckCircle2, Circle, Lock]);
|
||||||
|
|
||||||
|
/** Resolves the inner `RowIcon` component so the icon elements are visible. */
|
||||||
|
function expand(node: ReactNode): ReactNode {
|
||||||
|
if (Array.isArray(node)) return node.map(expand);
|
||||||
|
if (!isValidElement(node)) return node;
|
||||||
|
const el = node as AnyElement;
|
||||||
|
if (typeof el.type === "function" && !LEAVES.has(el.type)) {
|
||||||
|
const render = el.type as (props: Record<string, unknown>) => ReactNode;
|
||||||
|
return expand(render(el.props));
|
||||||
|
}
|
||||||
|
const children = el.props.children;
|
||||||
|
return { ...el, props: { ...el.props, children: expand(children) } } as ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZERO: OnboardingCounters = {
|
||||||
|
sources: 0,
|
||||||
|
imports: 0,
|
||||||
|
manualCategorized: 0,
|
||||||
|
budgetEntries: 0,
|
||||||
|
transactions: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function render(overrides: Partial<OnboardingChecklistProps> = {}, counters = ZERO, budgetAllowed = true) {
|
||||||
|
const checklist = computeChecklist(counters, { importFolderSet: false, budgetAllowed });
|
||||||
|
const onHide = vi.fn();
|
||||||
|
const props: OnboardingChecklistProps = {
|
||||||
|
items: checklist.items,
|
||||||
|
done: checklist.done,
|
||||||
|
total: checklist.total,
|
||||||
|
mode: "expanded",
|
||||||
|
budgetReady: true,
|
||||||
|
onHide,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
return { element: expand(OnboardingChecklist(props)), onHide, checklist };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowOf(element: ReactNode, key: string): AnyElement {
|
||||||
|
const row = find(element, (e) => e.type === "li" && e.key === key)[0];
|
||||||
|
expect(row, `row ${key}`).toBeDefined();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OnboardingChecklist — expanded", () => {
|
||||||
|
it("renders the title, the counter 1 / 6 and the hide button", () => {
|
||||||
|
const { element, onHide } = render();
|
||||||
|
const text = texts(element);
|
||||||
|
expect(text).toContain("onboarding.checklist.title");
|
||||||
|
expect(text).toContain("onboarding.checklist.counter[done=1,total=6]");
|
||||||
|
const [hide] = find(element, (e) => e.type === "button");
|
||||||
|
expect(hide.props.type).toBe("button");
|
||||||
|
expect(texts(hide.props.children)).toContain("onboarding.checklist.hide");
|
||||||
|
(hide.props.onClick as () => void)();
|
||||||
|
expect(onHide).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders six rows with their i18n labels", () => {
|
||||||
|
const { element } = render();
|
||||||
|
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
||||||
|
const text = texts(element);
|
||||||
|
for (const key of ["profile", "importFolder", "source", "import", "manualCategory", "budget"]) {
|
||||||
|
expect(text).toContain(`onboarding.checklist.items.${key}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("links every undone row to its `to`, and never a done row", () => {
|
||||||
|
const { element, checklist } = render();
|
||||||
|
const links = find(element, (e) => e.type === Link);
|
||||||
|
const undone = checklist.items.filter((i) => !i.done);
|
||||||
|
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
|
||||||
|
const profile = rowOf(element, "profile");
|
||||||
|
expect(find(profile, (e) => e.type === Link)).toHaveLength(0);
|
||||||
|
expect(find(profile, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a row done when its counter is reached", () => {
|
||||||
|
const { element } = render({}, { ...ZERO, sources: 1 });
|
||||||
|
const source = rowOf(element, "source");
|
||||||
|
expect(find(source, (e) => e.type === CheckCircle2)).toHaveLength(1);
|
||||||
|
expect(find(source, (e) => e.type === Link)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("in Free, the budget row shows a lock, the 'Base and up' label and links to the license card", () => {
|
||||||
|
const { element } = render({}, ZERO, false);
|
||||||
|
const budget = rowOf(element, "budget");
|
||||||
|
expect(find(budget, (e) => e.type === Lock)).toHaveLength(1);
|
||||||
|
expect(texts(budget)).toContain("onboarding.checklist.lockedBudget");
|
||||||
|
const [link] = find(budget, (e) => e.type === Link);
|
||||||
|
expect(link.props.to).toBe("/settings/users");
|
||||||
|
expect(texts(element)).toContain("onboarding.checklist.counter[done=1,total=5]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("while the entitlement is unknown, the budget row is neutral and the counter is absent", () => {
|
||||||
|
const { element } = render({ budgetReady: false }, ZERO, false);
|
||||||
|
const budget = rowOf(element, "budget");
|
||||||
|
expect(find(budget, (e) => e.type === Lock)).toHaveLength(0);
|
||||||
|
expect(find(budget, (e) => e.type === CheckCircle2)).toHaveLength(0);
|
||||||
|
expect(find(budget, (e) => e.type === Circle)).toHaveLength(1);
|
||||||
|
expect(find(budget, (e) => e.type === Link)).toHaveLength(0);
|
||||||
|
expect(texts(budget)).not.toContain("onboarding.checklist.lockedBudget");
|
||||||
|
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OnboardingChecklist — compact", () => {
|
||||||
|
it("renders the icons on one line with the counter, without the row sentences", () => {
|
||||||
|
const { element } = render({ mode: "compact" }, { ...ZERO, sources: 1, imports: 1 });
|
||||||
|
const text = texts(element);
|
||||||
|
expect(text).toContain("onboarding.checklist.title");
|
||||||
|
expect(text).toContain("onboarding.checklist.counter[done=3,total=6]");
|
||||||
|
expect(text.some((s) => s.startsWith("onboarding.checklist.items."))).toBe(false);
|
||||||
|
expect(find(element, (e) => e.type === "li")).toHaveLength(6);
|
||||||
|
expect(find(element, (e) => e.type === CheckCircle2)).toHaveLength(3);
|
||||||
|
expect(find(element, (e) => e.type === Circle)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels each icon for assistive technologies and links the undone rows", () => {
|
||||||
|
const { element, checklist } = render({ mode: "compact" });
|
||||||
|
const links = find(element, (e) => e.type === Link);
|
||||||
|
const undone = checklist.items.filter((i) => !i.done);
|
||||||
|
expect(links.map((l) => l.props.to)).toEqual(undone.map((i) => i.to));
|
||||||
|
for (const link of links) {
|
||||||
|
expect(String(link.props["aria-label"])).toMatch(/^onboarding\.checklist\.items\./);
|
||||||
|
}
|
||||||
|
const [profileIcon] = find(element, (e) => e.type === "span" && e.props.role === "img");
|
||||||
|
expect(profileIcon.props["aria-label"]).toBe("onboarding.checklist.items.profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the hide button and hides the counter until the entitlement is known", () => {
|
||||||
|
const { element, onHide } = render({ mode: "compact", budgetReady: false });
|
||||||
|
expect(texts(element).some((s) => s.startsWith("onboarding.checklist.counter"))).toBe(false);
|
||||||
|
const [hide] = find(element, (e) => e.type === "button");
|
||||||
|
(hide.props.onClick as () => void)();
|
||||||
|
expect(onHide).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
164
src/components/dashboard/OnboardingChecklist.tsx
Normal file
164
src/components/dashboard/OnboardingChecklist.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { CheckCircle2, Circle, Lock } from "lucide-react";
|
||||||
|
import type { ChecklistItem } from "../../shared/types";
|
||||||
|
|
||||||
|
export type OnboardingChecklistMode = "expanded" | "compact";
|
||||||
|
|
||||||
|
export interface OnboardingChecklistProps {
|
||||||
|
items: ChecklistItem[];
|
||||||
|
/** Number of unlocked rows that are done. */
|
||||||
|
done: number;
|
||||||
|
/** Number of unlocked rows (6, or 5 when Budget is locked). */
|
||||||
|
total: number;
|
||||||
|
/** `expanded` = one row per line with its label; `compact` = icons on one line. */
|
||||||
|
mode: OnboardingChecklistMode;
|
||||||
|
/**
|
||||||
|
* False until the Budget entitlement is known. The budget row is then
|
||||||
|
* neutral (neither done nor locked) and the counter is hidden, because
|
||||||
|
* `total` depends on the lock.
|
||||||
|
*/
|
||||||
|
budgetReady: boolean;
|
||||||
|
onHide: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowState = "done" | "todo" | "locked" | "pending";
|
||||||
|
|
||||||
|
function rowState(item: ChecklistItem, budgetReady: boolean): RowState {
|
||||||
|
if (item.key === "budget" && !budgetReady) return "pending";
|
||||||
|
if (item.locked) return "locked";
|
||||||
|
return item.done ? "done" : "todo";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON_CLASS: Record<RowState, string> = {
|
||||||
|
done: "text-[var(--positive)]",
|
||||||
|
todo: "text-[var(--muted-foreground)]",
|
||||||
|
locked: "text-[var(--muted-foreground)]",
|
||||||
|
pending: "text-[var(--muted-foreground)] opacity-50",
|
||||||
|
};
|
||||||
|
|
||||||
|
function RowIcon({ state, size }: { state: RowState; size: number }) {
|
||||||
|
const className = `shrink-0 ${ICON_CLASS[state]}`;
|
||||||
|
if (state === "done") return <CheckCircle2 size={size} className={className} aria-hidden="true" />;
|
||||||
|
if (state === "locked") return <Lock size={size} className={className} aria-hidden="true" />;
|
||||||
|
return <Circle size={size} className={className} aria-hidden="true" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getting-started checklist of the dashboard (#352). Pure: the only hook is
|
||||||
|
* `useTranslation`, so the component is tested by calling it as a function.
|
||||||
|
*
|
||||||
|
* - A row that is not done links to its `to` route; a done row has no link.
|
||||||
|
* - A locked row (Budget outside the edition) shows a lock, the "Base and up"
|
||||||
|
* label and links to the license card (`/settings/users`).
|
||||||
|
* - Compact mode keeps the title, the icons (one per row, labelled for
|
||||||
|
* assistive technologies), the counter and the hide button, no sentences.
|
||||||
|
*/
|
||||||
|
export default function OnboardingChecklist({
|
||||||
|
items,
|
||||||
|
done,
|
||||||
|
total,
|
||||||
|
mode,
|
||||||
|
budgetReady,
|
||||||
|
onHide,
|
||||||
|
}: OnboardingChecklistProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const counter = budgetReady ? (
|
||||||
|
<span className="text-sm font-medium text-[var(--muted-foreground)] tabular-nums">
|
||||||
|
{t("onboarding.checklist.counter", { done, total })}
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
const hideButton = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onHide}
|
||||||
|
className="text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
{t("onboarding.checklist.hide")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mode === "compact") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label={t("onboarding.checklist.title")}
|
||||||
|
className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-2 mb-6"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold">{t("onboarding.checklist.title")}</span>
|
||||||
|
<ul className="flex items-center gap-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const state = rowState(item, budgetReady);
|
||||||
|
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||||
|
const icon = <RowIcon state={state} size={18} />;
|
||||||
|
return (
|
||||||
|
<li key={item.key} className="flex">
|
||||||
|
{state === "todo" || state === "locked" ? (
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className="rounded-md p-0.5 hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span title={label} aria-label={label} role="img" className="p-0.5">
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{counter}
|
||||||
|
<span className="ml-auto">{hideButton}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label={t("onboarding.checklist.title")}
|
||||||
|
className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<h3 className="font-semibold">{t("onboarding.checklist.title")}</h3>
|
||||||
|
{counter}
|
||||||
|
<span className="ml-auto">{hideButton}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item) => {
|
||||||
|
const state = rowState(item, budgetReady);
|
||||||
|
const label = t(`onboarding.checklist.items.${item.key}`);
|
||||||
|
return (
|
||||||
|
<li key={item.key} className="flex items-center gap-3 text-sm">
|
||||||
|
<RowIcon state={state} size={20} />
|
||||||
|
{state === "todo" && (
|
||||||
|
<Link to={item.to} className="hover:underline text-[var(--foreground)]">
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{state === "locked" && (
|
||||||
|
<Link
|
||||||
|
to={item.to}
|
||||||
|
className="flex flex-wrap items-center gap-2 hover:underline text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="rounded-full border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--muted-foreground)]">
|
||||||
|
{t("onboarding.checklist.lockedBudget")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{state === "done" && (
|
||||||
|
<span className="text-[var(--muted-foreground)] line-through">{label}</span>
|
||||||
|
)}
|
||||||
|
{state === "pending" && <span className="text-[var(--muted-foreground)]">{label}</span>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/components/shared/EmptyState.test.tsx
Normal file
111
src/components/shared/EmptyState.test.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// EmptyState — unit tests (#352).
|
||||||
|
//
|
||||||
|
// No jsdom / testing-library in this project (see FilterPanel.test.tsx): the
|
||||||
|
// component is pure (no hook), so it is called as a plain function and its
|
||||||
|
// React-element tree is walked directly. `Link` is react-router's real
|
||||||
|
// component: creating the element never needs a router, only rendering would.
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { isValidElement, type ReactElement, type ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
import EmptyState from "./EmptyState";
|
||||||
|
|
||||||
|
function find(node: ReactNode, pred: (el: ReactElement) => boolean): ReactElement[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap((n) => find(n, pred));
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
const el = node as ReactElement<{ children?: ReactNode }>;
|
||||||
|
return [...(pred(el) ? [el] : []), ...find(el.props.children, pred)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function texts(node: ReactNode): string[] {
|
||||||
|
if (Array.isArray(node)) return node.flatMap(texts);
|
||||||
|
if (typeof node === "string") return [node];
|
||||||
|
if (!isValidElement(node)) return [];
|
||||||
|
return texts((node as ReactElement<{ children?: ReactNode }>).props.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON = "ICON_MARKER";
|
||||||
|
|
||||||
|
describe("EmptyState", () => {
|
||||||
|
it("renders the icon, the title and the description", () => {
|
||||||
|
const el = EmptyState({ icon: ICON, title: "Title", description: "Desc" });
|
||||||
|
const text = texts(el);
|
||||||
|
expect(text).toContain(ICON);
|
||||||
|
expect(text).toContain("Title");
|
||||||
|
expect(text).toContain("Desc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no action container when no action is given", () => {
|
||||||
|
const el = EmptyState({ icon: ICON, title: "Title" });
|
||||||
|
expect(find(el, (e) => e.type === Link)).toHaveLength(0);
|
||||||
|
expect(find(el, (e) => e.type === "button")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a `to` action as a react-router Link, never a button", () => {
|
||||||
|
const el = EmptyState({
|
||||||
|
icon: ICON,
|
||||||
|
title: "Title",
|
||||||
|
primaryAction: { label: "Go", to: "/import" },
|
||||||
|
});
|
||||||
|
const links = find(el, (e) => e.type === Link) as ReactElement<{ to: string; children: ReactNode }>[];
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect(links[0].props.to).toBe("/import");
|
||||||
|
expect(texts(links[0].props.children)).toContain("Go");
|
||||||
|
expect(find(el, (e) => e.type === "button")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders an `onClick` action as a type=button that calls the handler", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const el = EmptyState({
|
||||||
|
icon: ICON,
|
||||||
|
title: "Title",
|
||||||
|
primaryAction: { label: "Do", onClick },
|
||||||
|
});
|
||||||
|
const buttons = find(el, (e) => e.type === "button") as ReactElement<{
|
||||||
|
type: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}>[];
|
||||||
|
expect(buttons).toHaveLength(1);
|
||||||
|
expect(buttons[0].props.type).toBe("button");
|
||||||
|
expect(buttons[0].props.disabled).toBe(false);
|
||||||
|
buttons[0].props.onClick();
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates `disabled` on the button variant", () => {
|
||||||
|
const el = EmptyState({
|
||||||
|
icon: ICON,
|
||||||
|
title: "Title",
|
||||||
|
primaryAction: { label: "Do", onClick: vi.fn(), disabled: true },
|
||||||
|
});
|
||||||
|
const [button] = find(el, (e) => e.type === "button") as ReactElement<{ disabled: boolean }>[];
|
||||||
|
expect(button.props.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders both actions, primary first", () => {
|
||||||
|
const el = EmptyState({
|
||||||
|
icon: ICON,
|
||||||
|
title: "Title",
|
||||||
|
primaryAction: { label: "Primary", to: "/a" },
|
||||||
|
secondaryAction: { label: "Secondary", onClick: vi.fn() },
|
||||||
|
});
|
||||||
|
const actions = find(el, (e) => e.type === Link || e.type === "button");
|
||||||
|
expect(actions).toHaveLength(2);
|
||||||
|
expect(actions[0].type).toBe(Link);
|
||||||
|
expect(actions[1].type).toBe("button");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders children between the description and the actions", () => {
|
||||||
|
const el = EmptyState({
|
||||||
|
icon: ICON,
|
||||||
|
title: "Title",
|
||||||
|
description: "Desc",
|
||||||
|
primaryAction: { label: "Go", to: "/a" },
|
||||||
|
children: "CHILD_MARKER",
|
||||||
|
});
|
||||||
|
const text = texts(el);
|
||||||
|
expect(text.indexOf("Desc")).toBeLessThan(text.indexOf("CHILD_MARKER"));
|
||||||
|
expect(text.indexOf("CHILD_MARKER")).toBeLessThan(text.indexOf("Go"));
|
||||||
|
});
|
||||||
|
});
|
||||||
80
src/components/shared/EmptyState.tsx
Normal file
80
src/components/shared/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An action of the empty state: either a route (rendered as a react-router
|
||||||
|
* `<Link>`, never `useNavigate`) or a callback (rendered as a `<button>`).
|
||||||
|
*/
|
||||||
|
export type EmptyStateAction =
|
||||||
|
| { label: string; to: string }
|
||||||
|
| { label: string; onClick: () => void; disabled?: boolean };
|
||||||
|
|
||||||
|
export interface EmptyStateProps {
|
||||||
|
/** Centered icon, already sized by the caller. */
|
||||||
|
icon: ReactNode;
|
||||||
|
/** Translated title. */
|
||||||
|
title: string;
|
||||||
|
/** Translated sentence under the title. */
|
||||||
|
description?: string;
|
||||||
|
primaryAction?: EmptyStateAction;
|
||||||
|
secondaryAction?: EmptyStateAction;
|
||||||
|
/** Rendered between the description and the actions. */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIMARY_CLASS =
|
||||||
|
"inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
|
const SECONDARY_CLASS =
|
||||||
|
"inline-flex items-center justify-center gap-2 px-4 py-2 rounded-md border border-[var(--border)] text-[var(--foreground)] font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
|
||||||
|
|
||||||
|
function renderAction(action: EmptyStateAction, className: string) {
|
||||||
|
if ("to" in action) {
|
||||||
|
return (
|
||||||
|
<Link to={action.to} className={className}>
|
||||||
|
{action.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={action.onClick}
|
||||||
|
disabled={action.disabled ?? false}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure empty-state card (#352): dashed border and centered icon, the same
|
||||||
|
* styling as the empty source list of the import page. No hook inside, so the
|
||||||
|
* component is tested by calling it as a function (FilterPanel.test.tsx pattern).
|
||||||
|
* Strings are passed already translated by the caller.
|
||||||
|
*/
|
||||||
|
export default function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
secondaryAction,
|
||||||
|
children,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl p-8 md:p-12 border-2 border-dashed border-[var(--border)] text-center">
|
||||||
|
<div className="mx-auto mb-4 flex justify-center text-[var(--muted-foreground)]">{icon}</div>
|
||||||
|
<h2 className="text-xl font-semibold text-[var(--foreground)]">{title}</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-[var(--muted-foreground)] max-w-xl mx-auto">{description}</p>
|
||||||
|
)}
|
||||||
|
{children && <div className="mt-6 text-left max-w-xl mx-auto">{children}</div>}
|
||||||
|
{(primaryAction || secondaryAction) && (
|
||||||
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||||
|
{primaryAction && renderAction(primaryAction, PRIMARY_CLASS)}
|
||||||
|
{secondaryAction && renderAction(secondaryAction, SECONDARY_CLASS)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -136,7 +136,11 @@ export function useOnboardingChecklist() {
|
||||||
return {
|
return {
|
||||||
status: state.status,
|
status: state.status,
|
||||||
checklist,
|
checklist,
|
||||||
|
/** Raw counters; the dashboard reads `transactions` to decide on the empty state (#352). */
|
||||||
|
counters: state.counters,
|
||||||
hidden: state.hidden,
|
hidden: state.hidden,
|
||||||
|
/** Message of the last load error, null otherwise. */
|
||||||
|
error: state.error,
|
||||||
budgetReady,
|
budgetReady,
|
||||||
refresh,
|
refresh,
|
||||||
hide,
|
hide,
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,27 @@
|
||||||
"Rising/falling categories and budget adherence summarize the reference month's changes",
|
"Rising/falling categories and budget adherence summarize the reference month's changes",
|
||||||
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
|
"The bar chart ranks your expenses by category; the chart below tracks their trend over time"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Welcome to Simpl'Résultat",
|
||||||
|
"description": "After your first import, this page will show your income, expenses, savings rate and top categories for the selected month.",
|
||||||
|
"cta": "Choose an import folder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"checklist": {
|
||||||
|
"title": "Getting started",
|
||||||
|
"counter": "{{done}} / {{total}}",
|
||||||
|
"hide": "Hide",
|
||||||
|
"lockedBudget": "Base and up",
|
||||||
|
"items": {
|
||||||
|
"profile": "Create a profile",
|
||||||
|
"importFolder": "Choose an import folder",
|
||||||
|
"source": "Set up an import source",
|
||||||
|
"import": "Import a first file",
|
||||||
|
"manualCategory": "Categorize a transaction by hand",
|
||||||
|
"budget": "Enter a first budget"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
|
|
@ -722,6 +743,11 @@
|
||||||
"accounts": "Accounts",
|
"accounts": "Accounts",
|
||||||
"licenses": "Licenses",
|
"licenses": "Licenses",
|
||||||
"userGuide": "User guide"
|
"userGuide": "User guide"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"label": "Getting-started checklist on the dashboard",
|
||||||
|
"show": "Show the getting-started checklist",
|
||||||
|
"confirmation": "The checklist will reappear on the dashboard"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,27 @@
|
||||||
"Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
|
"Les catégories en hausse/en baisse et le respect du budget résument les changements du mois de référence",
|
||||||
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
|
"Le graphique en barres classe vos dépenses par catégorie ; le graphique du bas en suit l'évolution dans le temps"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Bienvenue dans Simpl'Résultat",
|
||||||
|
"description": "Après votre premier import, cette page montrera vos revenus, vos dépenses, votre taux d'épargne et vos principales catégories pour le mois choisi.",
|
||||||
|
"cta": "Choisir un dossier d'import"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"checklist": {
|
||||||
|
"title": "Liste de démarrage",
|
||||||
|
"counter": "{{done}} / {{total}}",
|
||||||
|
"hide": "Masquer",
|
||||||
|
"lockedBudget": "Base et plus",
|
||||||
|
"items": {
|
||||||
|
"profile": "Créer un profil",
|
||||||
|
"importFolder": "Choisir un dossier d'import",
|
||||||
|
"source": "Configurer une source d'import",
|
||||||
|
"import": "Importer un premier fichier",
|
||||||
|
"manualCategory": "Classer une transaction à la main",
|
||||||
|
"budget": "Saisir un premier budget"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
|
|
@ -722,6 +743,11 @@
|
||||||
"accounts": "Comptes",
|
"accounts": "Comptes",
|
||||||
"licenses": "Licences",
|
"licenses": "Licences",
|
||||||
"userGuide": "Guide d'utilisation"
|
"userGuide": "Guide d'utilisation"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"label": "Liste de démarrage sur le tableau de bord",
|
||||||
|
"show": "Afficher la liste de démarrage",
|
||||||
|
"confirmation": "La liste réapparaîtra sur le tableau de bord"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"data": {
|
"data": {
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,26 @@
|
||||||
// ranked bar chart (replacing the former expense-only pie) and the net-worth
|
// ranked bar chart (replacing the former expense-only pie) and the net-worth
|
||||||
// tile (from the Bilan — a different metric from every transactional widget
|
// tile (from the Bilan — a different metric from every transactional widget
|
||||||
// here, see NetWorthTile's own header comment).
|
// here, see NetWorthTile's own header comment).
|
||||||
import { useState, useCallback, useMemo } from "react";
|
//
|
||||||
|
// Onboarding (#352): the page branches on the getting-started checklist
|
||||||
|
// `status`, never on a bare counter. While it loads, only the title and the
|
||||||
|
// banner are rendered (no flash of 0 $ widgets nor of the empty state). A
|
||||||
|
// profile without any transaction gets `DashboardEmptyState` instead of the
|
||||||
|
// widgets, outside the `isLoading` veil so its link stays clickable. With
|
||||||
|
// transactions, the compact checklist sits between the title and the filter
|
||||||
|
// panel until it is complete or dismissed. A load error falls back to the
|
||||||
|
// v0.15.0 page without the list.
|
||||||
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
|
import { useOnboardingChecklist } from "../hooks/useOnboardingChecklist";
|
||||||
|
import { logWarn } from "../services/logService";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
|
||||||
|
import DashboardEmptyState from "../components/dashboard/DashboardEmptyState";
|
||||||
|
import OnboardingChecklist from "../components/dashboard/OnboardingChecklist";
|
||||||
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
import NetWorthTile from "../components/dashboard/NetWorthTile";
|
||||||
import FilterPanel from "../components/reports/FilterPanel";
|
import FilterPanel from "../components/reports/FilterPanel";
|
||||||
import KpiCard from "../components/reports/cards/KpiCard";
|
import KpiCard from "../components/reports/cards/KpiCard";
|
||||||
|
|
@ -35,6 +48,13 @@ export default function DashboardPage() {
|
||||||
setAccountIds,
|
setAccountIds,
|
||||||
} = useDashboard();
|
} = useDashboard();
|
||||||
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
|
||||||
|
const onboarding = useOnboardingChecklist();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (onboarding.status === "error") {
|
||||||
|
logWarn("Dashboard: getting-started checklist unavailable, rendering without it", onboarding.error);
|
||||||
|
}
|
||||||
|
}, [onboarding.status, onboarding.error]);
|
||||||
|
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
|
||||||
|
|
@ -58,13 +78,53 @@ export default function DashboardPage() {
|
||||||
|
|
||||||
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
|
||||||
|
|
||||||
return (
|
const header = (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<>
|
||||||
<CategoriesV1DiscoveryBanner />
|
<CategoriesV1DiscoveryBanner />
|
||||||
<div className="relative flex items-center gap-3 mb-6">
|
<div className="relative flex items-center gap-3 mb-6">
|
||||||
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
|
||||||
<PageHelp helpKey="dashboard" />
|
<PageHelp helpKey="dashboard" />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onboarding.status === "loading") {
|
||||||
|
return <div>{header}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { checklist } = onboarding;
|
||||||
|
const checklistReady = onboarding.status === "ready" && checklist !== null;
|
||||||
|
|
||||||
|
if (checklistReady && onboarding.counters?.transactions === 0) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{header}
|
||||||
|
<DashboardEmptyState
|
||||||
|
checklist={checklist}
|
||||||
|
budgetReady={onboarding.budgetReady}
|
||||||
|
hidden={onboarding.hidden}
|
||||||
|
onHide={onboarding.hide}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCompactChecklist = checklistReady && !onboarding.hidden && !checklist.complete;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||||
|
{header}
|
||||||
|
|
||||||
|
{showCompactChecklist && (
|
||||||
|
<OnboardingChecklist
|
||||||
|
items={checklist.items}
|
||||||
|
done={checklist.done}
|
||||||
|
total={checklist.total}
|
||||||
|
mode="compact"
|
||||||
|
budgetReady={onboarding.budgetReady}
|
||||||
|
onHide={onboarding.hide}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{state.error && (
|
{state.error && (
|
||||||
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,51 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft, ListChecks } from "lucide-react";
|
||||||
import AccountCard from "../../components/settings/AccountCard";
|
import AccountCard from "../../components/settings/AccountCard";
|
||||||
import LicenseCard from "../../components/settings/LicenseCard";
|
import LicenseCard from "../../components/settings/LicenseCard";
|
||||||
import DocsContent from "../../components/settings/DocsContent";
|
import DocsContent from "../../components/settings/DocsContent";
|
||||||
|
import { getPreference } from "../../services/userPreferenceService";
|
||||||
|
import {
|
||||||
|
ONBOARDING_CHECKLIST_HIDDEN_KEY,
|
||||||
|
showChecklist,
|
||||||
|
} from "../../services/onboardingService";
|
||||||
|
import { logError } from "../../services/logService";
|
||||||
|
|
||||||
|
// Re-show button of the getting-started checklist (#352). `hidden` = the
|
||||||
|
// preference was "1" at mount; `shown` = the button was clicked and the
|
||||||
|
// preference removed, so a confirmation line replaces the button.
|
||||||
|
type ChecklistButtonState = "unknown" | "visible" | "hidden" | "shown";
|
||||||
|
|
||||||
export default function UsersSettingsPage() {
|
export default function UsersSettingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [checklistButton, setChecklistButton] = useState<ChecklistButtonState>("unknown");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const value = await getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY);
|
||||||
|
if (!cancelled) setChecklistButton(value === "1" ? "hidden" : "visible");
|
||||||
|
} catch (e) {
|
||||||
|
// Informational control: without the preference, keep it out of the way.
|
||||||
|
if (!cancelled) setChecklistButton("visible");
|
||||||
|
logError("Settings: failed to read the checklist preference", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowChecklist = async () => {
|
||||||
|
try {
|
||||||
|
await showChecklist();
|
||||||
|
setChecklistButton("shown");
|
||||||
|
} catch (e) {
|
||||||
|
logError("Settings: failed to show the checklist", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -38,6 +77,25 @@ export default function UsersSettingsPage() {
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||||
{t("settings.users.sections.userGuide")}
|
{t("settings.users.sections.userGuide")}
|
||||||
</h2>
|
</h2>
|
||||||
|
{(checklistButton === "hidden" || checklistButton === "shown") && (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4 text-sm">
|
||||||
|
<ListChecks size={18} className="shrink-0 text-[var(--muted-foreground)]" aria-hidden="true" />
|
||||||
|
<span className="flex-1 min-w-[12rem]">{t("settings.users.onboarding.label")}</span>
|
||||||
|
{checklistButton === "hidden" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleShowChecklist}
|
||||||
|
className="inline-flex items-center justify-center px-3 py-1.5 rounded-md border border-[var(--border)] font-medium hover:bg-[var(--muted)] transition-colors"
|
||||||
|
>
|
||||||
|
{t("settings.users.onboarding.show")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span role="status" className="text-[var(--muted-foreground)]">
|
||||||
|
{t("settings.users.onboarding.confirmation")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DocsContent />
|
<DocsContent />
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue