diff --git a/ghostkey-web/src/App.tsx b/ghostkey-web/src/App.tsx
index 6c0a00f..bab01bc 100644
--- a/ghostkey-web/src/App.tsx
+++ b/ghostkey-web/src/App.tsx
@@ -73,6 +73,9 @@ const EmergencyPage = lazy(() =>
const RemindersPage = lazy(() =>
import("./VaultToolPages").then((m) => ({ default: m.RemindersPage })),
);
+const ToolsPage = lazy(() =>
+ import("./VaultToolPages").then((m) => ({ default: m.ToolsPage })),
+);
const RecoveryKitPage = lazy(() =>
import("./RecoveryKitPage").then((m) => ({ default: m.RecoveryKitPage })),
);
@@ -125,6 +128,7 @@ export type Route =
| "practice"
| "emergency"
| "reminders"
+ | "tools"
| "recovery"
| "recovery-guide"
| "checkin"
@@ -146,6 +150,7 @@ const VALID: Route[] = [
"practice",
"emergency",
"reminders",
+ "tools",
"recovery",
"recovery-guide",
"checkin",
@@ -409,6 +414,7 @@ export default function App() {
{location.kind === "route" && location.route === "practice" && }
{location.kind === "route" && location.route === "emergency" && }
{location.kind === "route" && location.route === "reminders" && }
+ {location.kind === "route" && location.route === "tools" && }
{location.kind === "route" && location.route === "recovery" && }
{location.kind === "route" && location.route === "recovery-guide" && }
{location.kind === "route" && location.route === "checkin" && }
diff --git a/ghostkey-web/src/Dashboard.tsx b/ghostkey-web/src/Dashboard.tsx
index bccac1d..8092080 100644
--- a/ghostkey-web/src/Dashboard.tsx
+++ b/ghostkey-web/src/Dashboard.tsx
@@ -63,6 +63,7 @@ import {
import { unsealOwner } from "./crypto/sealing";
import { usePrice, btcAndUsd, satsToUsd, formatUsd } from "./fiat";
import { lastResortCheckinOpen } from "./checkin";
+import { useToolDoneState } from "./toolStatus";
import type { Route } from "./App";
interface Props {
@@ -120,6 +121,9 @@ export function Dashboard({ onNavigate }: Props) {
// VAPID public key from /health, or null when the server has no
// push keypair configured. Gates the reminder opt-in card.
const [pushKey, setPushKey] = useState(null);
+ // Set-once tools that are already done drop off the More list below;
+ // the nav's Tools page stays their permanent home.
+ const toolsDone = useToolDoneState(activeId, ownerToken);
const now = useTicker(1000);
@@ -482,11 +486,20 @@ export function Dashboard({ onNavigate }: Props) {
{/* Set-once tools live on their own pages now, reached from
this compact list, so the dashboard stays status + money +
- heir. Recovery kit already works the same way (in the nav). */}
+ heir. Once a tool is done (video saved, practice sent,
+ reminders on) its link leaves this list too; the nav's
+ Tools page is the permanent home for all of them. */}
diff --git a/ghostkey-web/src/NavBar.tsx b/ghostkey-web/src/NavBar.tsx
index 950c454..ed688ec 100644
--- a/ghostkey-web/src/NavBar.tsx
+++ b/ghostkey-web/src/NavBar.tsx
@@ -32,6 +32,7 @@ const SIGNED_OUT_ITEMS: NavItem[] = [
const SIGNED_IN_ITEMS: NavItem[] = [
{ key: "dashboard", label: "Dashboard" },
+ { key: "tools", label: "Tools" },
{ key: "recovery", label: "Recovery kit" },
{ key: "inherit", label: "Inherit" },
];
diff --git a/ghostkey-web/src/PracticeClaimCard.tsx b/ghostkey-web/src/PracticeClaimCard.tsx
index b235be5..9f78bc4 100644
--- a/ghostkey-web/src/PracticeClaimCard.tsx
+++ b/ghostkey-web/src/PracticeClaimCard.tsx
@@ -21,6 +21,46 @@ export interface DrillProgress {
drill_completed_at?: string | null;
}
+/** How this heir is reached, from the server's heir profile. The copy
+ * must match the real channel: a WhatsApp heir never gets an email,
+ * so the card must not promise one. Unknown falls back to "message". */
+export type HeirChannel = "email" | "sms" | "whatsapp" | null | undefined;
+
+function practiceNoun(channel: HeirChannel): string {
+ switch (channel) {
+ case "email":
+ return "practice email";
+ case "sms":
+ return "practice text message";
+ case "whatsapp":
+ return "practice WhatsApp message";
+ default:
+ return "practice message";
+ }
+}
+
+function sendWords(channel: HeirChannel, who: string): {
+ alert: string;
+ button: string;
+} {
+ switch (channel) {
+ case "email":
+ return { alert: `This emails ${who} right now.`, button: `Email ${who} now` };
+ case "sms":
+ return { alert: `This texts ${who} right now.`, button: `Text ${who} now` };
+ case "whatsapp":
+ return {
+ alert: `This sends ${who} a WhatsApp message right now.`,
+ button: `Message ${who} on WhatsApp`,
+ };
+ default:
+ return {
+ alert: `This sends ${who} a message right now.`,
+ button: `Send it to ${who} now`,
+ };
+ }
+}
+
function fmtDay(rfc: string): string | null {
const d = new Date(rfc);
if (Number.isNaN(d.getTime())) return null;
@@ -33,7 +73,11 @@ function fmtDay(rfc: string): string | null {
/** One line describing where the rehearsal stands, for the card and
* its tests. */
-export function drillStatusLine(progress: DrillProgress, who: string): string {
+export function drillStatusLine(
+ progress: DrillProgress,
+ who: string,
+ channel?: HeirChannel,
+): string {
if (progress.drill_completed_at) {
const when = fmtDay(progress.drill_completed_at);
return when
@@ -52,7 +96,7 @@ export function drillStatusLine(progress: DrillProgress, who: string): string {
? `Practice sent ${when}. ${who} hasn't opened it yet.`
: `Practice sent. ${who} hasn't opened it yet.`;
}
- return `See the claim work while you're here to help. ${who} gets a clearly-marked practice email and walks the real steps. Nothing can move.`;
+ return `See the claim work while you're here to help. ${who} gets a clearly-marked ${practiceNoun(channel)} and walks the real steps. Nothing can move.`;
}
type Stage = "idle" | "confirming" | "sending" | "sent";
@@ -61,11 +105,13 @@ export function PracticeClaimCard({
vaultId,
ownerToken,
heirName,
+ heirChannel,
progress,
}: {
vaultId: string;
ownerToken: string | null;
heirName?: string;
+ heirChannel?: HeirChannel;
progress: DrillProgress;
}) {
const [stage, setStage] = useState("idle");
@@ -78,8 +124,9 @@ export function PracticeClaimCard({
// A just-sent drill beats whatever the vault fetch knew.
const line =
stage === "sent" && result
- ? drillStatusLine({ drill_started_at: result.started_at }, who)
- : drillStatusLine(progress, who);
+ ? drillStatusLine({ drill_started_at: result.started_at }, who, heirChannel)
+ : drillStatusLine(progress, who, heirChannel);
+ const send = sendWords(heirChannel, who);
const completed = Boolean(progress.drill_completed_at) && stage !== "sent";
const startedBefore = Boolean(progress.drill_started_at) || stage === "sent";
@@ -129,8 +176,8 @@ export function PracticeClaimCard({
- This emails {who} right now. The message says clearly that you
- are fine and that this is practice.
+ {send.alert} The message says clearly that you are fine and
+ that this is practice.