Files
2026-08-01 14:01:17 +02:00

303 lines
11 KiB
TypeScript

// lib/tour.ts — product tour engine (driver.js). Open-core: available in both builds.
// Each page gets its own short, independent tour keyed by a "page" id. A tour is shown
// once automatically (tracked server-side via /api/tour) and can be replayed manually
// from Settings → General ("Restart tour"), which never touches the "seen" state.
"use client";
import { driver, type DriveStep } from "driver.js";
import "driver.js/dist/driver.css";
export type TourPage =
| "dashboard"
| "lists"
| "contacts"
| "companies"
| "workflows"
| "inbox"
| "settings";
// Steps whose `element` selector isn't in the DOM are silently skipped by driver.js —
// relied on here so a step referencing a premium-only anchor (Todos nav, InMail, MCP)
// just no-ops cleanly in the open-core build instead of needing its own guard.
const TOURS: Record<TourPage, DriveStep[]> = {
dashboard: [
{
element: '[data-tour="nav-dashboard"]',
popover: {
title: "Welcome to Linki",
description: "This is your outreach dashboard. Everything in Linki runs on one background process at a human pace — nothing fires the instant you click a button, so don't expect instant numbers here either.",
},
},
{
element: '[data-tour="dashboard-filters"]',
popover: {
title: "LinkedIn and email, side by side",
description: "The two channels are tracked completely separately — a campaign can run both at once, and this page reports them independently rather than blended.",
},
},
{
element: '[data-tour="dashboard-funnel"]',
popover: {
title: "Your outreach funnel",
description: "See exactly where leads drop off — from total targets down to replies. This is the \"is my campaign actually working\" view.",
},
},
{
element: '[data-tour="dashboard-chart"]',
popover: {
title: "Activity over time",
description: "Visits, connections, messages and emails per day. Click a series label to toggle it off, or change the day range to reshape the whole page.",
},
},
{
element: '[data-tour="nav-settings"]',
popover: {
title: "First: connect a LinkedIn account",
description: "Nothing else works until an account is authenticated in Settings → LinkedIn — that's the one true prerequisite before importing or running anything.",
},
},
{
element: '[data-tour="nav-lists"]',
popover: {
title: "Then: import a list",
description: "Paste a Sales Navigator search URL — that's how leads get into Linki.",
},
},
{
element: '[data-tour="nav-workflows"]',
popover: {
title: "Then: build a campaign",
description: "Chain steps like visit, connect, message and email into a sequence, pick the list and account, and launch.",
},
},
],
lists: [
{
element: '[data-tour="lists-new"]',
popover: {
title: "Create a list",
description: "Paste a LinkedIn Sales Navigator search URL to scrape leads automatically.",
},
},
{
element: '[data-tour="lists-new"]',
popover: {
title: "Imports run in the background",
description: "A Sales Navigator import doesn't happen instantly — it's queued and scraped in small batches with human-like delays, specifically to avoid LinkedIn flagging the account.",
},
},
{
element: '[data-tour="lists-jobs"]',
popover: {
title: "Import jobs",
description: "There's a shared daily import cap across every list (configurable in Settings → General) — a large list automatically spills into the next day. Track progress and cancel batches here.",
},
},
{
element: '[data-tour="lists-new"]',
popover: {
title: "A fresh import is intentionally thin",
description: "Newly imported leads only have basic data (name, title, company). Full profile and email enrichment happen later, automatically, right before a campaign actually needs them — you don't need to enrich everything up front.",
},
},
],
contacts: [
{
element: '[data-tour="nav-contacts"]',
popover: {
title: "Every lead, in one place",
description: "This is the single, deduplicated view of everyone you've ever imported — the same person can belong to several lists, and shows up here just once regardless.",
},
},
{
element: '[data-tour="contacts-filters"]',
popover: {
title: "Search and filter",
description: "Filter by list, title, connection status, seniority, or email verification to build a targeted sub-audience without re-importing anything.",
},
},
{
element: '[data-tour="contacts-select"]',
popover: {
title: "Bulk-select, then add to a list",
description: "Select multiple contacts and add them all to a list at once — an easy way to turn a filtered view (e.g. everyone who replied) into a new targeted list. Contacts already in that list are skipped.",
},
},
],
companies: [
{
element: '[data-tour="nav-companies"]',
popover: {
title: "Companies",
description: "Enriched organization records — industry, size, tech stack, revenue — that populate automatically as contacts get enriched. You can also add or edit one by hand.",
},
},
],
workflows: [
{
element: '[data-tour="workflows-new"]',
popover: {
title: "Build a campaign",
description: "A campaign chains steps — visit, connect, message, email — into a repeatable sequence, then runs it against a list of contacts through one LinkedIn account.",
},
},
{
element: '[data-tour="workflows-new"]',
popover: {
title: "LinkedIn and email run in parallel",
description: "LinkedIn steps and email steps are two independent tracks for the same contact. A reply on either channel stops that person's outreach on both — you don't need to manage them separately.",
},
},
{
element: '[data-tour="workflows-new"]',
popover: {
title: "A LinkedIn account is always required",
description: "Even an email-only campaign needs one authenticated LinkedIn account selected — set that up first in Settings if you haven't.",
},
},
{
element: '[data-tour="workflows-new"]',
popover: {
title: "One active run at a time",
description: "A campaign can only have one run going at once. To reach more people later, use \"Add contacts\" on the running campaign instead of starting a second run.",
},
},
{
element: '[data-tour="workflows-new"]',
popover: {
title: "It runs at a human pace",
description: "Launching doesn't message everyone instantly — the runner works through the list gradually in the background, respecting daily limits and working hours per account.",
},
},
],
inbox: [
{
element: '[data-tour="nav-inbox"]',
popover: {
title: "Every reply, one place",
description: "Replies from LinkedIn and email land here automatically — no need to check either inbox by hand. A reply also automatically stops that contact's outreach.",
},
},
{
element: '[data-tour="nav-inbox"]',
popover: {
title: "Not every reply means interest",
description: "Reply counts can be misleadingly high — many are auto-responders (out-of-office, \"contact my colleague\") rather than genuine interest. Open one to see the full thread and judge for yourself.",
},
},
],
settings: [
{
element: '[data-tour="settings-tab-linkedin"]',
popover: {
title: "Connect a LinkedIn account first",
description: "An account must be authenticated here before it can be used in any campaign — this is the single most important setup step in Linki.",
},
},
{
element: '[data-tour="settings-tab-email"]',
popover: {
title: "Email accounts, with ramp-up",
description: "Configure SMTP/IMAP for email steps. A new account can ramp up sending volume gradually instead of blasting at full volume from day one, which helps it avoid getting flagged as spam.",
},
},
{
element: '[data-tour="settings-tab-integrations"]',
popover: {
title: "Apollo enrichment",
description: "Add an Apollo API key here to enrich leads with verified emails, seniority, and company data.",
},
},
{
element: '[data-tour="settings-tab-general"]',
popover: {
title: "Connect an AI agent",
description: "The General tab has a copyable MCP URL to hook up Claude Code, Cursor, or any MCP-compatible agent to manage Linki on your behalf — plus the daily import cap and this tour picker.",
},
},
],
};
function markSeen(page: TourPage) {
fetch("/api/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ page }),
}).catch(() => {});
}
function buildAndRun(page: TourPage, persist: boolean) {
const steps = TOURS[page];
if (!steps || steps.length === 0) return;
const d = driver({
showProgress: true,
overlayColor: "#000",
overlayOpacity: 0.65,
stagePadding: 6,
stageRadius: 8,
popoverOffset: 12,
allowClose: true,
popoverClass: "linki-tour-popover",
nextBtnText: "Next",
prevBtnText: "Back",
doneBtnText: "Done",
steps,
onDestroyed: () => {
if (persist) markSeen(page);
},
});
d.drive();
}
export function startPageTour(page: TourPage) {
buildAndRun(page, true);
}
export function replayPageTour(page: TourPage) {
buildAndRun(page, false);
}
export async function getSeenTours(): Promise<Set<TourPage>> {
try {
const res = await fetch("/api/tour");
if (!res.ok) return new Set();
const data = await res.json();
return new Set((data.seen ?? []) as TourPage[]);
} catch {
return new Set();
}
}
export const ALL_TOUR_PAGES: TourPage[] = ["dashboard", "lists", "contacts", "companies", "workflows", "inbox", "settings"];
export const TOUR_PAGE_LABELS: Record<TourPage, string> = {
dashboard: "Dashboard",
lists: "Lists",
contacts: "Contacts",
companies: "Companies",
workflows: "Campaigns",
inbox: "Inbox",
settings: "Settings",
};
// Maps a pathname to its tour page id, or null if the current page has no tour
// (e.g. a detail route like /lists/[id] — the index page's tour covers the entry point).
export function pathToTourPage(pathname: string): TourPage | null {
if (pathname === "/") return "dashboard";
if (pathname === "/lists") return "lists";
if (pathname === "/contacts") return "contacts";
if (pathname === "/companies") return "companies";
if (pathname === "/workflows") return "workflows";
if (pathname === "/inbox") return "inbox";
if (pathname === "/settings") return "settings";
return null;
}