mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
better cron jobs. Better UI on walkthrough.
This commit is contained in:
parent
6c6c37f7a6
commit
91fc06188f
@ -8,9 +8,105 @@ const endpoints = [
|
||||
"/api/latest/internal/external-db-sync/poller",
|
||||
];
|
||||
|
||||
const previewEndpoints = [
|
||||
"/api/latest/internal/preview/fill-pool",
|
||||
];
|
||||
const previewFillPoolEndpoint = "/api/latest/internal/preview/fill-pool";
|
||||
|
||||
const PREVIEW_FILL_POOL_ACTIVE_INTERVAL_MS = 5_000;
|
||||
const PREVIEW_FILL_POOL_IDLE_INTERVAL_MS = 60_000;
|
||||
const PREVIEW_FILL_POOL_ERROR_INTERVAL_MS = 10_000;
|
||||
const BACKEND_HEALTH_POLL_INTERVAL_MS = 2_000;
|
||||
|
||||
type PreviewFillPoolResult = {
|
||||
ready_count_before: number,
|
||||
created_count: number,
|
||||
target_ready_count: number,
|
||||
deleted_expired_count: number,
|
||||
};
|
||||
|
||||
async function waitUntilBackendReady(baseUrl: string): Promise<void> {
|
||||
while (true) {
|
||||
const healthResult = await Result.fromPromise(fetch(`${baseUrl}/health`));
|
||||
if (healthResult.status === "ok" && healthResult.data.ok) {
|
||||
return;
|
||||
}
|
||||
await wait(BACKEND_HEALTH_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewFillPoolPollIntervalMs(result: PreviewFillPoolResult): number {
|
||||
const poolNeedsFilling = result.ready_count_before < result.target_ready_count;
|
||||
const didWork = result.created_count > 0 || result.deleted_expired_count > 0;
|
||||
if (poolNeedsFilling || didWork) {
|
||||
return PREVIEW_FILL_POOL_ACTIVE_INTERVAL_MS;
|
||||
}
|
||||
return PREVIEW_FILL_POOL_IDLE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
function logPreviewFillPoolActivity(result: PreviewFillPoolResult): void {
|
||||
const parts: string[] = [];
|
||||
if (result.created_count > 0) {
|
||||
parts.push(`created ${result.created_count}`);
|
||||
}
|
||||
if (result.deleted_expired_count > 0) {
|
||||
parts.push(`cleaned up ${result.deleted_expired_count} expired lease(s)`);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const readyCount = result.ready_count_before + result.created_count;
|
||||
console.log(`Preview pool: ${parts.join(", ")} (${readyCount}/${result.target_ready_count} ready)`);
|
||||
}
|
||||
|
||||
async function runPreviewFillPool(baseUrl: string, cronSecret: string): Promise<PreviewFillPoolResult> {
|
||||
const res = await fetch(`${baseUrl}${previewFillPoolEndpoint}`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${cronSecret}`,
|
||||
"x-stack-development-disable-extended-logging": "yes",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new HexclaveAssertionError(
|
||||
`Failed to call ${previewFillPoolEndpoint}: ${res.status} ${res.statusText}\n${await res.text()}`,
|
||||
{ res },
|
||||
);
|
||||
}
|
||||
return await res.json() as PreviewFillPoolResult;
|
||||
}
|
||||
|
||||
async function runPreviewFillPoolLoop(baseUrl: string, cronSecret: string): Promise<void> {
|
||||
console.log("Preview pool cron started (fills every 5s while below target, every 60s when full).");
|
||||
await waitUntilBackendReady(baseUrl);
|
||||
|
||||
let isIdle = false;
|
||||
while (true) {
|
||||
const runResult = await Result.fromPromise(runPreviewFillPool(baseUrl, cronSecret));
|
||||
if (runResult.status === "error") {
|
||||
captureError("run-cron-jobs-preview", runResult.error);
|
||||
isIdle = false;
|
||||
await wait(PREVIEW_FILL_POOL_ERROR_INTERVAL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = runResult.data;
|
||||
const didWork = result.created_count > 0 || result.deleted_expired_count > 0;
|
||||
const poolNeedsFilling = result.ready_count_before < result.target_ready_count;
|
||||
|
||||
if (didWork) {
|
||||
logPreviewFillPoolActivity(result);
|
||||
isIdle = false;
|
||||
} else if (poolNeedsFilling) {
|
||||
isIdle = false;
|
||||
} else if (!isIdle) {
|
||||
console.log(
|
||||
`Preview pool full (${result.ready_count_before}/${result.target_ready_count} ready), `
|
||||
+ `polling every ${PREVIEW_FILL_POOL_IDLE_INTERVAL_MS / 1000}s`,
|
||||
);
|
||||
isIdle = true;
|
||||
}
|
||||
|
||||
await wait(getPreviewFillPoolPollIntervalMs(result));
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseUrl = `http://localhost:${getEnvVariable('NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX', '81')}02`;
|
||||
@ -25,27 +121,7 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const run = async (endpoint: string) => {
|
||||
console.log(`Running ${endpoint}...`);
|
||||
const res = await fetch(`${baseUrl}${endpoint}`, {
|
||||
headers: { 'Authorization': `Bearer ${cronSecret}` },
|
||||
});
|
||||
if (!res.ok) throw new HexclaveAssertionError(`Failed to call ${endpoint}: ${res.status} ${res.statusText}\n${await res.text()}`, { res });
|
||||
console.log(`${endpoint} completed.`);
|
||||
};
|
||||
|
||||
for (const endpoint of previewEndpoints) {
|
||||
runAsynchronously(async () => {
|
||||
await wait(30_000); // Wait 30 seconds to make sure the server is fully started
|
||||
while (true) {
|
||||
const runResult = await Result.fromPromise(run(endpoint));
|
||||
if (runResult.status === "error") {
|
||||
captureError("run-cron-jobs-preview", runResult.error);
|
||||
}
|
||||
await wait(5000);
|
||||
}
|
||||
});
|
||||
}
|
||||
runAsynchronously(() => runPreviewFillPoolLoop(baseUrl, cronSecret));
|
||||
return;
|
||||
}
|
||||
console.log("Starting cron jobs...");
|
||||
@ -62,7 +138,7 @@ async function main() {
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
runAsynchronously(async () => {
|
||||
await wait(30_000); // Wait 30 seconds to make sure the server is fully started
|
||||
await waitUntilBackendReady(baseUrl);
|
||||
while (true) {
|
||||
const runResult = await Result.fromPromise(run(endpoint));
|
||||
if (runResult.status === "error") {
|
||||
|
||||
@ -414,6 +414,17 @@ body:has(.show-site-loading-indicator) .site-loading-indicator {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes walkthrough-pulse-ring {
|
||||
0% {
|
||||
transform: scale(0.85);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* Pacifica styles */
|
||||
[data-pacifica-surface] {
|
||||
|
||||
@ -1,21 +1,42 @@
|
||||
export function MockCursor({ className }: { className?: string }) {
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WalkthroughPhase } from './walkthrough-steps';
|
||||
|
||||
export function MockCursor({
|
||||
className,
|
||||
phase,
|
||||
}: {
|
||||
className?: string,
|
||||
phase?: WalkthroughPhase,
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
||||
>
|
||||
<path
|
||||
d="M5 3L19 12L12 13L9 20L5 3Z"
|
||||
fill="white"
|
||||
stroke="black"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="relative">
|
||||
{phase === 'dwelling' && (
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -top-1 -left-1 h-8 w-8 rounded-full",
|
||||
"border-2 border-blue-500",
|
||||
"animate-[walkthrough-pulse-ring_1.4s_ease-out_infinite]",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<svg
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
style={{ filter: 'drop-shadow(2px 2px 0 rgba(0,0,0,0.85))' }}
|
||||
>
|
||||
<path
|
||||
d="M5 3L19 12L12 13L9 20L5 3Z"
|
||||
fill="white"
|
||||
stroke="#171717"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,9 +4,11 @@ import { cn } from '@/lib/utils';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { MockCursor } from './mock-cursor';
|
||||
import { type SpotlightRect, type WalkthroughStep } from './walkthrough-steps';
|
||||
import { type SpotlightRect, type WalkthroughPhase, type WalkthroughStep } from './walkthrough-steps';
|
||||
|
||||
export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing';
|
||||
export type { WalkthroughPhase };
|
||||
|
||||
const SPOTLIGHT_BORDER = 'rgb(59 130 246)'; // blue-500
|
||||
|
||||
export function WalkthroughOverlay({
|
||||
step,
|
||||
@ -61,7 +63,7 @@ export function WalkthroughOverlay({
|
||||
: null;
|
||||
|
||||
const padding = step?.spotlightPadding ?? 8;
|
||||
const overlayOpacity = showSpotlight ? (animatedIn ? 0.55 : 0) : 0;
|
||||
const overlayOpacity = showSpotlight ? (animatedIn ? 0.62 : 0) : 0;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
@ -70,15 +72,18 @@ export function WalkthroughOverlay({
|
||||
{showSpotlight && displayRect && step && (
|
||||
<>
|
||||
<div
|
||||
className="fixed pointer-events-none rounded-xl"
|
||||
className="fixed pointer-events-none"
|
||||
style={{
|
||||
top: displayRect.top - padding,
|
||||
left: displayRect.left - padding,
|
||||
width: displayRect.width + padding * 2,
|
||||
height: displayRect.height + padding * 2,
|
||||
boxShadow: `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity})`,
|
||||
borderRadius: animatedIn ? '14px' : '0px',
|
||||
border: animatedIn ? `2px solid ${SPOTLIGHT_BORDER}` : '0px solid transparent',
|
||||
boxShadow: animatedIn
|
||||
? `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity}), 0 0 14px 2px rgba(59, 130, 246, 0.35), 0 0 28px 6px rgba(59, 130, 246, 0.12)`
|
||||
: `0 0 0 9999px rgba(0, 0, 0, ${overlayOpacity})`,
|
||||
transition: 'all 400ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
borderRadius: animatedIn ? undefined : '0px',
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -89,6 +94,7 @@ export function WalkthroughOverlay({
|
||||
totalSteps={totalSteps}
|
||||
phase={phase}
|
||||
spotlightRect={spotlightRect!}
|
||||
padding={padding}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -103,10 +109,9 @@ export function WalkthroughOverlay({
|
||||
transition: 'transform 500ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
>
|
||||
<MockCursor />
|
||||
<MockCursor phase={phase} />
|
||||
</div>
|
||||
|
||||
{/* Floating tour progress card — bottom-center, bold and unmissable */}
|
||||
{step && (
|
||||
<TourProgressCard
|
||||
stepIndex={stepIndex}
|
||||
@ -116,12 +121,12 @@ export function WalkthroughOverlay({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* "Click to take control" hover overlay — above everything including CmdK */}
|
||||
{/* "Click to take control" hover overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[60] flex items-center justify-center cursor-pointer",
|
||||
"bg-black/50 backdrop-blur-[2px]",
|
||||
"transition-opacity duration-200",
|
||||
"transition-opacity duration-150",
|
||||
isHovering ? "opacity-100" : "opacity-0 pointer-events-none",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@ -135,7 +140,7 @@ export function WalkthroughOverlay({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invisible click catcher (active only when not hovering) — anywhere = stop */}
|
||||
{/* Invisible click catcher (active only when not hovering) */}
|
||||
{!isHovering && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
@ -162,8 +167,6 @@ function TourProgressCard({
|
||||
phase: WalkthroughPhase,
|
||||
dwellMs: number,
|
||||
}) {
|
||||
// Animate the bar's scaleX. During 'dwelling', grow from i/N to (i+1)/N over
|
||||
// `dwellMs`. During 'navigating', hold at i/N. During 'finishing', show 100%.
|
||||
const [progress, setProgress] = useState(stepIndex / totalSteps);
|
||||
const [transitionMs, setTransitionMs] = useState(0);
|
||||
|
||||
@ -175,7 +178,6 @@ function TourProgressCard({
|
||||
setTransitionMs(300);
|
||||
setProgress(1);
|
||||
} else if (phase === 'dwelling') {
|
||||
// Snap to start with no transition, then next frame kick off the long animation.
|
||||
setTransitionMs(0);
|
||||
setProgress(stepIndex / totalSteps);
|
||||
raf1 = requestAnimationFrame(() => {
|
||||
@ -185,8 +187,6 @@ function TourProgressCard({
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// 'navigating' — hold at start of step with a short transition so loop
|
||||
// resets ease in rather than snap.
|
||||
setTransitionMs(250);
|
||||
setProgress(stepIndex / totalSteps);
|
||||
}
|
||||
@ -220,8 +220,7 @@ function TourProgressCard({
|
||||
className={cn(
|
||||
"inline-flex items-center px-2 py-0.5 rounded-full",
|
||||
"text-[10px] font-bold uppercase tracking-wider",
|
||||
"bg-gradient-to-r from-blue-500 to-indigo-500 text-white",
|
||||
"shadow-[0_0_12px_rgba(59,130,246,0.6)]",
|
||||
"bg-blue-500 text-white",
|
||||
)}
|
||||
>
|
||||
Tour
|
||||
@ -240,7 +239,6 @@ function TourProgressCard({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* The big bar */}
|
||||
<div className="px-5 pb-4">
|
||||
<div
|
||||
className={cn(
|
||||
@ -250,11 +248,7 @@ function TourProgressCard({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 origin-left rounded-full",
|
||||
"bg-gradient-to-r from-blue-500 via-blue-500 to-indigo-500",
|
||||
"shadow-[0_0_18px_rgba(59,130,246,0.85),0_0_6px_rgba(99,102,241,1)]",
|
||||
)}
|
||||
className="absolute inset-y-0 left-0 origin-left rounded-full bg-blue-500"
|
||||
style={{
|
||||
width: '100%',
|
||||
transform: `scaleX(${progress})`,
|
||||
@ -262,12 +256,7 @@ function TourProgressCard({
|
||||
? `transform ${transitionMs}ms linear`
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Bright leading edge highlight */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-10 bg-gradient-to-l from-white/80 to-transparent pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -281,53 +270,53 @@ function SpotlightTooltip({
|
||||
totalSteps,
|
||||
phase,
|
||||
spotlightRect,
|
||||
padding,
|
||||
}: {
|
||||
step: WalkthroughStep,
|
||||
stepIndex: number,
|
||||
totalSteps: number,
|
||||
phase: WalkthroughPhase,
|
||||
spotlightRect: SpotlightRect,
|
||||
padding: number,
|
||||
}) {
|
||||
const tooltipWidth = 300;
|
||||
const tooltipHeight = 96;
|
||||
const tooltipGap = 16;
|
||||
// Reserve room for the bottom progress card (~88px tall including margin).
|
||||
const tooltipWidth = 280;
|
||||
const tooltipGap = 14;
|
||||
const viewportMarginTop = 16;
|
||||
const viewportMarginBottom = 112;
|
||||
const viewportMarginX = 16;
|
||||
const padding = step.spotlightPadding ?? 8;
|
||||
|
||||
const spotlightTop = spotlightRect.top - padding;
|
||||
const spotlightBottom = spotlightRect.top + spotlightRect.height + padding;
|
||||
const spotlightCenterX = spotlightRect.left + spotlightRect.width / 2;
|
||||
|
||||
// Default: below the spotlight.
|
||||
let placement: 'below' | 'above' = 'below';
|
||||
let top = spotlightBottom + tooltipGap;
|
||||
let left = spotlightCenterX - tooltipWidth / 2;
|
||||
|
||||
// If that would collide with the bottom progress card, flip to above the spotlight.
|
||||
if (top + tooltipHeight > window.innerHeight - viewportMarginBottom) {
|
||||
top = spotlightTop - tooltipGap - tooltipHeight;
|
||||
const estimatedHeight = 96;
|
||||
if (top + estimatedHeight > window.innerHeight - viewportMarginBottom) {
|
||||
placement = 'above';
|
||||
top = spotlightTop - tooltipGap - estimatedHeight;
|
||||
}
|
||||
|
||||
top = Math.max(
|
||||
viewportMarginTop,
|
||||
Math.min(top, window.innerHeight - tooltipHeight - viewportMarginBottom),
|
||||
Math.min(top, window.innerHeight - estimatedHeight - viewportMarginBottom),
|
||||
);
|
||||
|
||||
let left = spotlightCenterX - tooltipWidth / 2;
|
||||
left = Math.max(
|
||||
viewportMarginX,
|
||||
Math.min(left, window.innerWidth - tooltipWidth - viewportMarginX),
|
||||
);
|
||||
|
||||
const pointerLeft = Math.max(
|
||||
20,
|
||||
Math.min(spotlightCenterX - left, tooltipWidth - 20),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed pointer-events-none px-4 py-3 rounded-xl",
|
||||
"bg-white/95 dark:bg-zinc-900/90",
|
||||
"backdrop-blur-xl",
|
||||
"shadow-2xl shadow-black/25",
|
||||
"ring-1 ring-black/10 dark:ring-white/10",
|
||||
)}
|
||||
className="fixed pointer-events-none"
|
||||
style={{
|
||||
top,
|
||||
left,
|
||||
@ -335,16 +324,52 @@ function SpotlightTooltip({
|
||||
transition: 'top 400ms cubic-bezier(0.22, 1, 0.36, 1), left 400ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">{step.title}</span>
|
||||
<span className="text-[11px] tabular-nums font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{stepIndex + 1} / {totalSteps}
|
||||
</span>
|
||||
{/* Speech bubble pointer */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute w-3 h-3",
|
||||
"bg-white/95 dark:bg-zinc-900/90",
|
||||
"border-black/10 dark:border-white/10",
|
||||
)}
|
||||
style={{
|
||||
left: pointerLeft - 6,
|
||||
...(placement === 'below'
|
||||
? {
|
||||
top: -6,
|
||||
borderTopWidth: '1px',
|
||||
borderLeftWidth: '1px',
|
||||
transform: 'rotate(45deg)',
|
||||
}
|
||||
: {
|
||||
bottom: -6,
|
||||
borderBottomWidth: '1px',
|
||||
borderRightWidth: '1px',
|
||||
transform: 'rotate(45deg)',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative rounded-xl px-4 py-3",
|
||||
"bg-white/95 dark:bg-zinc-900/90",
|
||||
"backdrop-blur-xl",
|
||||
"shadow-2xl shadow-black/25",
|
||||
"ring-1 ring-black/10 dark:ring-white/10",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-50">{step.title}</span>
|
||||
<span className="text-[11px] tabular-nums font-medium text-zinc-500 dark:text-zinc-400">
|
||||
{stepIndex + 1} / {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{step.description}</p>
|
||||
{phase === 'navigating' && (
|
||||
<p className="mt-1.5 text-[11px] text-zinc-400 dark:text-zinc-500 italic">navigating…</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-300 leading-relaxed">{step.description}</p>
|
||||
{phase === 'navigating' && (
|
||||
<p className="mt-1.5 text-[11px] text-zinc-400 dark:text-zinc-500 italic">navigating…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
export type WalkthroughPhase = 'navigating' | 'dwelling' | 'finishing';
|
||||
|
||||
export type WalkthroughStep = {
|
||||
id: string,
|
||||
path: string,
|
||||
@ -21,14 +23,14 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [
|
||||
path: '/',
|
||||
sidebarNavLabel: 'Overview',
|
||||
title: 'Global User Map',
|
||||
description: 'See where your users are around the world.',
|
||||
description: 'Pinch-zoom around the globe and see where sign-ups are landing.',
|
||||
spotlightPadding: 12,
|
||||
},
|
||||
{
|
||||
id: 'overview-metrics',
|
||||
path: '/',
|
||||
title: 'Usage Metrics',
|
||||
description: 'Track daily active users and sign-ups at a glance.',
|
||||
description: 'Daily actives, sign-ups, and retention — the numbers you actually check.',
|
||||
spotlightPadding: 12,
|
||||
},
|
||||
{
|
||||
@ -36,34 +38,34 @@ export const WALKTHROUGH_STEPS: WalkthroughStep[] = [
|
||||
path: '/users',
|
||||
cmdkSearch: 'Users',
|
||||
title: 'User Management',
|
||||
description: 'Manage all your users — search, export, or create new ones.',
|
||||
description: 'Search, export, impersonate, or spin up test users in one place.',
|
||||
},
|
||||
{
|
||||
id: 'teams-table',
|
||||
path: '/teams',
|
||||
cmdkSearch: 'Teams',
|
||||
title: 'Teams',
|
||||
description: 'Organize users into teams for multi-tenant apps.',
|
||||
description: 'Multi-tenant apps live here. Teams, roles, invites — the whole stack.',
|
||||
},
|
||||
{
|
||||
id: 'emails-sent',
|
||||
path: '/email-sent',
|
||||
cmdkSearch: 'Emails sent',
|
||||
title: 'Email Logs',
|
||||
description: 'Monitor sent emails, delivery status, and domain reputation.',
|
||||
description: 'Every sent email, delivery status, and domain health in one feed.',
|
||||
},
|
||||
{
|
||||
id: 'payments-products',
|
||||
path: '/payments/products',
|
||||
cmdkSearch: 'Products',
|
||||
title: 'Products & Pricing',
|
||||
description: 'Define products, pricing, and subscriptions.',
|
||||
description: 'Products, prices, and subscriptions wired straight to Stripe.',
|
||||
},
|
||||
{
|
||||
id: 'analytics-replays',
|
||||
path: '/session-replays',
|
||||
sidebarNavLabel: 'Replays',
|
||||
title: 'Session Replays',
|
||||
description: 'Watch real user sessions to understand how people use your app.',
|
||||
description: 'Watch real sessions — clicks, rage taps, and dead ends included.',
|
||||
},
|
||||
];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user