+
{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": {