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:
Linus Rath
2026-03-21 01:38:42 +01:00
parent a8be40579e
commit 2547c10060
53 changed files with 4036 additions and 110 deletions
@@ -9,7 +9,7 @@ import { CalendarColorPicker } from "@/components/settings/calendar-management-s
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import type { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
interface CalendarSidebarPanelProps {
calendars: Calendar[];
@@ -17,7 +17,7 @@ interface CalendarSidebarPanelProps {
onToggleVisibility: (id: string) => void;
onColorChange?: (calendarId: string, color: string) => void;
onSubscribe?: () => void;
client?: JMAPClient | null;
client?: IJMAPClient | null;
}
export function CalendarSidebarPanel({
+1 -1
View File
@@ -326,7 +326,7 @@ export function CalendarToolbar({
)}
{!isMobile && (
<Button size="sm" onClick={onCreateEvent}>
<Button size="sm" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" />
{t("events.create")}
</Button>
+1 -1
View File
@@ -729,7 +729,7 @@ export function EventModal({
}
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? "fixed inset-0 z-50 flex flex-col bg-background" : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
+2 -2
View File
@@ -6,14 +6,14 @@ import { Button } from "@/components/ui/button";
import { X, Upload, Check, Loader2, RefreshCw, Globe } from "lucide-react";
import { format, parseISO } from "date-fns";
import type { CalendarEvent, Calendar } from "@/lib/jmap/types";
import type { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
interface ICalImportModalProps {
calendars: Calendar[];
client: JMAPClient;
client: IJMAPClient;
onClose: () => void;
}
@@ -4,13 +4,13 @@ import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { X, Loader2, Globe } from "lucide-react";
import type { JMAPClient } from "@/lib/jmap/client";
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { useCalendarStore } from "@/stores/calendar-store";
import { CalendarColorPicker } from "@/components/settings/calendar-management-settings";
import { toast } from "@/stores/toast-store";
interface ICalSubscriptionModalProps {
client: JMAPClient;
client: IJMAPClient;
onClose: () => void;
}
+1
View File
@@ -886,6 +886,7 @@ export function EmailComposer({
return (
<div
className={cn("flex flex-col h-full bg-background relative", className)}
data-tour="composer"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
+1 -1
View File
@@ -358,7 +358,7 @@ export function EmailList({
)}
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
{/* Loading overlay */}
{isLoading && emails.length > 0 && (
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
+53 -1
View File
@@ -66,6 +66,7 @@ import {
Moon,
HelpCircle,
EditIcon,
PlayCircle,
} from "lucide-react";
import { useTranslations } from "next-intl";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
@@ -80,6 +81,7 @@ import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { useTour } from "@/components/tour/tour-provider";
import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog";
import { findCalendarAttachment } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
@@ -871,6 +873,8 @@ export function EmailViewer({
const tCommon = useTranslations('common');
const tSmime = useTranslations('smime');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
const tWelcome = useTranslations('welcome');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
@@ -898,8 +902,9 @@ export function EmailViewer({
// Tablet list visibility
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client } = useAuthStore();
const { identities, client, isDemoMode } = useAuthStore();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const { startTour } = useTour();
const [showFullHeaders, setShowFullHeaders] = useState(false);
const [showAllBesideAttachments, setShowAllBesideAttachments] = useState(false);
const [showAllMobileAttachments, setShowAllMobileAttachments] = useState(false);
@@ -2684,6 +2689,52 @@ export function EmailViewer({
}
if (!email) {
if (isDemoMode) {
const logoSrc = resolvedTheme === 'dark'
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8 max-w-md">
<img
src={logoSrc}
alt="Bulwark Mail"
className="h-12 mx-auto mb-6"
/>
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
<div className="flex flex-col gap-3 items-center">
<div className="grid grid-cols-2 gap-3 text-left text-sm text-muted-foreground w-full">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_email')}</span>
</div>
<div className="flex items-center gap-2">
<Star className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_organize')}</span>
</div>
<div className="flex items-center gap-2">
<Keyboard className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_shortcuts')}</span>
</div>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_privacy')}</span>
</div>
</div>
<button
onClick={startTour}
className="mt-4 inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm font-medium"
>
<PlayCircle className="w-4 h-4" />
{tWelcome('start_tour')}
</button>
<p className="text-xs text-muted-foreground/60 mt-2">{tDemoWelcome('hint')}</p>
</div>
</div>
</div>
);
}
return (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8">
@@ -3233,6 +3284,7 @@ export function EmailViewer({
return (
<div
key={email.id}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden animate-in fade-in duration-300 relative", className)}
>
{/* Mobile More menu sidebar overlay */}
+10
View File
@@ -5,6 +5,7 @@ import { X, Keyboard } from "lucide-react";
import { KEYBOARD_SHORTCUTS } from "@/hooks/use-keyboard-shortcuts";
import { cn } from "@/lib/utils";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTour } from "@/components/tour/tour-provider";
interface KeyboardShortcutsModalProps {
isOpen: boolean;
@@ -13,6 +14,7 @@ interface KeyboardShortcutsModalProps {
export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsModalProps) {
const t = useTranslations();
const { startTour } = useTour();
const modalRef = useFocusTrap({
isActive: isOpen,
@@ -144,6 +146,14 @@ export function KeyboardShortcutsModal({ isOpen, onClose }: KeyboardShortcutsMod
<p className="text-sm text-muted-foreground text-center">
{t("shortcuts.tip")}
</p>
<p className="text-sm text-center mt-2">
<button
onClick={() => { onClose(); startTour(); }}
className="text-primary hover:text-primary/80 underline underline-offset-2 transition-colors"
>
{t("tour.take_a_tour")}
</button>
</p>
</div>
</div>
</div>
+6 -1
View File
@@ -303,6 +303,7 @@ export function NavigationRail({
key={item.id}
href={item.href}
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
data-tour={`nav-${item.id}`}
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150",
collapsed
@@ -401,6 +402,7 @@ export function NavigationRail({
<Link
href="/settings"
onClick={activeAppId ? () => onCloseInlineApp?.() : undefined}
data-tour="nav-settings"
className={cn(
"flex items-center justify-center w-10 h-10 rounded-md transition-colors",
isSettingsActive
@@ -418,6 +420,7 @@ export function NavigationRail({
{onShowShortcuts && (
<button
onClick={onShowShortcuts}
data-tour="nav-shortcuts"
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("keyboard_shortcuts")}
>
@@ -426,7 +429,9 @@ export function NavigationRail({
)}
{quota && quota.total > 0 && (
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
<div data-tour="storage-quota">
<StorageQuotaCircle quota={quota} usagePercent={quotaUsagePercent} />
</div>
)}
{isPushConnected != null && (
+74 -4
View File
@@ -24,6 +24,10 @@ import {
Settings,
X,
Tag,
RotateCcw,
FlaskConical,
PlayCircle,
Loader2,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
@@ -40,6 +44,7 @@ import { debug } from "@/lib/debug";
import { useConfig } from "@/hooks/use-config";
import { useThemeStore } from "@/stores/theme-store";
import { AccountSwitcher } from "./account-switcher";
import { useTour } from "@/components/tour/tour-provider";
interface SidebarProps {
mailboxes: Mailbox[];
@@ -328,6 +333,68 @@ function TagItem({
);
}
function DemoBanner() {
const t = useTranslations('sidebar');
const { isDemoMode, loginDemo } = useAuthStore();
const { startTour, resetTourCompletion } = useTour();
const router = useRouter();
const [isResetting, setIsResetting] = useState(false);
if (!isDemoMode) return null;
const handleReset = async () => {
setIsResetting(true);
// Navigate to home first so the mail page re-fetches data
router.push('/');
await loginDemo();
setIsResetting(false);
};
const handleStartTour = () => {
resetTourCompletion();
router.push('/');
setTimeout(() => startTour(), 100);
};
return (
<div
data-tour="demo-banner"
className={cn(
"flex flex-col gap-1.5 w-full px-3 py-2 text-xs",
"bg-primary/10 dark:bg-primary/10 text-primary",
)}
>
<div className="flex items-center gap-2">
<FlaskConical className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{t("demo_banner")}</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={handleStartTour}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors"
title={t("demo_tour")}
>
<PlayCircle className="w-3 h-3" />
{t("demo_tour")}
</button>
<button
onClick={handleReset}
disabled={isResetting}
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-primary/10 hover:bg-primary/20 transition-colors disabled:opacity-50"
title={t("demo_reset")}
>
{isResetting ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RotateCcw className="w-3 h-3" />
)}
{t("demo_reset")}
</button>
</div>
</div>
);
}
function VacationBanner() {
const t = useTranslations('sidebar');
const router = useRouter();
@@ -491,11 +558,14 @@ export function Sidebar({
)}
</div>
{/* Demo Banner */}
{!isCollapsed && <DemoBanner />}
{/* Vacation Banner */}
{!isCollapsed && <VacationBanner />}
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
<div className="py-1">
{mailboxes.length === 0 ? (
<div className="px-4 py-2 text-sm text-muted-foreground">
@@ -582,7 +652,7 @@ export function Sidebar({
</div>
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
<div className="relative">
<div className="relative" data-tour="keyword-tags">
{emailKeywords.map((kw) => {
const isSelected = selectedKeyword === kw.id;
return (
@@ -606,11 +676,11 @@ export function Sidebar({
{/* Compose Button */}
<div className={cn("border-t border-border", isCollapsed ? "flex justify-center py-3" : "px-3 py-3")}>
{isCollapsed ? (
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")}>
<Button onClick={onCompose} variant="ghost" size="icon" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-5 h-5" />
</Button>
) : (
<Button onClick={onCompose} className="w-full" title={t("compose_hint")}>
<Button onClick={onCompose} className="w-full" title={t("compose_hint")} data-tour="compose-button">
<PenSquare className="w-4 h-4 mr-2" />
{t("compose")}
</Button>
+19 -1
View File
@@ -8,13 +8,21 @@ import { formatFileSize } from '@/lib/utils';
export function AccountSettings() {
const t = useTranslations('settings.account');
const { username, serverUrl } = useAuthStore();
const { username, serverUrl, isDemoMode, primaryIdentity } = useAuthStore();
const { quota } = useEmailStore();
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
const displayName = primaryIdentity?.name || (isDemoMode ? 'Demo User' : undefined);
return (
<SettingsSection title={t('title')} description={t('description')}>
{/* Display Name (show in demo mode or when identity has a name) */}
{displayName && (
<SettingItem label={t('name_label')}>
<span className="text-sm text-foreground">{displayName}</span>
</SettingItem>
)}
{/* Email Address */}
<SettingItem label={t('email.label')}>
<span className="text-sm text-foreground">{username || t('../../common.unknown')}</span>
@@ -49,6 +57,16 @@ export function AccountSettings() {
</div>
</SettingItem>
)}
{/* Demo mode indicator */}
{isDemoMode && (
<SettingItem label={t('account_type_label')}>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-amber-600 dark:text-amber-400">
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
{t('demo_account')}
</span>
</SettingItem>
)}
</SettingsSection>
);
}
@@ -6,6 +6,9 @@ import { useSettingsStore, type ToolbarPosition, type Density } from '@/stores/s
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -61,8 +64,10 @@ function DensityPreview({ density }: { density: Density }) {
export function AppearanceSettings() {
const t = useTranslations('settings.appearance');
const tTour = useTranslations('tour');
const { theme, setTheme } = useThemeStore();
const { fontSize, density, animationsEnabled, toolbarPosition, showToolbarLabels, updateSetting } = useSettingsStore();
const { startTour, resetTourCompletion } = useTour();
return (
<SettingsSection title={t('title')} description={t('description')}>
@@ -141,6 +146,19 @@ export function AppearanceSettings() {
onChange={(checked) => updateSetting('animationsEnabled', checked)}
/>
</SettingItem>
{/* Restart Tour */}
<SettingItem label={tTour('restart_title')} description={tTour('restart_desc')}>
<Button
variant="outline"
size="sm"
onClick={() => { resetTourCompletion(); startTour(); }}
className="text-xs h-7"
>
<PlayCircle className="w-3.5 h-3.5 mr-1" />
{tTour('restart_button')}
</Button>
</SettingItem>
</SettingsSection>
);
}
+413
View File
@@ -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
);
}
+148
View File
@@ -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>
);
}
+221
View File
@@ -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;
}
+12 -1
View File
@@ -2,15 +2,17 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { X, Lightbulb, Settings } from "lucide-react";
import { X, Lightbulb, Settings, PlayCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useRouter } from "@/i18n/navigation";
import { useTour } from "@/components/tour/tour-provider";
const ONBOARDING_KEY = "onboarding_completed";
export function WelcomeBanner() {
const t = useTranslations("welcome");
const router = useRouter();
const { startTour } = useTour();
const [visible, setVisible] = useState(false);
const [dismissed, setDismissed] = useState(false);
@@ -78,6 +80,15 @@ export function WelcomeBanner() {
</button>
</div>
<div className="mt-2.5 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => { dismiss(); startTour(); }}
className="text-xs h-7"
>
<PlayCircle className="w-3.5 h-3.5 mr-1" />
{t("start_tour")}
</Button>
<Button
variant="outline"
size="sm"