diff --git a/frontend/components/tour/steps.ts b/frontend/components/tour/steps.ts index e138dcf..28929a1 100644 --- a/frontend/components/tour/steps.ts +++ b/frontend/components/tour/steps.ts @@ -12,7 +12,9 @@ export const TOUR_STEPS: TourStep[] = [ id: "welcome", chapter: "Welcome", title: "Welcome to FraudLens", - body: "Every upay payment is scored in real time: allowed, warned, held or blocked. This tour shows where those decisions land and how your team acts on them.", + body: "We check every upay payment for fraud as it happens. This tour can play by itself, and you can switch language anytime with the EN / বাংলা button.", + titleBn: "FraudLens-এ স্বাগতম", + bodyBn: "প্রতিটা upay পেমেন্ট আমরা সাথে সাথে প্রতারণার জন্য চেক করি। ট্যুরটা নিজে নিজেই চলতে পারে, আর EN / বাংলা বাটনে চাপ দিয়ে যেকোনো সময় ভাষা বদলাতে পারবেন।", }, // Navigation @@ -22,8 +24,10 @@ export const TOUR_STEPS: TourStep[] = [ target: "brand", mobileTarget: "bottom-nav", placement: "right", - title: "Your console", - body: "The sidebar is grouped by job: business impact, investigation, the mule network, model governance and the customer view.", + title: "Your main menu", + body: "This menu takes you everywhere. Pages are grouped by job: results, investigation, criminal networks, AI settings and the customer view.", + titleBn: "আপনার মূল মেনু", + bodyBn: "এই মেনু দিয়ে সব জায়গায় যাওয়া যায়। পেজগুলো কাজ অনুযায়ী সাজানো: ফলাফল, তদন্ত, প্রতারক চক্র, AI সেটিংস আর গ্রাহকের দিক।", }, { id: "nav-investigate", @@ -33,8 +37,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "right", action: "hover", roles: REVIEW, - title: "Day-to-day work", - body: "Alerts, cases, freezes, appeals and refunds: the queues an analyst clears every shift.", + title: "Your daily work", + body: "Here are the lists your team works through every day: suspicious payments, cases, account freezes, customer complaints and refunds.", + titleBn: "প্রতিদিনের কাজ", + bodyBn: "আপনার টিম প্রতিদিন যে কাজগুলো করে সেগুলো এখানে: সন্দেহজনক পেমেন্ট, কেস, অ্যাকাউন্ট ফ্রিজ, গ্রাহকের অভিযোগ আর রিফান্ড।", }, { id: "nav-governance", @@ -44,8 +50,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "right", action: "hover", roles: ["admin"], - title: "Governance first", - body: "As an admin you own the model, the policy and the audit trail. Investigation queues stay with analysts and supervisors.", + title: "Your admin area", + body: "As an admin, you look after the AI that scores payments, the rules it follows and the record of who did what.", + titleBn: "অ্যাডমিন অংশ", + bodyBn: "অ্যাডমিন হিসেবে আপনি দেখবেন পেমেন্ট যাচাই করা AI, সেটা কোন নিয়মে চলে, আর কে কী করেছে তার রেকর্ড।", }, { id: "serving", @@ -53,8 +61,10 @@ export const TOUR_STEPS: TourStep[] = [ target: "serving", mobileTarget: "bottom-nav", placement: "right", - title: "What is deciding right now", - body: "The live model and policy versions. If the model goes down this turns amber and decisions fall back to rules alone.", + title: "Is the AI working?", + body: "This shows which AI version is checking payments now. If it turns amber, the AI is down and simple backup rules take over.", + titleBn: "AI কি চালু আছে?", + bodyBn: "এখন কোন AI ভার্সন পেমেন্ট চেক করছে তা এখানে দেখা যায়। হলুদ হয়ে গেলে বুঝবেন AI বন্ধ, তখন সাধারণ ব্যাকআপ নিয়মে কাজ চলে।", }, // Overview @@ -65,8 +75,10 @@ export const TOUR_STEPS: TourStep[] = [ target: "home-kpis", placement: "bottom", action: "hover", - title: "Executive summary", - body: "Live decisions, interrupted payments and taka stopped, refreshed as traffic arrives.", + title: "Today at a glance", + body: "This shows how many payments were checked, how many were stopped and how much money (in taka) was saved. It updates live.", + titleBn: "এক নজরে আজকের অবস্থা", + bodyBn: "কতগুলো পেমেন্ট চেক হলো, কতগুলো আটকানো হলো আর কত টাকা বাঁচানো গেল, সব এখানে। সংখ্যাগুলো লাইভ আপডেট হয়।", }, { id: "impact", @@ -75,8 +87,10 @@ export const TOUR_STEPS: TourStep[] = [ target: "impact-simulator", placement: "bottom", action: "hover", - title: "Impact simulator", - body: "Drag the alert threshold to see the trade-off: victims' money saved against honest customers interrupted and reviewer load.", + title: "Try \"what if\"", + body: "Move the slider to be stricter or softer. You see how much money is saved versus how many honest customers get stopped.", + titleBn: "\"যদি এমন হয়\" দেখে নিন", + bodyBn: "স্লাইডারটা সরিয়ে কড়া বা নরম করে দেখুন। কত টাকা বাঁচে আর কতজন সৎ গ্রাহক অকারণে আটকে যায়, তা সাথে সাথে দেখা যাবে।", }, // Operations @@ -88,8 +102,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "top", action: "hover", roles: REVIEW, - title: "Alert queue", - body: "Held and blocked payments stream in live, highest risk first. Filter by tier or status and open one to see why it fired.", + title: "Suspicious payments", + body: "Payments we held or blocked appear here live, riskiest first. Click one to see why it looked suspicious.", + titleBn: "সন্দেহজনক পেমেন্ট", + bodyBn: "যেসব পেমেন্ট আটকানো হয়েছে সেগুলো এখানে লাইভ আসে, সবচেয়ে ঝুঁকিপূর্ণগুলো আগে। কেন সন্দেহ হলো দেখতে যেকোনো একটায় ক্লিক করুন।", }, { id: "cases", @@ -100,7 +116,9 @@ export const TOUR_STEPS: TourStep[] = [ action: "hover", roles: REVIEW, title: "Cases", - body: "Alerts on the same wallet roll up into one case with a review deadline. A person records every verdict; those verdicts retrain the model.", + body: "Alerts about the same account are grouped into one case. A person decides each one, and those decisions help the AI learn.", + titleBn: "কেস", + bodyBn: "একই অ্যাকাউন্টের সব অ্যালার্ট মিলে একটা কেস হয়। প্রতিটা কেসে সিদ্ধান্ত নেন একজন মানুষ, আর সেই সিদ্ধান্ত থেকে AI শেখে।", }, { id: "approvals", @@ -110,8 +128,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "top", action: "hover", roles: REVIEW, - title: "Freeze approvals", - body: "Freezing a wallet takes two people: an analyst asks, a supervisor approves. Ring proposals arrive together but are decided wallet by wallet.", + title: "Freezing an account", + body: "Freezing an account needs two people: one asks, a senior approves. Even for a whole gang, each account is checked one by one.", + titleBn: "অ্যাকাউন্ট ফ্রিজ করা", + bodyBn: "অ্যাকাউন্ট ফ্রিজ করতে দুজন লাগে: একজন অনুরোধ করেন, একজন সিনিয়র অনুমোদন দেন। পুরো চক্র হলেও প্রতিটা অ্যাকাউন্ট আলাদা করে দেখা হয়।", }, { id: "appeals", @@ -121,8 +141,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "top", action: "hover", roles: REVIEW, - title: "Customer appeals", - body: "Customers who think a hold was wrong appeal from the app. Each has a deadline, and late ones are flagged.", + title: "Customer complaints", + body: "If a customer thinks we stopped their payment by mistake, they can complain from the app. Each one has a deadline; late ones are marked.", + titleBn: "গ্রাহকের অভিযোগ", + bodyBn: "গ্রাহকের মনে হলে যে ভুল করে পেমেন্ট আটকানো হয়েছে, অ্যাপ থেকেই অভিযোগ করতে পারেন। প্রতিটার সময়সীমা আছে, দেরি হলে চিহ্ন পড়ে যায়।", }, { id: "refunds", @@ -132,8 +154,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "top", action: "hover", roles: REVIEW, - title: "Victim refunds", - body: "Once fraud is confirmed and the mule wallet frozen, victims are repaid from what is left in it, tracked to a promised date.", + title: "Paying victims back", + body: "Once fraud is confirmed, we freeze the criminal's account and return what's left to victims. You can track each refund until it's paid.", + titleBn: "ক্ষতিগ্রস্তদের টাকা ফেরত", + bodyBn: "প্রতারণা নিশ্চিত হলে প্রতারকের অ্যাকাউন্ট ফ্রিজ করে বাকি টাকা ক্ষতিগ্রস্তদের ফেরত দেওয়া হয়। টাকা পৌঁছানো পর্যন্ত প্রতিটা রিফান্ড ট্র্যাক করা যায়।", }, // Investigation @@ -145,8 +169,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "bottom", action: "hover", roles: REVIEW, - title: "Network explorer", - body: "Look up any wallet to see who it pays, who pays it and which handsets it shares.", + title: "Who's connected?", + body: "Search any account to see who it sends money to, who sends to it, and which phones it shares with others.", + titleBn: "কার সাথে কার যোগাযোগ?", + bodyBn: "যেকোনো অ্যাকাউন্ট সার্চ করে দেখুন সে কাকে টাকা পাঠায়, কে তাকে পাঠায়, আর অন্য কার সাথে একই ফোন ব্যবহার করে।", }, { id: "rings", @@ -156,8 +182,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "top", action: "hover", roles: REVIEW, - title: "Mule rings", - body: "Wallets tied by shared handsets and internal transfers, ranked by money received. Freeze a whole ring in one proposal.", + title: "Criminal gangs", + body: "Groups of accounts criminals use to move stolen money, linked by shared phones. You can ask to freeze a whole gang at once.", + titleBn: "প্রতারক চক্র", + bodyBn: "চুরির টাকা সরাতে প্রতারকরা যে অ্যাকাউন্টগুলো ব্যবহার করে, একই ফোনের সূত্রে সেগুলোকে এক দলে দেখানো হয়। পুরো চক্র একবারে ফ্রিজের অনুরোধ করা যায়।", }, { id: "consortium", @@ -167,8 +195,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "bottom", action: "hover", roles: REVIEW, - title: "Mule consortium", - body: "Providers share signed, tokenised mule listings, so a mule flagged by another provider is known here before it receives a victim's money.", + title: "Shared blacklist", + body: "Payment companies safely share lists of criminal accounts. So if another company catches one, we know before it gets a victim's money.", + titleBn: "সবার শেয়ার করা কালো তালিকা", + bodyBn: "পেমেন্ট কোম্পানিগুলো নিরাপদে প্রতারকদের অ্যাকাউন্টের তালিকা শেয়ার করে। অন্য কেউ ধরলে, টাকা যাওয়ার আগেই আমরা জেনে যাই।", }, { id: "agents", @@ -178,8 +208,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "top", action: "hover", roles: REVIEW, - title: "Agent risk", - body: "Cash-out agents ranked by risk, with what stands out about each. This is where stolen money usually leaves the system.", + title: "Risky cash-out agents", + body: "Shops where people turn wallet money into cash, riskiest first. This is usually where stolen money leaves the system.", + titleBn: "ঝুঁকিপূর্ণ ক্যাশ-আউট এজেন্ট", + bodyBn: "যেসব দোকানে ওয়ালেটের টাকা ক্যাশ করা হয়, ঝুঁকি অনুযায়ী সাজানো। চুরির টাকা সাধারণত এখান দিয়েই বেরিয়ে যায়।", }, // Governance @@ -190,8 +222,10 @@ export const TOUR_STEPS: TourStep[] = [ target: "model-kpis", placement: "bottom", action: "hover", - title: "Model monitoring", - body: "Precision, recall and money stopped on held-out weeks, plus drift and the shadow model waiting to replace it.", + title: "How well is the AI doing?", + body: "This shows how often the AI catches real fraud, how often it's wrong, and whether it's getting worse. A newer AI is tested here too.", + titleBn: "AI কেমন কাজ করছে?", + bodyBn: "AI কতবার আসল প্রতারণা ধরছে, কতবার ভুল করছে, আর দিন দিন খারাপ হচ্ছে কিনা, তা এখানে দেখা যায়। নতুন AI-ও এখানে পরীক্ষা হয়।", }, { id: "fairness", @@ -200,8 +234,10 @@ export const TOUR_STEPS: TourStep[] = [ target: "fairness-kpis", placement: "bottom", action: "hover", - title: "Fairness report", - body: "How often honest customers are interrupted, broken down by sender and receiver group, so no group carries more friction than another.", + title: "Is it fair to everyone?", + body: "This checks that no group of customers gets stopped by mistake more often than others.", + titleBn: "সবার প্রতি কি সমান আচরণ?", + bodyBn: "কোনো এক ধরনের গ্রাহক যেন অন্যদের চেয়ে বেশি অকারণে আটকে না যায়, সেটা এখানে যাচাই করা হয়।", }, { id: "policy", @@ -210,8 +246,10 @@ export const TOUR_STEPS: TourStep[] = [ target: "policy-tiers", placement: "bottom", action: "hover", - title: "Decision policy", - body: "Score thresholds per tier, what the customer sees, and the rules that can only raise a tier, never lower it.", + title: "The rules", + body: "Here you set when a payment is warned, held or blocked, and what the customer sees. Extra rules can only make it stricter.", + titleBn: "নিয়মকানুন", + bodyBn: "কখন পেমেন্টে সতর্কতা দেবে, আটকাবে বা ব্লক করবে, আর গ্রাহক কী দেখবেন, এখানে ঠিক হয়। বাড়তি নিয়ম শুধু কড়াই করতে পারে।", }, { id: "fraud-types", @@ -219,8 +257,10 @@ export const TOUR_STEPS: TourStep[] = [ route: "/fraud-types", target: "fraud-types-header", placement: "bottom", - title: "Fraud types", - body: "Coverage by scam type, and a live checker for Bangla, Banglish and English scam messages.", + title: "Types of scams", + body: "See which kinds of scams we catch. You can also paste a message in Bangla, Banglish or English to check if it's a scam.", + titleBn: "প্রতারণার ধরন", + bodyBn: "কোন কোন ধরনের প্রতারণা আমরা ধরি, দেখে নিন। বাংলা, বাংলিশ বা ইংরেজিতে কোনো মেসেজ পেস্ট করে চেক করতে পারবেন সেটা প্রতারণা কিনা।", }, { id: "audit", @@ -230,8 +270,10 @@ export const TOUR_STEPS: TourStep[] = [ placement: "top", action: "hover", roles: AUDIT, - title: "Audit log", - body: "Every verdict, freeze, refund and identity reveal, with who did it and from where. Nothing in the console is unrecorded.", + title: "Who did what", + body: "Every decision, freeze and refund is recorded here, with who did it and from where. Nothing happens without a record.", + titleBn: "কে কী করেছে", + bodyBn: "প্রতিটা সিদ্ধান্ত, ফ্রিজ আর রিফান্ড এখানে লেখা থাকে, কে করেছে আর কোথা থেকে করেছে সহ। রেকর্ড ছাড়া কিছুই হয় না।", }, // Customer demo @@ -243,7 +285,9 @@ export const TOUR_STEPS: TourStep[] = [ placement: "right", action: "click", title: "See the customer side", - body: "Open the phone demo to watch a payment from the sender's point of view.", + body: "Open the phone demo to see a payment the way a customer sees it.", + titleBn: "গ্রাহকের দিকটা দেখুন", + bodyBn: "ফোন ডেমো খুলে দেখুন, একজন গ্রাহক তার ফোনে পেমেন্টটা কেমন দেখেন।", }, { id: "phone", @@ -253,7 +297,9 @@ export const TOUR_STEPS: TourStep[] = [ placement: "left", action: "hover", title: "What the customer sees", - body: "Pick a payment and send it: the upay app shows the warning, hold or block in Bangla or English, with a one-tap scam report.", + body: "Pick a payment and send it. The upay app shows a warning or stops it, in Bangla or English, with a one-tap button to report a scam.", + titleBn: "গ্রাহক যা দেখেন", + bodyBn: "একটা পেমেন্ট বেছে পাঠিয়ে দেখুন। upay অ্যাপ বাংলা বা ইংরেজিতে সতর্ক করে বা আটকে দেয়, আর এক ট্যাপেই প্রতারণা রিপোর্ট করা যায়।", }, // Finish @@ -263,13 +309,17 @@ export const TOUR_STEPS: TourStep[] = [ target: "tour-button", placement: "right", action: "hover", - title: "Re-run anytime", - body: "Start this tour again from here whenever you need a refresher.", + title: "Watch again anytime", + body: "Click here whenever you want to take this tour again.", + titleBn: "আবার দেখতে চাইলে", + bodyBn: "যখনই আবার ট্যুরটা দেখতে চান, এখানে ক্লিক করুন।", }, { id: "done", chapter: "Finish", - title: "Where to start", - body: "Start with the highest-risk item in your queue. Every action you take is audited and feeds the next model.", + title: "Where to begin", + body: "Start with the riskiest item at the top of your list. Everything you do is recorded and helps the AI get smarter.", + titleBn: "কোথা থেকে শুরু করবেন", + bodyBn: "আপনার তালিকার একদম ওপরের, সবচেয়ে ঝুঁকিপূর্ণ কাজটা দিয়ে শুরু করুন। আপনার প্রতিটা কাজ রেকর্ড হয় আর AI-কে আরও স্মার্ট করে।", }, ]; diff --git a/frontend/components/tour/tour.tsx b/frontend/components/tour/tour.tsx index a4ee940..52252c8 100644 --- a/frontend/components/tour/tour.tsx +++ b/frontend/components/tour/tour.tsx @@ -25,7 +25,7 @@ import { useRouter } from "next/navigation"; import { cx } from "@/components/ui"; import type { Role } from "@/lib/types"; -import { TOUR_SEEN_KEY, type TourApi, type TourStep } from "./types"; +import { TOUR_LANG_KEY, TOUR_SEEN_KEY, type TourApi, type TourLang, type TourStep } from "./types"; /* ------------------------------------------------------------------------------------------ */ /* Context */ @@ -54,46 +54,187 @@ function hasSeen(): boolean { } } -const SECONDS_PER_STEP = 15; +const SECONDS_PER_STEP = 9; + +const BN_DIGITS = "০১২৩৪৫৬৭৮৯"; +const bnNum = (n: number) => String(n).replace(/\d/g, (d) => BN_DIGITS[Number(d)]); + +/** Everything the tour itself says, in plain English and everyday Bangla. */ +const UI = { + en: { + num: (n: number) => String(n), + next: "Next", + back: "Back", + skip: "Skip", + finish: "Finish", + play: "Play the tour by itself", + pause: "Pause the tour", + live: "Live", + paused: "Paused", + welcomeTitle: "Welcome to FraudLens", + welcomeBody: + "FraudLens stops scam payments on mobile money before the money is gone. Sit back and watch: a guide cursor walks through every screen and explains it in plain words.", + watch: "Watch the live tour", + manual: "Go step by step", + later: "Maybe later", + steps: (n: number) => `${n} stops`, + minutes: (m: number) => `about ${m} min`, + readyTitle: "You\u2019re ready", + readyBody: (n: number) => `That\u2019s all ${n} stops. Replay it anytime with the compass button, Take the tour.`, + restart: "Watch again", + done: "Done", + complete: "Complete", + }, + bn: { + num: bnNum, + next: "পরের ধাপ", + back: "আগের", + skip: "বাদ দিন", + finish: "শেষ", + play: "ট্যুর নিজে থেকে চলুক", + pause: "ট্যুর থামান", + live: "লাইভ", + paused: "থামানো", + welcomeTitle: "FraudLens-এ স্বাগতম", + welcomeBody: + "মোবাইল মানিতে প্রতারণার পেমেন্ট টাকা চলে যাওয়ার আগেই FraudLens থামিয়ে দেয়। আরাম করে বসুন আর দেখুন: একটা কার্সর নিজে নিজে প্রতিটা স্ক্রিন ঘুরে সহজ ভাষায় বুঝিয়ে দেবে।", + watch: "লাইভ ট্যুর দেখুন", + manual: "ধাপে ধাপে দেখুন", + later: "পরে দেখব", + steps: (n: number) => `${bnNum(n)}টি ধাপ`, + minutes: (m: number) => `প্রায় ${bnNum(m)} মিনিট`, + readyTitle: "আপনি এখন প্রস্তুত", + readyBody: (n: number) => `${bnNum(n)}টি ধাপই দেখা হলো। কম্পাস বোতাম “Take the tour” চেপে যেকোনো সময় আবার দেখতে পারবেন।`, + restart: "আবার দেখুন", + done: "ঠিক আছে", + complete: "সম্পন্ন", + }, +} as const; + +const CHAPTER_BN: Record = { + Welcome: "স্বাগতম", + Navigation: "মেনু", + Overview: "সারসংক্ষেপ", + Operations: "দৈনন্দিন কাজ", + Investigation: "তদন্ত", + Governance: "নিয়ম ও তদারকি", + "Customer demo": "গ্রাহকের দিক", + Finish: "শেষ", +}; + +const chapterName = (chapter: string, lang: TourLang) => (lang === "bn" ? (CHAPTER_BN[chapter] ?? chapter) : chapter); +const titleOf = (s: TourStep, lang: TourLang) => (lang === "bn" && s.titleBn ? s.titleBn : s.title); +const bodyOf = (s: TourStep, lang: TourLang) => (lang === "bn" && s.bodyBn ? s.bodyBn : s.body); + +function readLang(): TourLang { + try { + return window.localStorage.getItem(TOUR_LANG_KEY) === "bn" ? "bn" : "en"; + } catch { + return "en"; + } +} + +/** Reading time for a step in auto mode: typing plus a calm read, never rushed. */ +function dwellFor(text: string, lang: TourLang): number { + return Math.min(Math.max(2400 + text.length * (lang === "bn" ? 46 : 38), 5200), 11000); +} + +/** A pausable one-shot timer: restarts when `key` changes, keeps its remaining time across pauses. */ +function useCountdown(running: boolean, ms: number, key: number, onDone: () => void) { + const done = useRef(onDone); + const state = useRef({ key: -1, left: ms }); + useEffect(() => { + done.current = onDone; + }); + useEffect(() => { + const s = state.current; + if (s.key !== key) { + s.key = key; + s.left = ms; + } + if (!running) return; + const t0 = performance.now(); + const id = window.setTimeout(() => done.current(), s.left); + return () => { + window.clearTimeout(id); + s.left = Math.max(0, s.left - (performance.now() - t0)); + }; + }, [running, ms, key]); +} + +function LangSwitch({ lang, onLang, className }: { lang: TourLang; onLang: (l: TourLang) => void; className?: string }) { + return ( +
+ {(["en", "bn"] as const).map((l) => ( + + ))} +
+ ); +} interface RunState { /** Index into the role-filtered steps, or null when on the finish card / inactive. */ index: number | null; finished: boolean; + /** Plays by itself: the cursor tours each stop and moves on after a read. */ + auto: boolean; /** Bumps on every jump so the overlay can tell a fresh run from a stale one. */ nonce: number; } export function TourProvider({ steps, role, children }: { steps: TourStep[]; role: Role; children: ReactNode }) { const visible = useMemo(() => steps.filter((s) => !s.roles || s.roles.includes(role)), [steps, role]); - const [run, setRun] = useState({ index: null, finished: false, nonce: 0 }); + const [run, setRun] = useState({ index: null, finished: false, auto: true, nonce: 0 }); const [welcome, setWelcome] = useState(false); + const [lang, setLangState] = useState(readLang); + const setLang = useCallback((l: TourLang) => { + setLangState(l); + try { + window.localStorage.setItem(TOUR_LANG_KEY, l); + } catch { + /* the choice just won't outlive this page */ + } + }, []); const active = run.index !== null || run.finished; const goto = useCallback((index: number) => { - setRun((r) => ({ index, finished: false, nonce: r.nonce + 1 })); + setRun((r) => ({ ...r, index, finished: false, nonce: r.nonce + 1 })); }, []); const start = useCallback( - (fromStepId?: string) => { + (fromStepId?: string, mode: "auto" | "manual" = "auto") => { if (visible.length === 0) return; const at = fromStepId ? visible.findIndex((s) => s.id === fromStepId) : 0; setWelcome(false); markSeen(); - goto(Math.max(0, at)); + setRun((r) => ({ index: Math.max(0, at), finished: false, auto: mode === "auto", nonce: r.nonce + 1 })); }, - [visible, goto], + [visible], ); + const toggleAuto = useCallback(() => setRun((r) => ({ ...r, auto: !r.auto })), []); + const stop = useCallback(() => { markSeen(); - setRun((r) => ({ index: null, finished: false, nonce: r.nonce + 1 })); + setRun((r) => ({ ...r, index: null, finished: false, nonce: r.nonce + 1 })); }, []); const finish = useCallback(() => { markSeen(); - setRun((r) => ({ index: null, finished: true, nonce: r.nonce + 1 })); + setRun((r) => ({ ...r, index: null, finished: true, nonce: r.nonce + 1 })); }, []); // First visit: offer the tour once the console has painted. @@ -113,7 +254,9 @@ export function TourProvider({ steps, role, children }: { steps: TourStep[]; rol {welcome && !active && ( start()} + lang={lang} + onLang={setLang} + onStart={(mode) => start(undefined, mode)} onLater={() => { markSeen(); setWelcome(false); @@ -128,7 +271,10 @@ export function TourProvider({ steps, role, children }: { steps: TourStep[]; rol onGoto={goto} onFinish={finish} onStop={stop} - onRestart={() => goto(0)} + onRestart={() => start(undefined, "auto")} + onToggleAuto={toggleAuto} + lang={lang} + onLang={setLang} />, document.body, )} @@ -170,6 +316,23 @@ function CompassIcon({ className }: { className?: string }) { ); } +function PlayIcon({ className }: { className?: string }) { + return ( + + ); +} + +function PauseIcon({ className }: { className?: string }) { + return ( + + ); +} + function SparkleIcon({ className }: { className?: string }) { return (