mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
Beginner UX: soft first-run readiness card on Home (#11282)
Validated on the combined 8-PR board: home-page-static 4/4, check:dashboard-typecheck clean (220 pre-existing, all within frozen baseline — zero syntax errors), 88/88 across the board's focused suites, static gates within baseline. The soft first-run readiness card replaces the hard Home→onboarding redirect with a dismissable 4-step path. Thank you @ignamiranda!
This commit is contained in:
1
changelog.d/features/11282-first-run-readiness-card.md
Normal file
1
changelog.d/features/11282-first-run-readiness-card.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(dashboard):** replace the hard Home → onboarding redirect with a dismissable first-run readiness card so returning users can stay on Home while new users still get a clear 4-step path ([#11282](https://github.com/diegosouzapw/OmniRoute/pull/11282))
|
||||
97
src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx
Normal file
97
src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const DISMISS_STORAGE_KEY = "omniroute-first-run-readiness-dismissed";
|
||||
|
||||
type FirstRunReadinessCardProps = {
|
||||
setupComplete: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Soft entry path for first-run users. Replaces the hard redirect to
|
||||
* /dashboard/onboarding so returning users can dismiss and stay on Home.
|
||||
*/
|
||||
export default function FirstRunReadinessCard({ setupComplete }: FirstRunReadinessCardProps) {
|
||||
const t = useTranslations("home");
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (setupComplete) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setVisible(!localStorage.getItem(DISMISS_STORAGE_KEY));
|
||||
} catch {
|
||||
setVisible(true);
|
||||
}
|
||||
}, [setupComplete]);
|
||||
|
||||
if (!visible || setupComplete) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_STORAGE_KEY, "true");
|
||||
} catch {
|
||||
// ignore storage failures; still hide for this session
|
||||
}
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const steps = [
|
||||
t("readinessStep1"),
|
||||
t("readinessStep2"),
|
||||
t("readinessStep3"),
|
||||
t("readinessStep4"),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={t("readinessTitle")}
|
||||
className="mb-4 rounded-xl border border-blue-200 dark:border-blue-500/30 bg-blue-50 dark:bg-blue-500/10 px-5 py-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-blue-700/80 dark:text-blue-300/80">
|
||||
{t("readinessEyebrow")}
|
||||
</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-blue-950 dark:text-blue-100">
|
||||
{t("readinessTitle")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-blue-900/80 dark:text-blue-200/80">
|
||||
{t("readinessSubtitle")}
|
||||
</p>
|
||||
<ol className="mt-3 space-y-1.5 text-sm text-blue-900 dark:text-blue-100">
|
||||
{steps.map((label, index) => (
|
||||
<li key={label} className="flex items-center gap-2">
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-200/80 dark:bg-blue-400/20 text-xs font-semibold text-blue-800 dark:text-blue-200">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href="/dashboard/onboarding"
|
||||
className="inline-flex items-center rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||
>
|
||||
{t("readinessContinue")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
className="text-sm font-medium text-blue-800/80 hover:text-blue-950 dark:text-blue-200/80 dark:hover:text-blue-100"
|
||||
>
|
||||
{t("readinessDismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getSettings } from "@/lib/localDb";
|
||||
import HomePageClient from "../dashboard/HomePageClient";
|
||||
@@ -7,19 +6,18 @@ import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
|
||||
import CheaperInferenceSponsorBanner from "../dashboard/CheaperInferenceSponsorBanner";
|
||||
import VscodeCopilotBanner from "../dashboard/VscodeCopilotBanner";
|
||||
import NewsBanner from "../dashboard/NewsBanner";
|
||||
import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const settings = await getSettings();
|
||||
if (!settings.setupComplete) {
|
||||
redirect("/dashboard/onboarding");
|
||||
}
|
||||
const machineId = await getMachineId();
|
||||
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
|
||||
return (
|
||||
<>
|
||||
{isBootstrapped && <BootstrapBanner />}
|
||||
<FirstRunReadinessCard setupComplete={Boolean(settings.setupComplete)} />
|
||||
<KimiSponsorBanner />
|
||||
<CheaperInferenceSponsorBanner />
|
||||
<VscodeCopilotBanner />
|
||||
|
||||
@@ -1874,7 +1874,16 @@
|
||||
"directDownloadHint": "Or download the respective installer format directly:",
|
||||
"releaseNotes": "Release Notes",
|
||||
"readMore": "Read More",
|
||||
"noAuthLabel": "No Auth"
|
||||
"noAuthLabel": "No Auth",
|
||||
"readinessEyebrow": "Get ready to route",
|
||||
"readinessTitle": "Send your first request",
|
||||
"readinessSubtitle": "Four small steps. OmniRoute checks readiness as you go.",
|
||||
"readinessStep1": "Connect a provider",
|
||||
"readinessStep2": "Configure endpoint authentication",
|
||||
"readinessStep3": "Copy your endpoint",
|
||||
"readinessStep4": "Send a test request",
|
||||
"readinessContinue": "Continue setup",
|
||||
"readinessDismiss": "Dismiss for now"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
|
||||
77
tests/unit/home-page-static.test.ts
Normal file
77
tests/unit/home-page-static.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
function readHomePage(): string {
|
||||
return readFileSync(
|
||||
join(repoRoot, "src/app/(dashboard)/home/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function readReadinessCard(): string {
|
||||
return readFileSync(
|
||||
join(repoRoot, "src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function readEnKeys(): string[] {
|
||||
const en = JSON.parse(
|
||||
readFileSync(join(repoRoot, "src/i18n/messages/en.json"), "utf8"),
|
||||
) as { home: Record<string, string> };
|
||||
return Object.keys(en.home);
|
||||
}
|
||||
|
||||
describe("home page first-run readiness card", () => {
|
||||
it("does not hard-redirect incomplete setup to onboarding", () => {
|
||||
const source = readHomePage();
|
||||
assert.doesNotMatch(source, /redirect\(["']\/dashboard\/onboarding["']\)/);
|
||||
assert.match(source, /FirstRunReadinessCard/);
|
||||
assert.match(source, /setupComplete=\{Boolean\(settings\.setupComplete\)\}/);
|
||||
});
|
||||
|
||||
it("keeps the readiness card dismissable via localStorage", () => {
|
||||
const source = readReadinessCard();
|
||||
assert.match(source, /omniroute-first-run-readiness-dismissed/);
|
||||
assert.match(source, /localStorage/);
|
||||
assert.match(source, /readinessContinue/);
|
||||
assert.match(source, /readinessDismiss/);
|
||||
});
|
||||
|
||||
it("uses t() keys for readiness copy", () => {
|
||||
const source = readReadinessCard();
|
||||
for (const key of [
|
||||
"readinessEyebrow",
|
||||
"readinessTitle",
|
||||
"readinessSubtitle",
|
||||
"readinessStep1",
|
||||
"readinessStep2",
|
||||
"readinessStep3",
|
||||
"readinessStep4",
|
||||
]) {
|
||||
assert.match(source, new RegExp(key));
|
||||
}
|
||||
});
|
||||
|
||||
it("new i18n keys exist in en.json home namespace", () => {
|
||||
const keys = readEnKeys();
|
||||
for (const key of [
|
||||
"readinessEyebrow",
|
||||
"readinessTitle",
|
||||
"readinessSubtitle",
|
||||
"readinessStep1",
|
||||
"readinessStep2",
|
||||
"readinessStep3",
|
||||
"readinessStep4",
|
||||
"readinessContinue",
|
||||
"readinessDismiss",
|
||||
]) {
|
||||
assert.ok(keys.includes(key), `Missing home.${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user