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:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user