Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ function PortfolioAnalyticsInner() {
<div id="analytics-report" className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
{/* Header */}
<div className="mb-6 flex flex-wrap items-start justify-between gap-4">
<div>
<div data-tour="analytics-header">
<h1 className="text-2xl font-bold text-zinc-100">{t("title")}</h1>
<p className="mt-1 text-sm text-zinc-500">{t("subtitle")}</p>
</div>
Expand Down
32 changes: 27 additions & 5 deletions components/onboarding/OnboardingTour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { useFeatureFlag } from "@/lib/featureFlags";
export const TOUR_STORAGE_KEY = "kora-tour-done";

const INVESTOR_STEPS = [
{ titleKey: "findOpportunityTitle", bodyKey: "findOpportunityBody", selector: "[data-tour='marketplace-search']", placement: "bottom" as const },
{ titleKey: "reviewDetailsTitle", bodyKey: "reviewDetailsBody", selector: "[data-tour='invoice-card']", placement: "right" as const },
{ titleKey: "fundInvoiceTitle", bodyKey: "fundInvoiceBody", selector: "[data-tour='fund-button']", placement: "top" as const },
{ titleKey: "trackPortfolioTitle", bodyKey: "trackPortfolioBody", selector: "[data-tour='investor-dashboard']", placement: "bottom" as const },
{ titleKey: "findOpportunityTitle", bodyKey: "findOpportunityBody", selector: "[data-tour='marketplace-search']", placement: "bottom" as const },
{ titleKey: "reviewDetailsTitle", bodyKey: "reviewDetailsBody", selector: "[data-tour='invoice-card']", placement: "right" as const },
{ titleKey: "fundInvoiceTitle", bodyKey: "fundInvoiceBody", selector: "[data-tour='fund-button']", placement: "top" as const },
{ titleKey: "trackPortfolioTitle", bodyKey: "trackPortfolioBody", selector: "[data-tour='investor-dashboard']", placement: "bottom" as const },
{ titleKey: "viewAnalyticsTitle", bodyKey: "viewAnalyticsBody", selector: "[data-tour='analytics-header']", placement: "bottom" as const, optional: true },
];

const SME_STEPS = [
Expand All @@ -24,7 +25,7 @@ const SME_STEPS = [
{ titleKey: "marketplaceVisibilityTitle", bodyKey: "marketplaceVisibilityBody", selector: "[data-tour='marketplace-link']", placement: "bottom" as const },
];

const ELIGIBLE_ROUTES = ["/marketplace", "/dashboard/sme", "/dashboard/investor", "/invoice/create"];
const ELIGIBLE_ROUTES = ["/marketplace", "/dashboard/sme", "/dashboard/investor", "/invoice/create", "/analytics"];

export default function OnboardingTour() {
const t = useTranslations("onboarding");
Expand All @@ -38,6 +39,27 @@ export default function OnboardingTour() {
const steps = persona === "sme" ? SME_STEPS : INVESTOR_STEPS;
const stepIndex = Math.min(tour.stepIndex ?? 0, steps.length - 1);

// Gracefully skip a step when its DOM target is absent, but only for steps
// flagged as `optional` (e.g. the analytics page may not be reachable for
// all users). Required steps are always shown regardless of DOM presence.
useEffect(() => {
if (!open) return;
const current = steps[stepIndex];
if (!current.optional) return;
if (!document.querySelector(current.selector)) {
const nextIndex = stepIndex + 1;
if (nextIndex < steps.length) {
setTourSettings({ stepIndex: nextIndex });
} else {
// No more steps — finish the tour inline (mirrors handleComplete logic).
try { localStorage.setItem(TOUR_STORAGE_KEY, "true"); } catch { /* storage unavailable */ }
setTourSettings({ completed: true, skipped: true });
setOpen(false);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, stepIndex, steps]);

// Check route eligibility and completion status
useEffect(() => {
if (!enabled) {
Expand Down
50 changes: 50 additions & 0 deletions components/onboarding/__tests__/OnboardingTour.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ vi.mock("next-intl", () => ({
if (key === "steps.findOpportunityBody") return "Search by debtor, invoice number, or jurisdiction to narrow the marketplace.";
if (key === "steps.reviewDetailsTitle") return "Review invoice details";
if (key === "steps.reviewDetailsBody") return "Each card summarizes the amount, return, risk tier, funding progress, and maturity.";
if (key === "steps.fundInvoiceTitle") return "Fund an invoice";
if (key === "steps.fundInvoiceBody") return "Open an eligible listing from its funding action when you are ready to invest.";
if (key === "steps.trackPortfolioTitle") return "Track your portfolio";
if (key === "steps.trackPortfolioBody") return "Use the investor dashboard to monitor positions, repayments, and earned yield.";
if (key === "steps.viewAnalyticsTitle") return "Explore portfolio analytics";
if (key === "steps.viewAnalyticsBody") return "Dive into charts, yield projections, vintage cohorts, and export your full portfolio data from the Analytics page.";
if (key === "steps.mintInvoiceTitle") return "Mint an invoice";
if (key === "steps.mintInvoiceBody") return "Upload unpaid invoice details and mint a Soroban NFT to request financing.";
if (key === "steps.smeDashboardTitle") return "SME Dashboard";
Expand All @@ -42,6 +48,9 @@ vi.mock("@/lib/featureFlags", () => ({
describe("OnboardingTour Component", () => {
beforeEach(() => {
vi.clearAllMocks();
// JSDOM does not implement scrollIntoView; stub it so TourTooltip
// does not throw when it finds a real DOM anchor element.
window.HTMLElement.prototype.scrollIntoView = vi.fn();
localStorage.removeItem(TOUR_STORAGE_KEY);
useSettingsStore.setState({
tour: { ...DEFAULT_TOUR_SETTINGS },
Expand Down Expand Up @@ -100,4 +109,45 @@ describe("OnboardingTour Component", () => {
expect(useSettingsStore.getState().tour.completed).toBe(true);
expect(localStorage.getItem(TOUR_STORAGE_KEY)).toBe("true");
});

it("investor tour has 5 steps including the analytics step at index 4", async () => {
// Mount the analytics-header anchor so the optional-skip effect does not
// auto-complete the tour before we can assert on step 5's content.
const anchor = document.createElement("div");
anchor.setAttribute("data-tour", "analytics-header");
document.body.appendChild(anchor);

render(<OnboardingTour />);

// Step 1 visible after mount delay
expect(await screen.findByText("Find the right opportunity", {}, { timeout: 2000 })).toBeInTheDocument();
expect(screen.getByText("Step 1 of 5")).toBeInTheDocument();

// Advance 0 → 1 → 2 → 3
for (let i = 0; i < 3; i++) {
fireEvent.click(screen.getByRole("button", { name: "Next" }));
}

// Step 4: Track your portfolio
expect(await screen.findByText("Track your portfolio")).toBeInTheDocument();
expect(screen.getByText("Step 4 of 5")).toBeInTheDocument();

// Advance to step 5: analytics
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("Explore portfolio analytics")).toBeInTheDocument();
expect(screen.getByText("Step 5 of 5")).toBeInTheDocument();

// Last step should show "Finish" not "Next"
expect(screen.getByRole("button", { name: "Finish" })).toBeInTheDocument();

document.body.removeChild(anchor);
});

it("SME tour configuration is unchanged (3 steps, no analytics step)", async () => {
useSettingsStore.setState({ tour: { ...DEFAULT_TOUR_SETTINGS, persona: "sme" } });
render(<OnboardingTour />);

expect(await screen.findByText("Mint an invoice", {}, { timeout: 2000 })).toBeInTheDocument();
expect(screen.getByText("Step 1 of 3")).toBeInTheDocument();
});
});
4 changes: 3 additions & 1 deletion messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,9 @@
"fundInvoiceTitle": "موّل فاتورة",
"fundInvoiceBody": "افتح إدراجًا مؤهلاً من خلال إجراء التمويل الخاص به عندما تكون مستعدًا للاستثمار.",
"trackPortfolioTitle": "تتبع محفظتك",
"trackPortfolioBody": "استخدم لوحة المستثمر لمراقبة المراكز والسدادات والعوائد المكتسبة."
"trackPortfolioBody": "استخدم لوحة المستثمر لمراقبة المراكز والسدادات والعوائد المكتسبة.",
"viewAnalyticsTitle": "Explore portfolio analytics",
"viewAnalyticsBody": "Dive into charts, yield projections, vintage cohorts, and export your full portfolio data from the Analytics page."
}
},
"invoiceCard": {
Expand Down
4 changes: 3 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -844,7 +844,9 @@
"fundInvoiceTitle": "Fund an invoice",
"fundInvoiceBody": "Open an eligible listing from its funding action when you are ready to invest.",
"trackPortfolioTitle": "Track your portfolio",
"trackPortfolioBody": "Use the investor dashboard to monitor positions, repayments, and earned yield."
"trackPortfolioBody": "Use the investor dashboard to monitor positions, repayments, and earned yield.",
"viewAnalyticsTitle": "Explore portfolio analytics",
"viewAnalyticsBody": "Dive into charts, yield projections, vintage cohorts, and export your full portfolio data from the Analytics page."
}
},
"invoiceCard": {
Expand Down
4 changes: 3 additions & 1 deletion messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,9 @@
"fundInvoiceTitle": "Financia una factura",
"fundInvoiceBody": "Abre un listado elegible desde su acción de financiamiento cuando estés listo para invertir.",
"trackPortfolioTitle": "Sigue tu portafolio",
"trackPortfolioBody": "Usa el panel de inversor para monitorear posiciones, pagos y rendimiento generado."
"trackPortfolioBody": "Usa el panel de inversor para monitorear posiciones, pagos y rendimiento generado.",
"viewAnalyticsTitle": "Explore portfolio analytics",
"viewAnalyticsBody": "Dive into charts, yield projections, vintage cohorts, and export your full portfolio data from the Analytics page."
}
},
"invoiceCard": {
Expand Down
4 changes: 3 additions & 1 deletion messages/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -873,7 +873,9 @@
"fundInvoiceTitle": "Financie uma fatura",
"fundInvoiceBody": "Abra uma listagem elegível pela sua ação de financiamento quando estiver pronto para investir.",
"trackPortfolioTitle": "Acompanhe sua carteira",
"trackPortfolioBody": "Use o painel do investidor para monitorar posições, reembolsos e rendimentos obtidos."
"trackPortfolioBody": "Use o painel do investidor para monitorar posições, reembolsos e rendimentos obtidos.",
"viewAnalyticsTitle": "Explore portfolio analytics",
"viewAnalyticsBody": "Dive into charts, yield projections, vintage cohorts, and export your full portfolio data from the Analytics page."
}
},
"invoiceCard": {
Expand Down
Loading