Replace empty BalanceOverviewCard with BalanceOnboardingCard showing two steps: 1. Create an account 2. Enter a snapshot Step 2 is grayed out until at least one account exists; the entire card is replaced by BalanceOverviewCard once a snapshot is recorded. Hide "+ New snapshot" button when 0 accounts (it lives inside the overview card, which is now hidden in that state). Improve SnapshotEditPage noAccounts copy to clarify account vs snapshot semantics. Resolves #178
210 lines
6.6 KiB
TypeScript
210 lines
6.6 KiB
TypeScript
// BalanceOnboardingCard — empty-state onboarding for /balance.
|
|
//
|
|
// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts
|
|
// or no snapshots yet. Two vertical steps:
|
|
// 1. Create an account → /balance/accounts
|
|
// 2. Enter a snapshot → /balance/snapshot
|
|
//
|
|
// Each step has 3 states:
|
|
// - "active": primary CTA, currently the next thing to do
|
|
// - "done": marked with a checkmark, no CTA
|
|
// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled
|
|
//
|
|
// The whole card is replaced by BalanceOverviewCard once at least one
|
|
// snapshot exists, so step 2 in practice is rendered as "active" or
|
|
// "disabled"; the "done" branch is supported for completeness/tests.
|
|
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import type { TFunction } from "i18next";
|
|
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
|
|
|
|
interface BalanceOnboardingCardProps {
|
|
/** Number of active (non-archived) accounts. */
|
|
accountsCount: number;
|
|
/** Number of snapshots saved (any date). */
|
|
snapshotsCount: number;
|
|
}
|
|
|
|
export type StepState = "active" | "done" | "disabled";
|
|
|
|
/**
|
|
* Pure helper exposed for unit tests — derives the state of each onboarding
|
|
* step from the (accountsCount, snapshotsCount) pair.
|
|
*
|
|
* - Step 1 is "done" once at least one account exists, "active" otherwise.
|
|
* - Step 2 is "done" once any snapshot exists, "active" once at least one
|
|
* account exists, "disabled" otherwise. In practice the parent guard on
|
|
* /balance only renders this card when snapshotsCount === 0, so the
|
|
* "done" branch for step 2 is mostly defensive.
|
|
*/
|
|
export function deriveOnboardingSteps(
|
|
accountsCount: number,
|
|
snapshotsCount: number
|
|
): { step1: StepState; step2: StepState } {
|
|
const step1: StepState = accountsCount >= 1 ? "done" : "active";
|
|
const step2: StepState =
|
|
snapshotsCount >= 1
|
|
? "done"
|
|
: accountsCount >= 1
|
|
? "active"
|
|
: "disabled";
|
|
return { step1, step2 };
|
|
}
|
|
|
|
export default function BalanceOnboardingCard({
|
|
accountsCount,
|
|
snapshotsCount,
|
|
}: BalanceOnboardingCardProps) {
|
|
const { t } = useTranslation();
|
|
|
|
const { step1: step1State, step2: step2State } = deriveOnboardingSteps(
|
|
accountsCount,
|
|
snapshotsCount
|
|
);
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
|
<h2 className="text-lg font-semibold mb-1">
|
|
{t("balance.onboarding.title")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--muted-foreground)] mb-5">
|
|
{t("balance.onboarding.subtitle")}
|
|
</p>
|
|
|
|
<ol className="space-y-3">
|
|
<Step
|
|
number={1}
|
|
state={step1State}
|
|
icon={<Wallet size={18} />}
|
|
title={t("balance.onboarding.step1.title")}
|
|
description={t("balance.onboarding.step1.description")}
|
|
ctaLabel={t("balance.onboarding.step1.cta")}
|
|
ctaHref="/balance/accounts"
|
|
t={t}
|
|
/>
|
|
<Step
|
|
number={2}
|
|
state={step2State}
|
|
icon={<FileText size={18} />}
|
|
title={t("balance.onboarding.step2.title")}
|
|
description={t("balance.onboarding.step2.description")}
|
|
ctaLabel={t("balance.onboarding.step2.cta")}
|
|
ctaHref="/balance/snapshot"
|
|
disabledHint={t("balance.onboarding.step2.disabledHint")}
|
|
t={t}
|
|
/>
|
|
</ol>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Internal — single step row
|
|
// -----------------------------------------------------------------------------
|
|
|
|
interface StepProps {
|
|
number: number;
|
|
state: StepState;
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
description: string;
|
|
ctaLabel: string;
|
|
ctaHref: string;
|
|
disabledHint?: string;
|
|
t: TFunction;
|
|
}
|
|
|
|
function Step({
|
|
number,
|
|
state,
|
|
icon,
|
|
title,
|
|
description,
|
|
ctaLabel,
|
|
ctaHref,
|
|
disabledHint,
|
|
t,
|
|
}: StepProps) {
|
|
const isDone = state === "done";
|
|
const isActive = state === "active";
|
|
const isDisabled = state === "disabled";
|
|
|
|
// Number bubble: green check when done, primary bg when active, muted when disabled.
|
|
const bubbleClass = isDone
|
|
? "bg-[var(--positive)] text-white"
|
|
: isActive
|
|
? "bg-[var(--primary)] text-white"
|
|
: "bg-[var(--muted)] text-[var(--muted-foreground)]";
|
|
|
|
const titleClass = isDisabled
|
|
? "text-[var(--muted-foreground)]"
|
|
: "text-[var(--foreground)]";
|
|
|
|
return (
|
|
<li
|
|
data-testid={`balance-onboarding-step-${number}`}
|
|
data-state={state}
|
|
className={`flex items-start gap-4 p-4 rounded-lg border ${
|
|
isDisabled
|
|
? "border-[var(--border)] opacity-60"
|
|
: "border-[var(--border)]"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
|
|
aria-hidden="true"
|
|
>
|
|
{isDone ? <Check size={16} /> : number}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
|
|
{icon}
|
|
</span>
|
|
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
|
|
</div>
|
|
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
|
{isDisabled && disabledHint && (
|
|
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
|
|
{disabledHint}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="shrink-0 self-center">
|
|
{isDone ? (
|
|
<span
|
|
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
|
|
data-testid={`balance-onboarding-step-${number}-done-badge`}
|
|
>
|
|
<Check size={14} />
|
|
{t("balance.onboarding.doneBadge")}
|
|
</span>
|
|
) : isActive ? (
|
|
<Link
|
|
to={ctaHref}
|
|
data-testid={`balance-onboarding-step-${number}-cta`}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
>
|
|
{ctaLabel}
|
|
<ArrowRight size={14} />
|
|
</Link>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
data-testid={`balance-onboarding-step-${number}-cta`}
|
|
aria-disabled="true"
|
|
title={disabledHint}
|
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
|
|
>
|
|
{ctaLabel}
|
|
<ArrowRight size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|