Adds a non-destructive, dismissable banner on the Dashboard that invites profiles still on the legacy v2 category seed to discover the new v1 IPC taxonomy. The banner links to the standard categories guide page (/settings/categories/standard, shipped in #117). Visibility rules: - Only rendered when `categories_schema_version='v2'` in `user_preferences`. - Hidden once the user dismisses it — dismissal is persisted in the same `user_preferences` table under the key `categories_v1_banner_dismissed` (value '1'), so the banner never reappears across app restarts. - New v1-seeded profiles never see it. No DB schema change: reuses the existing key/value `user_preferences` table via the existing `getPreference`/`setPreference` helpers. No migration added. i18n keys under `dashboard.categoriesBanner.*` (FR + EN). Changelog entry added under [Unreleased] in both CHANGELOG.md and CHANGELOG.fr.md.
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link } from "react-router-dom";
|
|
import { Sparkles, X, ArrowRight } from "lucide-react";
|
|
import {
|
|
getPreference,
|
|
setPreference,
|
|
} from "../../services/userPreferenceService";
|
|
|
|
// Preference keys used to decide whether to show the banner.
|
|
// - CATEGORIES_SCHEMA_VERSION is set by migration v8 (see src-tauri/src/lib.rs):
|
|
// existing profiles are tagged 'v2' (legacy seed), new profiles are 'v1'
|
|
// (IPC taxonomy). Only v2 profiles are invited to discover the new v1 guide.
|
|
// - BANNER_DISMISSED is a persistent flag set when the user dismisses the
|
|
// banner; once set, the banner never reappears for this profile.
|
|
const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
|
|
const BANNER_DISMISSED_KEY = "categories_v1_banner_dismissed";
|
|
|
|
type Visibility = "loading" | "visible" | "hidden";
|
|
|
|
/**
|
|
* Dashboard banner that invites users on the legacy v2 category seed to
|
|
* discover the new v1 IPC taxonomy guide. It is:
|
|
* - Non-destructive (read-only CTA that navigates to the guide page).
|
|
* - Dismissable — the dismissal is persisted in `user_preferences` and
|
|
* survives app restarts / profile reloads.
|
|
* - Only visible on profiles tagged `categories_schema_version='v2'`.
|
|
* Profiles on the new v1 seed never see it (they already have the IPC
|
|
* taxonomy).
|
|
*/
|
|
export default function CategoriesV1DiscoveryBanner() {
|
|
const { t } = useTranslation();
|
|
const [visibility, setVisibility] = useState<Visibility>("loading");
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const [schemaVersion, dismissed] = await Promise.all([
|
|
getPreference(CATEGORIES_SCHEMA_VERSION_KEY),
|
|
getPreference(BANNER_DISMISSED_KEY),
|
|
]);
|
|
if (cancelled) return;
|
|
const shouldShow = schemaVersion === "v2" && dismissed !== "1";
|
|
setVisibility(shouldShow ? "visible" : "hidden");
|
|
} catch {
|
|
// If prefs cannot be read (e.g. DB not ready), hide the banner
|
|
// silently — it is a purely informational, non-critical UI element.
|
|
if (!cancelled) setVisibility("hidden");
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
const dismiss = async () => {
|
|
// Optimistically hide the banner, then persist the flag. If persistence
|
|
// fails we still keep it hidden for this session.
|
|
setVisibility("hidden");
|
|
try {
|
|
await setPreference(BANNER_DISMISSED_KEY, "1");
|
|
} catch {
|
|
// Ignore — the banner will reappear on next launch if the write failed,
|
|
// which is an acceptable degradation.
|
|
}
|
|
};
|
|
|
|
if (visibility !== "visible") return null;
|
|
|
|
return (
|
|
<div
|
|
role="status"
|
|
className="flex items-start gap-3 rounded-xl border border-[var(--primary)]/30 bg-[var(--primary)]/5 p-4 mb-6"
|
|
>
|
|
<div className="mt-0.5 shrink-0 rounded-lg bg-[var(--primary)]/10 p-2 text-[var(--primary)]">
|
|
<Sparkles size={18} />
|
|
</div>
|
|
<div className="flex-1 space-y-2 text-sm">
|
|
<p className="font-semibold text-[var(--foreground)]">
|
|
{t("dashboard.categoriesBanner.title")}
|
|
</p>
|
|
<p className="text-[var(--muted-foreground)]">
|
|
{t("dashboard.categoriesBanner.description")}
|
|
</p>
|
|
<Link
|
|
to="/settings/categories/standard"
|
|
className="inline-flex items-center gap-1.5 font-medium text-[var(--primary)] hover:underline"
|
|
>
|
|
{t("dashboard.categoriesBanner.cta")}
|
|
<ArrowRight size={14} />
|
|
</Link>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={dismiss}
|
|
aria-label={t("dashboard.categoriesBanner.dismiss")}
|
|
className="shrink-0 rounded-md p-1 text-[var(--muted-foreground)] hover:bg-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|