diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx index c0bfc754..27274dea 100644 --- a/app/analytics/page.tsx +++ b/app/analytics/page.tsx @@ -347,7 +347,7 @@ function PortfolioAnalyticsInner() {
{/* Header */}
-
+

{t("title")}

{t("subtitle")}

diff --git a/components/onboarding/OnboardingTour.tsx b/components/onboarding/OnboardingTour.tsx index dcd87606..e8948c22 100644 --- a/components/onboarding/OnboardingTour.tsx +++ b/components/onboarding/OnboardingTour.tsx @@ -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 = [ @@ -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"); @@ -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) { diff --git a/components/onboarding/__tests__/OnboardingTour.test.tsx b/components/onboarding/__tests__/OnboardingTour.test.tsx index 4a61cdaf..423da37a 100644 --- a/components/onboarding/__tests__/OnboardingTour.test.tsx +++ b/components/onboarding/__tests__/OnboardingTour.test.tsx @@ -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"; @@ -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 }, @@ -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(); + + // 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(); + + expect(await screen.findByText("Mint an invoice", {}, { timeout: 2000 })).toBeInTheDocument(); + expect(screen.getByText("Step 1 of 3")).toBeInTheDocument(); + }); }); diff --git a/messages/ar.json b/messages/ar.json index 9e38ecae..d2ecd6cf 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 0139739c..e4cef951 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index c8ce66eb..0f2d775c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 287b7b60..6b7a865c 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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": {