Skip to content
Merged
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
4 changes: 2 additions & 2 deletions frontend/app/(console)/api-keys/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,12 @@ export default function ApiKeysPage() {

return (
<>
<PageHeader
<PageHeader tour="api-keys-header"
title="Partner API keys"
sub="Let a wallet app call the scoring endpoints with its own key: limited per minute and per day, counted, and revocable. Sandbox keys give canned answers and touch nothing."
actions={<Button small variant="primary" onClick={() => setCreating(true)}>Make a key</Button>}
/>
<Card flush>
<Card flush tour="api-keys-list">
<Async state={keys}>
{(rows) =>
rows.length ? (
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/(console)/blocklist/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export default function BlocklistPage() {

return (
<>
<PageHeader
<PageHeader tour="blocklist-header"
title="Blocklist"
sub="Wallets, phone numbers and domains known to be used for fraud. A listed wallet asks the sender to verify and wait; nothing here blocks money by itself."
actions={
Expand All @@ -141,7 +141,7 @@ export default function BlocklistPage() {
)
}
/>
<Card flush>
<Card flush tour="blocklist-list">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-line px-4 py-3">
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-xs font-medium text-fg-3">Type</span>
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/(console)/webhooks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,14 @@ export default function WebhooksPage() {
const pages = Math.max(1, Math.ceil(total / 25));
return (
<>
<PageHeader
<PageHeader tour="webhooks-header"
title="Webhooks"
sub="Tell other systems when a payment is warned, stepped up or held, when a case gets a verdict, and when a wallet is frozen. Customers are texted the fixed policy wording when SMS is configured."
actions={<Button small variant="primary" onClick={() => setCreating(true)}>Add endpoint</Button>}
/>
{error && <div className="mb-3"><ErrorNote error={error} /></div>}
{note && <p className="mb-3 text-sm text-fg-2">{note}</p>}
<Card title="Endpoints" hint="An endpoint that fails 30 times in a row is switched off." flush>
<Card tour="webhooks-endpoints" title="Endpoints" hint="An endpoint that fails 30 times in a row is switched off." flush>
<Async state={endpoints}>
{(rows) =>
rows.length ? (
Expand Down
41 changes: 41 additions & 0 deletions frontend/components/tour/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,20 @@ export const TOUR_STEPS: TourStep[] = [
bodyBn: "প্রতারণা নিশ্চিত হলে প্রতারকের অ্যাকাউন্ট ফ্রিজ করে বাকি টাকা ক্ষতিগ্রস্তদের ফেরত দেওয়া হয়। টাকা পৌঁছানো পর্যন্ত প্রতিটা রিফান্ড ট্র্যাক করা যায়।",
},

{
id: "blocklist",
chapter: "Operations",
route: "/blocklist",
target: "blocklist-list",
placement: "top",
action: "hover",
roles: REVIEW,
title: "Known scam numbers",
body: "Wallets, phone numbers and links already used by scammers. If someone tries to pay one, they are asked to check and wait first.",
titleBn: "চেনা প্রতারকদের তালিকা",
bodyBn: "প্রতারকেরা আগে যেসব ওয়ালেট, ফোন নম্বর আর লিংক ব্যবহার করেছে, সেগুলো এখানে। কেউ এগুলোতে টাকা পাঠাতে গেলে আগে যাচাই করে একটু অপেক্ষা করতে বলা হয়।",
},

// Investigation
{
id: "network",
Expand Down Expand Up @@ -276,6 +290,33 @@ export const TOUR_STEPS: TourStep[] = [
bodyBn: "প্রতিটা সিদ্ধান্ত, ফ্রিজ আর রিফান্ড এখানে লেখা থাকে, কে করেছে আর কোথা থেকে করেছে সহ। রেকর্ড ছাড়া কিছুই হয় না।",
},

{
id: "webhooks",
chapter: "Governance",
route: "/webhooks",
target: "webhooks-endpoints",
placement: "bottom",
action: "hover",
roles: AUDIT,
title: "Instant alerts to other systems",
body: "When a payment is held or a wallet is frozen, FraudLens can tell your other systems right away, and text the customer if SMS is on.",
titleBn: "অন্য সিস্টেমে সাথে সাথে খবর",
bodyBn: "কোনো পেমেন্ট আটকালে বা ওয়ালেট ফ্রিজ হলে FraudLens সাথে সাথে আপনার অন্য সিস্টেমকে জানাতে পারে, আর SMS চালু থাকলে গ্রাহককেও মেসেজ পাঠায়।",
},
{
id: "api-keys",
chapter: "Governance",
route: "/api-keys",
target: "api-keys-list",
placement: "bottom",
action: "hover",
roles: AUDIT,
title: "Keys for partner apps",
body: "Give a wallet app its own key to check payments with FraudLens. Each key has a daily limit, shows its usage, and can be switched off anytime.",
titleBn: "পার্টনার অ্যাপের চাবি",
bodyBn: "কোনো ওয়ালেট অ্যাপকে আলাদা একটা চাবি (API key) দিন, যাতে তারা FraudLens দিয়ে পেমেন্ট চেক করতে পারে। প্রতিটার দৈনিক সীমা আছে, ব্যবহার দেখা যায়, আর যেকোনো সময় বন্ধ করা যায়।",
},

// Customer demo
{
id: "nav-phone",
Expand Down
76 changes: 51 additions & 25 deletions frontend/components/tour/tour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,8 @@ function TourOverlay({
const isReady = finished || ready?.nonce === nonce;
const target = isReady && !finished ? (ready?.el ?? null) : null;
const action = step?.action;
const showCursor = !!target && action !== "none" && !reduced;
// The guide's cursor never leaves while the tour runs, so the tour always reads as live.
const showCursor = !reduced && !finished;

const rootRef = useRef<HTMLDivElement>(null);
const holeRef = useRef<HTMLDivElement>(null);
Expand All @@ -604,7 +605,9 @@ function TourOverlay({
reduced: false,
nonce: -1,
movingUntil: 0,
cursor: { x: -1, y: -1, fromX: 0, fromY: 0, ctrlX: 0, ctrlY: 0, t0: 0, dur: 0, animating: false, bend: 1, arrivedAt: 0 },
cursor: { x: -1, y: -1, fromX: 0, fromY: 0, ctrlX: 0, ctrlY: 0, t0: 0, dur: 0, animating: false, bend: 1, arrivedAt: 0, baseX: 0, baseY: 0 },
/** The card is showing, so the cursor can rest on its Next button when there is no target. */
ready: false,
});

useEffect(() => {
Expand All @@ -615,22 +618,14 @@ function TourOverlay({
if (l.target !== target || l.nonce !== nonce) {
l.movingUntil = performance.now() + (reduced ? 0 : MOVE_MS);
l.nonce = nonce;
// Start a fresh cursor glide toward the new target.
// Start a fresh cursor glide; the frame loop picks its destination once there is one.
const c = l.cursor;
if (target) {
if (c.x < 0) {
c.x = window.innerWidth * 0.62;
c.y = window.innerHeight * 0.82;
}
c.fromX = c.x;
c.fromY = c.y;
c.t0 = performance.now();
c.bend = -c.bend;
c.animating = true;
c.dur = 0;
}
c.bend = -c.bend;
c.animating = true;
c.dur = 0;
}
l.target = target;
l.ready = isReady;
});

// Resolve the step: navigate, wait for the target, scroll it into view.
Expand Down Expand Up @@ -759,11 +754,25 @@ function TourOverlay({
// Cursor: glide on a bezier toward the (possibly moving) target centre, then stick to it.
const cur = cursorRef.current;
const c = l.cursor;
if (cur && box) {
const tx = box.left + box.width / 2;
const ty = box.top + box.height / 2;
// No target: rest on the card's Next button, as if about to press it.
let aim: Box | null = box;
const nextBtn = nextRef.current;
if (!aim && l.ready && nextBtn) {
const r = nextBtn.getBoundingClientRect();
if (r.width > 0) aim = { left: r.left, top: r.top, width: r.width, height: r.height };
}
if (cur && c.x < 0) {
c.x = c.baseX = vw * 0.62;
c.y = c.baseY = vh * 0.82;
}
if (cur && aim) {
const tx = aim.left + aim.width * (box ? 0.5 : 0.62);
const ty = aim.top + aim.height * (box ? 0.5 : 0.7);
if (c.animating) {
if (c.dur === 0) {
c.fromX = c.x;
c.fromY = c.y;
c.t0 = now;
const dist = Math.hypot(tx - c.fromX, ty - c.fromY);
c.dur = l.reduced ? 1 : Math.min(Math.max(dist * 1.1, 520), 1150);
const mx = (c.fromX + tx) / 2;
Expand All @@ -784,16 +793,33 @@ function TourOverlay({
c.arrivedAt = now;
setArrived(l.nonce);
}
} else if (l.action === "hover" && !l.reduced && (box.width > 220 || box.height > 140)) {
// A big panel: the cursor reads it, sweeping a slow figure-eight across it like a person would.
const k = (now - c.arrivedAt) / 1000;
const amp = Math.min(k / 0.9, 1);
c.x = tx + Math.max(box.width / 2 - 40, 0) * 0.78 * amp * Math.sin(k * 0.85);
c.y = ty + Math.max(box.height / 2 - 28, 0) * 0.6 * amp * Math.sin(k * 1.7);
} else {
} else if (l.reduced) {
c.x = tx;
c.y = ty;
} else {
const k = (now - c.arrivedAt) / 1000;
const amp = Math.min(k / 0.9, 1); // ease into the motion from the landing point
if (box && l.action === "hover" && (box.width > 220 || box.height > 140)) {
// A big panel: the cursor reads it, sweeping a slow figure-eight across it like a person would.
c.x = tx + Math.max(box.width / 2 - 40, 0) * 0.78 * amp * Math.sin(k * 0.85);
c.y = ty + Math.max(box.height / 2 - 28, 0) * 0.6 * amp * Math.sin(k * 1.7);
} else {
// Anything smaller: a restless hand, drifting inside the target on two out-of-step waves.
const ax = Math.min(Math.max(aim.width / 2 - 8, 6), 24) * amp;
const ay = Math.min(Math.max(aim.height / 2 - 6, 5), 12) * amp;
c.x = tx + ax * (0.7 * Math.sin(k * 1.3) + 0.3 * Math.sin(k * 3.1 + 1));
c.y = ty + ay * (0.7 * Math.sin(k * 1.8 + 0.6) + 0.3 * Math.sin(k * 2.7));
}
}
c.baseX = c.x;
c.baseY = c.y;
} else if (cur && !l.reduced) {
// Waiting for the next page to render: hover in place instead of freezing.
const k = now / 1000;
c.x = c.baseX + 7 * Math.sin(k * 1.9);
c.y = c.baseY + 5 * Math.sin(k * 2.6 + 0.8);
}
if (cur && c.x >= 0) {
cur.style.transform = `translate(${c.x}px, ${c.y}px)`;
}
};
Expand Down
Loading