feat: add demo data for emails, files, filters, identities, mailboxes, vacation responses, and JMAP client interface
- Created demo emails with various states (inbox, sent, drafts, trash, etc.) in `emails.ts`. - Added demo file nodes representing directories and files in `files.ts`. - Implemented demo Sieve capabilities and scripts in `filters.ts`. - Defined demo identities for users in `identities.ts`. - Established demo mailboxes with permissions and counts in `mailboxes.ts`. - Created a demo vacation response in `vacation.ts`. - Introduced a comprehensive JMAP client interface in `client-interface.ts` to standardize interactions with the JMAP API.
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTour } from "./tour-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFocusTrap } from "@/hooks/use-focus-trap";
|
||||
|
||||
interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const PADDING = 8;
|
||||
const TOOLTIP_GAP = 12;
|
||||
const TOOLTIP_MAX_W = 360;
|
||||
|
||||
function getTargetRect(selector: string): Rect | null {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
// Element might exist but be hidden (zero dimensions)
|
||||
if (r.width === 0 && r.height === 0) return null;
|
||||
return { top: r.top, left: r.left, width: r.width, height: r.height };
|
||||
}
|
||||
|
||||
function computeTooltipPosition(
|
||||
target: Rect,
|
||||
placement: "top" | "bottom" | "left" | "right",
|
||||
tooltipSize: { width: number; height: number }
|
||||
): { top: number; left: number; actualPlacement: string } {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const tw = Math.max(tooltipSize.width, 200); // minimum fallback width
|
||||
const th = Math.max(tooltipSize.height, 100); // minimum fallback height
|
||||
|
||||
const positions = {
|
||||
bottom: {
|
||||
top: target.top + target.height + PADDING + TOOLTIP_GAP,
|
||||
left: target.left + target.width / 2 - tw / 2,
|
||||
},
|
||||
top: {
|
||||
top: target.top - PADDING - TOOLTIP_GAP - th,
|
||||
left: target.left + target.width / 2 - tw / 2,
|
||||
},
|
||||
right: {
|
||||
top: target.top + target.height / 2 - th / 2,
|
||||
left: target.left + target.width + PADDING + TOOLTIP_GAP,
|
||||
},
|
||||
left: {
|
||||
top: target.top + target.height / 2 - th / 2,
|
||||
left: target.left - PADDING - TOOLTIP_GAP - tw,
|
||||
},
|
||||
};
|
||||
|
||||
const fits = (p: { top: number; left: number }) =>
|
||||
p.top >= 8 && p.left >= 8 && p.top + th <= vh - 8 && p.left + tw <= vw - 8;
|
||||
|
||||
// Try preferred placement first, then fallback order
|
||||
const order: Array<"top" | "bottom" | "left" | "right"> = [placement, "bottom", "right", "left", "top"];
|
||||
for (const dir of order) {
|
||||
const pos = positions[dir];
|
||||
if (fits(pos)) return { ...pos, actualPlacement: dir };
|
||||
}
|
||||
|
||||
// If nothing fits perfectly, use preferred but clamped
|
||||
const pos = positions[placement];
|
||||
return {
|
||||
top: Math.max(8, Math.min(pos.top, vh - th - 8)),
|
||||
left: Math.max(8, Math.min(pos.left, vw - tw - 8)),
|
||||
actualPlacement: placement,
|
||||
};
|
||||
}
|
||||
|
||||
export function TourOverlay() {
|
||||
const t = useTranslations();
|
||||
const { currentStep, totalSteps, steps, nextStep, prevStep, stopTour } = useTour();
|
||||
const step = steps[currentStep];
|
||||
|
||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Use refs for callbacks to avoid stale closures in timers/intervals
|
||||
const updatePositionRef = useRef<() => void>(() => {});
|
||||
const nextStepRef = useRef<() => void>(() => {});
|
||||
nextStepRef.current = nextStep;
|
||||
|
||||
const focusTrapRef = useFocusTrap({
|
||||
isActive: visible,
|
||||
onEscape: stopTour,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
// Set mounted for portal
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!step) return;
|
||||
const rect = getTargetRect(step.target);
|
||||
|
||||
if (rect) {
|
||||
setTargetRect(rect);
|
||||
if (tooltipRef.current) {
|
||||
const { width, height } = tooltipRef.current.getBoundingClientRect();
|
||||
const pos = computeTooltipPosition(rect, step.placement, { width, height });
|
||||
setTooltipPos({ top: pos.top, left: pos.left });
|
||||
}
|
||||
}
|
||||
// If rect is null, keep previous targetRect (element temporarily hidden during scroll/resize)
|
||||
// Only the step-change effect should null out targetRect
|
||||
}, [step]);
|
||||
|
||||
// Keep ref in sync
|
||||
updatePositionRef.current = updatePosition;
|
||||
|
||||
// Wait for target element to appear, then show
|
||||
useEffect(() => {
|
||||
if (!step) return;
|
||||
console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" — target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`);
|
||||
setVisible(false);
|
||||
// Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position
|
||||
// instead of disappearing and reappearing
|
||||
|
||||
// Clear any pending timer from a previous step
|
||||
if (pendingTimerRef.current) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
|
||||
// Run beforeAction if defined (e.g. click an email to open the viewer)
|
||||
if (step.beforeAction) {
|
||||
step.beforeAction();
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 50; // 5 seconds
|
||||
let cancelled = false;
|
||||
|
||||
const tryFind = () => {
|
||||
if (cancelled) return true;
|
||||
const el = document.querySelector(step.target);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`);
|
||||
el.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
// Delay after scroll for layout to settle
|
||||
pendingTimerRef.current = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`);
|
||||
updatePositionRef.current();
|
||||
setVisible(true);
|
||||
// Second position update after tooltip renders with final dimensions
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) updatePositionRef.current();
|
||||
});
|
||||
}, 200);
|
||||
return true;
|
||||
}
|
||||
if (attempts % 10 === 0) {
|
||||
console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (tryFind()) return () => { cancelled = true; };
|
||||
|
||||
// Poll for element appearance (for page navigation)
|
||||
const interval = setInterval(() => {
|
||||
attempts++;
|
||||
if (tryFind() || attempts >= maxAttempts) {
|
||||
clearInterval(interval);
|
||||
if (attempts >= maxAttempts && !cancelled) {
|
||||
// Skip this step if element never appears
|
||||
console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED — element never appeared after ${maxAttempts} attempts`);
|
||||
nextStepRef.current();
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(interval);
|
||||
if (pendingTimerRef.current) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Recalculate on resize/scroll (debounced)
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let rafId: number | null = null;
|
||||
const handler = () => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
};
|
||||
window.addEventListener("resize", handler);
|
||||
window.addEventListener("scroll", handler, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handler);
|
||||
window.removeEventListener("scroll", handler, true);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [visible, updatePosition]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight" || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
nextStep();
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
prevStep();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
stopTour();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [nextStep, prevStep, stopTour]);
|
||||
|
||||
// Re-position after tooltip content renders with new dimensions
|
||||
useEffect(() => {
|
||||
if (!visible || !tooltipRef.current) return;
|
||||
// Use rAF to wait for the browser to lay out the tooltip content
|
||||
const id = requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [visible, updatePosition, currentStep]);
|
||||
|
||||
if (!mounted || !step) return null;
|
||||
|
||||
const cutout = targetRect
|
||||
? {
|
||||
x: targetRect.left - PADDING,
|
||||
y: targetRect.top - PADDING,
|
||||
w: targetRect.width + PADDING * 2,
|
||||
h: targetRect.height + PADDING * 2,
|
||||
}
|
||||
: null;
|
||||
|
||||
const isLast = currentStep >= totalSteps - 1;
|
||||
const isFirst = currentStep === 0;
|
||||
const isInteractive = step.interactive;
|
||||
|
||||
const reducedMotion =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const transitionStyle = reducedMotion ? "none" : "all 300ms ease";
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
{/* SVG overlay with cutout */}
|
||||
<svg
|
||||
className="fixed inset-0 z-[9998]"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ pointerEvents: isInteractive ? "none" : "auto" }}
|
||||
onClick={stopTour}
|
||||
>
|
||||
<defs>
|
||||
<mask id="tour-mask">
|
||||
<rect fill="white" width="100%" height="100%" />
|
||||
{cutout && (
|
||||
<rect
|
||||
fill="black"
|
||||
x={cutout.x}
|
||||
y={cutout.y}
|
||||
width={cutout.w}
|
||||
height={cutout.h}
|
||||
rx="8"
|
||||
style={{ transition: transitionStyle }}
|
||||
/>
|
||||
)}
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
fill="black"
|
||||
opacity="0.5"
|
||||
mask="url(#tour-mask)"
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Click-through cutout zone for interactive steps */}
|
||||
{isInteractive && cutout && (
|
||||
<div
|
||||
className="fixed z-[9998]"
|
||||
style={{
|
||||
top: cutout.y,
|
||||
left: cutout.x,
|
||||
width: cutout.w,
|
||||
height: cutout.h,
|
||||
pointerEvents: "none",
|
||||
transition: transitionStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Non-interactive overlay click blocker around cutout */}
|
||||
{!isInteractive && cutout && (
|
||||
<div
|
||||
className="fixed z-[9998]"
|
||||
style={{
|
||||
top: cutout.y,
|
||||
left: cutout.x,
|
||||
width: cutout.w,
|
||||
height: cutout.h,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
ref={(node) => {
|
||||
(tooltipRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
(focusTrapRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t(step.titleKey)}
|
||||
className={cn(
|
||||
"fixed z-[9999] transition-all",
|
||||
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2"
|
||||
)}
|
||||
style={{
|
||||
top: tooltipPos?.top ?? -9999,
|
||||
left: tooltipPos?.left ?? -9999,
|
||||
maxWidth: TOOLTIP_MAX_W,
|
||||
transition: reducedMotion ? "none" : "opacity 200ms ease, transform 200ms ease, top 300ms ease, left 300ms ease",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="bg-background border border-border rounded-xl shadow-2xl p-4">
|
||||
{/* Step counter */}
|
||||
<p className="text-xs text-muted-foreground mb-1" aria-live="polite">
|
||||
{t("tour.step_counter", { current: currentStep + 1, total: totalSteps })}
|
||||
</p>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-sm text-foreground">{t(step.titleKey)}</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mt-1">{t(step.descriptionKey)}</p>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<button
|
||||
onClick={stopTour}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded hover:bg-muted"
|
||||
>
|
||||
{t("tour.skip")}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={prevStep}
|
||||
disabled={isFirst}
|
||||
className={cn(
|
||||
"text-xs px-3 py-1.5 rounded-md border border-border transition-colors",
|
||||
isFirst
|
||||
? "opacity-40 cursor-not-allowed"
|
||||
: "hover:bg-muted"
|
||||
)}
|
||||
>
|
||||
{t("tour.back")}
|
||||
</button>
|
||||
<button
|
||||
onClick={nextStep}
|
||||
className="text-xs px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{isLast ? t("tour.finish") : t("tour.next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div className="flex justify-center gap-1 mt-2">
|
||||
{steps.map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-1.5 h-1.5 rounded-full transition-colors",
|
||||
i === currentStep ? "bg-primary" : "bg-muted-foreground/30"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { useWebDAVStore } from "@/stores/webdav-store";
|
||||
import { getTourSteps, type TourStep } from "./tour-steps";
|
||||
import { TourOverlay } from "./tour-overlay";
|
||||
|
||||
const TOUR_COMPLETED_KEY = "tour_completed";
|
||||
const TOUR_CURRENT_STEP_KEY = "tour_current_step";
|
||||
|
||||
interface TourContextValue {
|
||||
isActive: boolean;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
steps: TourStep[];
|
||||
startTour: () => void;
|
||||
stopTour: () => void;
|
||||
nextStep: () => void;
|
||||
prevStep: () => void;
|
||||
hasCompletedTour: boolean;
|
||||
resetTourCompletion: () => void;
|
||||
}
|
||||
|
||||
const TourContext = createContext<TourContextValue | null>(null);
|
||||
|
||||
export function useTour() {
|
||||
const ctx = useContext(TourContext);
|
||||
if (!ctx) throw new Error("useTour must be used within TourProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function TourProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { isDemoMode } = useAuthStore();
|
||||
const { supportsCalendar } = useCalendarStore();
|
||||
const { supportsWebDAV } = useWebDAVStore();
|
||||
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [hasCompletedTour, setHasCompletedTour] = useState(false);
|
||||
|
||||
const steps = getTourSteps({ isDemoMode, supportsCalendar, supportsWebDAV: supportsWebDAV !== false });
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
setHasCompletedTour(localStorage.getItem(TOUR_COMPLETED_KEY) === "true");
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const startTour = useCallback(() => {
|
||||
let resumeStep = 0;
|
||||
try {
|
||||
const stored = localStorage.getItem(TOUR_CURRENT_STEP_KEY);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed >= 0) resumeStep = parsed;
|
||||
}
|
||||
} catch { /* */ }
|
||||
|
||||
// If the resume step is beyond the current steps, start from 0
|
||||
if (resumeStep >= steps.length) resumeStep = 0;
|
||||
|
||||
setCurrentStep(resumeStep);
|
||||
setIsActive(true);
|
||||
}, [steps.length]);
|
||||
|
||||
const stopTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const completeTour = useCallback(() => {
|
||||
setIsActive(false);
|
||||
setHasCompletedTour(true);
|
||||
try {
|
||||
localStorage.setItem(TOUR_COMPLETED_KEY, "true");
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const nextStep = useCallback(() => {
|
||||
if (currentStep >= steps.length - 1) {
|
||||
completeTour();
|
||||
return;
|
||||
}
|
||||
const next = currentStep + 1;
|
||||
const nextStepDef = steps[next];
|
||||
setCurrentStep(next);
|
||||
try {
|
||||
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(next));
|
||||
} catch { /* */ }
|
||||
|
||||
// Navigate if the next step requires a different page
|
||||
if (nextStepDef?.page) {
|
||||
router.push(nextStepDef.page);
|
||||
}
|
||||
}, [currentStep, steps, completeTour, router]);
|
||||
|
||||
const prevStep = useCallback(() => {
|
||||
if (currentStep <= 0) return;
|
||||
const prev = currentStep - 1;
|
||||
const prevStepDef = steps[prev];
|
||||
setCurrentStep(prev);
|
||||
try {
|
||||
localStorage.setItem(TOUR_CURRENT_STEP_KEY, String(prev));
|
||||
} catch { /* */ }
|
||||
|
||||
if (prevStepDef?.page) {
|
||||
router.push(prevStepDef.page);
|
||||
} else if (steps[currentStep]?.page) {
|
||||
// Going back from a page-specific step to a non-page step => go to mail
|
||||
router.push("/");
|
||||
}
|
||||
}, [currentStep, steps, router]);
|
||||
|
||||
const resetTourCompletion = useCallback(() => {
|
||||
setHasCompletedTour(false);
|
||||
try {
|
||||
localStorage.removeItem(TOUR_COMPLETED_KEY);
|
||||
localStorage.removeItem(TOUR_CURRENT_STEP_KEY);
|
||||
} catch { /* */ }
|
||||
}, []);
|
||||
|
||||
const value: TourContextValue = {
|
||||
isActive,
|
||||
currentStep,
|
||||
totalSteps: steps.length,
|
||||
steps,
|
||||
startTour,
|
||||
stopTour,
|
||||
nextStep,
|
||||
prevStep,
|
||||
hasCompletedTour,
|
||||
resetTourCompletion,
|
||||
};
|
||||
|
||||
return (
|
||||
<TourContext.Provider value={value}>
|
||||
{children}
|
||||
{isActive && <TourOverlay />}
|
||||
</TourContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
export interface TourStep {
|
||||
id: string;
|
||||
target: string;
|
||||
titleKey: string;
|
||||
descriptionKey: string;
|
||||
placement: "top" | "bottom" | "left" | "right";
|
||||
interactive?: boolean;
|
||||
spotlight?: "rect" | "circle";
|
||||
page?: string;
|
||||
demoOnly?: boolean;
|
||||
beforeAction?: () => void;
|
||||
}
|
||||
|
||||
export const BASE_TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
id: "sidebar",
|
||||
target: '[data-tour="sidebar"]',
|
||||
titleKey: "tour.sidebar_title",
|
||||
descriptionKey: "tour.sidebar_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "compose",
|
||||
target: '[data-tour="compose-button"]',
|
||||
titleKey: "tour.compose_title",
|
||||
descriptionKey: "tour.compose_desc",
|
||||
placement: "right",
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
target: '[data-tour="search-input"]',
|
||||
titleKey: "tour.search_title",
|
||||
descriptionKey: "tour.search_desc",
|
||||
placement: "bottom",
|
||||
interactive: true,
|
||||
},
|
||||
{
|
||||
id: "email-list",
|
||||
target: '[data-tour="email-list"]',
|
||||
titleKey: "tour.email_list_title",
|
||||
descriptionKey: "tour.email_list_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "email-viewer",
|
||||
target: '[data-tour="email-viewer"]',
|
||||
titleKey: "tour.email_viewer_title",
|
||||
descriptionKey: "tour.email_viewer_desc",
|
||||
placement: "left",
|
||||
beforeAction: () => {
|
||||
// Click the "Welcome to Bulwark Mail!" email (or the first email) to open the viewer
|
||||
const emailList = document.querySelector('[data-tour="email-list"]');
|
||||
if (!emailList) return;
|
||||
// Try to find the welcome email by subject text
|
||||
const items = emailList.querySelectorAll('.cursor-pointer');
|
||||
let target: HTMLElement | null = null;
|
||||
for (const item of items) {
|
||||
if (item.textContent?.includes("Welcome to Bulwark Mail")) {
|
||||
target = item as HTMLElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Fallback to first email if welcome email not found
|
||||
if (!target) target = emailList.querySelector('.cursor-pointer') as HTMLElement | null;
|
||||
if (target) target.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "keywords",
|
||||
target: '[data-tour="keyword-tags"]',
|
||||
titleKey: "tour.keywords_title",
|
||||
descriptionKey: "tour.keywords_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "nav-calendar",
|
||||
target: '[data-tour="nav-calendar"]',
|
||||
titleKey: "tour.calendar_title",
|
||||
descriptionKey: "tour.calendar_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "nav-contacts",
|
||||
target: '[data-tour="nav-contacts"]',
|
||||
titleKey: "tour.contacts_title",
|
||||
descriptionKey: "tour.contacts_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "nav-settings",
|
||||
target: '[data-tour="nav-settings"]',
|
||||
titleKey: "tour.settings_title",
|
||||
descriptionKey: "tour.settings_desc",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
id: "shortcuts",
|
||||
target: '[data-tour="nav-shortcuts"]',
|
||||
titleKey: "tour.shortcuts_title",
|
||||
descriptionKey: "tour.shortcuts_desc",
|
||||
placement: "right",
|
||||
interactive: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const DEMO_TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
id: "compose-open",
|
||||
target: '[data-tour="composer"]',
|
||||
titleKey: "tour.compose_open_title",
|
||||
descriptionKey: "tour.compose_open_desc",
|
||||
placement: "left",
|
||||
demoOnly: true,
|
||||
beforeAction: () => {
|
||||
// Click the compose button to open the composer
|
||||
const btn = document.querySelector('[data-tour="compose-button"]') as HTMLElement | null;
|
||||
if (btn) btn.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "calendar-view",
|
||||
target: '[data-tour="calendar-view"]',
|
||||
titleKey: "tour.calendar_view_title",
|
||||
descriptionKey: "tour.calendar_view_desc",
|
||||
placement: "bottom",
|
||||
page: "/calendar",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "create-event",
|
||||
target: '[data-tour="create-event-button"]',
|
||||
titleKey: "tour.create_event_title",
|
||||
descriptionKey: "tour.create_event_desc",
|
||||
placement: "bottom",
|
||||
page: "/calendar",
|
||||
interactive: true,
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "event-modal",
|
||||
target: '[data-tour="event-modal"]',
|
||||
titleKey: "tour.event_modal_title",
|
||||
descriptionKey: "tour.event_modal_desc",
|
||||
placement: "left",
|
||||
page: "/calendar",
|
||||
interactive: true,
|
||||
demoOnly: true,
|
||||
beforeAction: () => {
|
||||
// Click the create event button to open the modal
|
||||
const btn = document.querySelector('[data-tour="create-event-button"]') as HTMLElement | null;
|
||||
if (btn) btn.click();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contacts-list",
|
||||
target: '[data-tour="contacts-list"]',
|
||||
titleKey: "tour.contacts_list_title",
|
||||
descriptionKey: "tour.contacts_list_desc",
|
||||
placement: "right",
|
||||
page: "/contacts",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "settings-tabs",
|
||||
target: '[data-tour="settings-tabs"]',
|
||||
titleKey: "tour.settings_tabs_title",
|
||||
descriptionKey: "tour.settings_tabs_desc",
|
||||
placement: "right",
|
||||
page: "/settings",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "nav-files",
|
||||
target: '[data-tour="nav-files"]',
|
||||
titleKey: "tour.files_title",
|
||||
descriptionKey: "tour.files_desc",
|
||||
placement: "right",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "demo-banner",
|
||||
target: '[data-tour="demo-banner"]',
|
||||
titleKey: "tour.demo_banner_title",
|
||||
descriptionKey: "tour.demo_banner_desc",
|
||||
placement: "bottom",
|
||||
page: "/",
|
||||
demoOnly: true,
|
||||
},
|
||||
{
|
||||
id: "quota",
|
||||
target: '[data-tour="storage-quota"]',
|
||||
titleKey: "tour.quota_title",
|
||||
descriptionKey: "tour.quota_desc",
|
||||
placement: "right",
|
||||
demoOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function getTourSteps(options: {
|
||||
isDemoMode: boolean;
|
||||
supportsCalendar: boolean;
|
||||
supportsWebDAV: boolean;
|
||||
}): TourStep[] {
|
||||
let steps = [...BASE_TOUR_STEPS];
|
||||
|
||||
if (!options.supportsCalendar) {
|
||||
steps = steps.filter((s) => s.id !== "nav-calendar");
|
||||
}
|
||||
|
||||
if (options.isDemoMode) {
|
||||
const demoSteps = DEMO_TOUR_STEPS.filter((s) => {
|
||||
if (s.id === "nav-files" && !options.supportsWebDAV) return false;
|
||||
if ((s.id === "calendar-view" || s.id === "create-event" || s.id === "event-modal") && !options.supportsCalendar) return false;
|
||||
return true;
|
||||
});
|
||||
steps = [...steps, ...demoSteps];
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
Reference in New Issue
Block a user