diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index 19e41bbc..671b7ffe 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -795,6 +795,7 @@ export default function CalendarPage() {
diff --git a/app/[locale]/contacts/page.tsx b/app/[locale]/contacts/page.tsx index a7f47981..5f94ff13 100644 --- a/app/[locale]/contacts/page.tsx +++ b/app/[locale]/contacts/page.tsx @@ -606,6 +606,7 @@ export default function ContactsPage() { {/* Panel 2: Contact list */}
- {children} + + {children} + diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index c527bbbf..4e7e7b94 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -11,7 +11,7 @@ import { useThemeStore } from "@/stores/theme-store"; import { useShallow } from "zustand/react/shallow"; import { useConfig } from "@/hooks/use-config"; import { cn } from "@/lib/utils"; -import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield } from "lucide-react"; +import { Mail, AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play } from "lucide-react"; import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { OAUTH_SCOPES } from "@/lib/oauth/tokens"; @@ -30,9 +30,9 @@ export default function LoginPage() { const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; - const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore(); + const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore(); const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme }))); - const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); + const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig(); const resolvedTheme = useThemeStore((s) => s.resolvedTheme); const [formData, setFormData] = useState({ @@ -54,6 +54,7 @@ export default function LoginPage() { const [oauthMetadata, setOauthMetadata] = useState(null); const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false); const [oauthLoading, setOauthLoading] = useState(false); + const [demoLoading, setDemoLoading] = useState(false); const suggestionsRef = useRef(null); const inputRef = useRef(null); @@ -206,7 +207,7 @@ export default function LoginPage() { ); } - if (!serverUrl) { + if (!serverUrl && !demoMode) { return (
@@ -353,9 +354,167 @@ export default function LoginPage() { } }; + const handleDemoLogin = async () => { + setDemoLoading(true); + const success = await loginDemo(); + if (success) { + router.push('/'); + } + setDemoLoading(false); + }; + const currentThemeOption = THEME_OPTIONS.find(o => o.value === theme) || THEME_OPTIONS[2]; const CurrentThemeIcon = currentThemeOption.icon; + // Demo-only mode: show only a large demo login button + if (demoMode && !isAddAccountMode) { + return ( +
+ {/* Theme toggle */} +
+ + + {showThemeMenu && ( +
+ {THEME_OPTIONS.map((option) => { + const Icon = option.icon; + const isActive = theme === option.value; + return ( + + ); + })} +
+ )} +
+ +
+
+ {/* Header with logo */} +
+
+ {appName} +
+

+ {appName} +

+

+ {t("demo_tagline")} +

+
+ + {/* Large demo button */} +
+ {error && ( +
+ +

+ {t(`error.${error}`) || t("error.generic")} +

+
+ )} + + + +

+ {t("demo_no_signup")} +

+
+
+ + {/* Footer */} +
+ {loginCompanyName && ( +

+ {loginCompanyName} +

+ )} + {(loginImprintUrl || loginPrivacyPolicyUrl || loginWebsiteUrl) && ( +
+ {loginWebsiteUrl && ( + + {t("website")} + + )} + {loginImprintUrl && ( + + {t("imprint")} + + )} + {loginPrivacyPolicyUrl && ( + + {t("privacy_policy")} + + )} +
+ )} +

+ v{APP_VERSION} +

+
+
+
+ ); + } + return (
{/* Theme toggle - top right, dropdown style */} @@ -747,6 +906,34 @@ export default function LoginPage() {
)} + + {/* Demo Mode Button */} + {demoMode && !isAddAccountMode && ( +
+ +

+ {t("demo_description")} +

+
+ )}
diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 520fbb56..c0b39e5a 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1174,6 +1174,7 @@ export default function Home() { onChange={(e) => setSearchQuery(e.target.value)} className={cn("pl-9 h-9", searchQuery && "pr-8")} data-search-input + data-tour="search-input" /> {searchQuery && (
{/* Tabs */} -
+
{groupedTabs.map((group, groupIndex) => (
diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 40f18a04..5558f4ba 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -35,5 +35,6 @@ export async function GET() { loginImprintUrl: process.env.LOGIN_IMPRINT_URL || '', loginPrivacyPolicyUrl: process.env.LOGIN_PRIVACY_POLICY_URL || '', loginWebsiteUrl: process.env.LOGIN_WEBSITE_URL || '', + demoMode: process.env.DEMO_MODE === 'true', }); } diff --git a/components/calendar/calendar-sidebar-panel.tsx b/components/calendar/calendar-sidebar-panel.tsx index 266c0809..d8aba289 100644 --- a/components/calendar/calendar-sidebar-panel.tsx +++ b/components/calendar/calendar-sidebar-panel.tsx @@ -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({ diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index 3f88d83a..c9db0990 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -326,7 +326,7 @@ export function CalendarToolbar({ )} {!isMobile && ( - diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 61961bbf..b6a8874d 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -729,7 +729,7 @@ export function EventModal({ } return ( -
+

{isEdit ? t("events.edit") : t("events.create")} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 3429b7c5..2e9657d4 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -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; } diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index 23ef58d9..226c9265 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -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; } diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 509ebc75..85a4480b 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -886,6 +886,7 @@ export function EmailComposer({ return (
+
{/* Loading overlay */} {isLoading && emails.length > 0 && (
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b4ccd9d3..550f569b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 ( +
+
+ Bulwark Mail +

{tDemoWelcome('title')}

+

{tDemoWelcome('description')}

+
+
+
+ + {tDemoWelcome('feature_email')} +
+
+ + {tDemoWelcome('feature_organize')} +
+
+ + {tDemoWelcome('feature_shortcuts')} +
+
+ + {tDemoWelcome('feature_privacy')} +
+
+ +

{tDemoWelcome('hint')}

+
+
+
+ ); + } return (
@@ -3233,6 +3284,7 @@ export function EmailViewer({ return (
{/* Mobile More menu sidebar overlay */} diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx index 0a99cb90..1df20c97 100644 --- a/components/keyboard-shortcuts-modal.tsx +++ b/components/keyboard-shortcuts-modal.tsx @@ -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

{t("shortcuts.tip")}

+

+ +

diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 4e22eba8..60dbad22 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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({ 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 && ( + +
+
+ ); +} + function VacationBanner() { const t = useTranslations('sidebar'); const router = useRouter(); @@ -491,11 +558,14 @@ export function Sidebar({ )}
+ {/* Demo Banner */} + {!isCollapsed && } + {/* Vacation Banner */} {!isCollapsed && } {/* Mailbox List */} -
+
{mailboxes.length === 0 ? (
@@ -582,7 +652,7 @@ export function Sidebar({
{((tagsExpanded && !isCollapsed) || isCollapsed) && ( -
+
{emailKeywords.map((kw) => { const isSelected = selectedKeyword === kw.id; return ( @@ -606,11 +676,11 @@ export function Sidebar({ {/* Compose Button */}
{isCollapsed ? ( - ) : ( - diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index e880e593..1083e0cb 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -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 ( + {/* Display Name (show in demo mode or when identity has a name) */} + {displayName && ( + + {displayName} + + )} + {/* Email Address */} {username || t('../../common.unknown')} @@ -49,6 +57,16 @@ export function AccountSettings() {
)} + + {/* Demo mode indicator */} + {isDemoMode && ( + + + + {t('demo_account')} + + + )} ); } diff --git a/components/settings/appearance-settings.tsx b/components/settings/appearance-settings.tsx index 81fae268..f046d6c9 100644 --- a/components/settings/appearance-settings.tsx +++ b/components/settings/appearance-settings.tsx @@ -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 = { '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 ( @@ -141,6 +146,19 @@ export function AppearanceSettings() { onChange={(checked) => updateSetting('animationsEnabled', checked)} /> + + {/* Restart Tour */} + + + ); } diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx new file mode 100644 index 00000000..e3d5fff2 --- /dev/null +++ b/components/tour/tour-overlay.tsx @@ -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(null); + const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null); + const [visible, setVisible] = useState(false); + const [mounted, setMounted] = useState(false); + const tooltipRef = useRef(null); + const pendingTimerRef = useRef | 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 */} + + + + + {cutout && ( + + )} + + + + + + {/* Click-through cutout zone for interactive steps */} + {isInteractive && cutout && ( +
+ )} + + {/* Non-interactive overlay click blocker around cutout */} + {!isInteractive && cutout && ( +
+ )} + + {/* Tooltip */} +
{ + (tooltipRef as React.MutableRefObject).current = node; + (focusTrapRef as React.MutableRefObject).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()} + > +
+ {/* Step counter */} +

+ {t("tour.step_counter", { current: currentStep + 1, total: totalSteps })} +

+ + {/* Title */} +

{t(step.titleKey)}

+ + {/* Description */} +

{t(step.descriptionKey)}

+ + {/* Navigation buttons */} +
+ +
+ + +
+
+ + {/* Progress dots */} +
+ {steps.map((_, i) => ( + + ))} +
+
+
+ , + document.body + ); +} diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx new file mode 100644 index 00000000..1030906b --- /dev/null +++ b/components/tour/tour-provider.tsx @@ -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(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 ( + + {children} + {isActive && } + + ); +} diff --git a/components/tour/tour-steps.ts b/components/tour/tour-steps.ts new file mode 100644 index 00000000..9f3fdde1 --- /dev/null +++ b/components/tour/tour-steps.ts @@ -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; +} diff --git a/components/ui/welcome-banner.tsx b/components/ui/welcome-banner.tsx index 25a498f6..c8f08e85 100644 --- a/components/ui/welcome-banner.tsx +++ b/components/ui/welcome-banner.tsx @@ -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() {
+