Phase 4: Webhooks, Backup/Restore UI, Onboarding/Tutorial
- Webhooks Backend: model, schema, service (HMAC-SHA256, httpx), routes, event bus dispatcher, migration 0042 - Webhooks Frontend: SettingsWebhooksPage (CRUD, test button, event multi-select), API client - Backup/Restore Backend: model, schema, service (pg_dump/pg_restore), routes (admin-only), migration 0043 - Backup/Restore Frontend: SettingsBackupPage (create, list, restore dialog with RESTORE confirmation, auto-refresh) - Onboarding: OnboardingTour (8 steps, custom CSS overlay), WelcomeDialog, onboardingStore (zustand + localStorage) - Onboarding integrated into AppShell - Routes: /settings/webhooks, /settings/backup registered - Settings nav: Webhooks, Backup & Restore entries added - Migration conflict fixed: 0042_webhooks → 0043_backups chain
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOnboardingStore } from '@/store/onboardingStore';
|
||||
import { useUpsertUserPreference } from '@/api/userPreferences';
|
||||
import { X, ChevronLeft, ChevronRight, Check } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* OnboardingTour — Custom guided tour with CSS overlay and positioned tooltips.
|
||||
* No external dependency (react-joyride types unavailable for TS strict mode).
|
||||
*
|
||||
* 8 steps targeting sidebar items, search, contacts, calendar, AI assistant, settings.
|
||||
* Each step highlights a target element via a cut-out overlay and shows a tooltip.
|
||||
*/
|
||||
|
||||
interface TourStep {
|
||||
target: string;
|
||||
titleKey: string;
|
||||
titleFallback: string;
|
||||
descKey: string;
|
||||
descFallback: string;
|
||||
placement?: 'bottom' | 'top' | 'left' | 'right' | 'center';
|
||||
}
|
||||
|
||||
const TOUR_STEPS: TourStep[] = [
|
||||
{
|
||||
target: '[data-testid="sidebar"]',
|
||||
titleKey: 'onboarding.step1Title',
|
||||
titleFallback: 'Navigation',
|
||||
descKey: 'onboarding.step1Desc',
|
||||
descFallback: 'In der Seitenleiste finden Sie alle Hauptbereiche von LeoCRM. Klicken Sie auf einen Eintrag, um dorthin zu navigieren.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: 'a[href="/dashboard"]',
|
||||
titleKey: 'onboarding.step2Title',
|
||||
titleFallback: 'Dashboard',
|
||||
descKey: 'onboarding.step2Desc',
|
||||
descFallback: 'Das Dashboard gibt Ihnen einen Überblick über wichtige Kennzahlen und aktuelle Aktivitäten.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: 'a[href="/contacts"]',
|
||||
titleKey: 'onboarding.step3Title',
|
||||
titleFallback: 'Kontakte',
|
||||
descKey: 'onboarding.step3Desc',
|
||||
descFallback: 'Verwalten Sie hier alle Firmen und Personen. Legen Sie neue Kontakte an oder bearbeiten Sie bestehende.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-testid="search-dropdown"]',
|
||||
titleKey: 'onboarding.step4Title',
|
||||
titleFallback: 'Globale Suche',
|
||||
descKey: 'onboarding.step4Desc',
|
||||
descFallback: 'Mit der globalen Suche finden Sie schnell Kontakte, Termine und andere Einträge in LeoCRM.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: 'a[href="/calendar"]',
|
||||
titleKey: 'onboarding.step5Title',
|
||||
titleFallback: 'Kalender',
|
||||
descKey: 'onboarding.step5Desc',
|
||||
descFallback: 'Im Kalender verwalten Sie Termine, Veranstaltungen und Wochenpläne.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: 'a[href="/ai-assistant"]',
|
||||
titleKey: 'onboarding.step6Title',
|
||||
titleFallback: 'KI-Assistent',
|
||||
descKey: 'onboarding.step6Desc',
|
||||
descFallback: 'Der KI-Assistent hilft Ihnen bei Automatisierung, Texterstellung und intelligente Vorschläge.',
|
||||
placement: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-testid="topbar"]',
|
||||
titleKey: 'onboarding.step7Title',
|
||||
titleFallback: 'Top-Bar',
|
||||
descKey: 'onboarding.step7Desc',
|
||||
descFallback: 'Hier erreichen Sie Benachrichtigungen, Ihr Benutzerprofil und weitere Einstellungen.',
|
||||
placement: 'bottom',
|
||||
},
|
||||
{
|
||||
target: 'a[href="/settings"]',
|
||||
titleKey: 'onboarding.step8Title',
|
||||
titleFallback: 'Einstellungen',
|
||||
descKey: 'onboarding.step8Desc',
|
||||
descFallback: 'Passen Sie LeoCRM an Ihre Bedürfnisse an: Profil, Sprache, Design und mehr.',
|
||||
placement: 'right',
|
||||
},
|
||||
];
|
||||
|
||||
interface Rect {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const HIGHLIGHT_PADDING = 8;
|
||||
|
||||
export function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const { isActive, step, next, prev, skip, complete } = useOnboardingStore();
|
||||
const upsertPreference = useUpsertUserPreference();
|
||||
|
||||
const [targetRect, setTargetRect] = useState<Rect | null>(null);
|
||||
const [tooltipPos, setTooltipPos] = useState<{ top: number; left: number } | null>(null);
|
||||
const [targetMissing, setTargetMissing] = useState(false);
|
||||
|
||||
const currentStep = TOUR_STEPS[step];
|
||||
const totalSteps = TOUR_STEPS.length;
|
||||
const isLastStep = step >= totalSteps - 1;
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!isActive || !currentStep) return;
|
||||
|
||||
const el = document.querySelector(currentStep.target) as HTMLElement | null;
|
||||
if (!el) {
|
||||
setTargetMissing(true);
|
||||
setTargetRect(null);
|
||||
setTooltipPos({ top: window.innerHeight / 2 - 120, left: window.innerWidth / 2 - 200 });
|
||||
return;
|
||||
}
|
||||
|
||||
setTargetMissing(false);
|
||||
const rect = el.getBoundingClientRect();
|
||||
const padded: Rect = {
|
||||
top: rect.top - HIGHLIGHT_PADDING,
|
||||
left: rect.left - HIGHLIGHT_PADDING,
|
||||
width: rect.width + HIGHLIGHT_PADDING * 2,
|
||||
height: rect.height + HIGHLIGHT_PADDING * 2,
|
||||
};
|
||||
setTargetRect(padded);
|
||||
|
||||
// Calculate tooltip position based on placement
|
||||
const tooltipWidth = 360;
|
||||
const tooltipHeight = 220;
|
||||
const margin = 16;
|
||||
let top: number;
|
||||
let left: number;
|
||||
|
||||
const placement = currentStep.placement || 'bottom';
|
||||
|
||||
switch (placement) {
|
||||
case 'right':
|
||||
top = rect.top + rect.height / 2 - tooltipHeight / 2;
|
||||
left = rect.right + margin;
|
||||
break;
|
||||
case 'left':
|
||||
top = rect.top + rect.height / 2 - tooltipHeight / 2;
|
||||
left = rect.left - tooltipWidth - margin;
|
||||
break;
|
||||
case 'top':
|
||||
top = rect.top - tooltipHeight - margin;
|
||||
left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||
break;
|
||||
case 'bottom':
|
||||
top = rect.bottom + margin;
|
||||
left = rect.left + rect.width / 2 - tooltipWidth / 2;
|
||||
break;
|
||||
default:
|
||||
top = window.innerHeight / 2 - tooltipHeight / 2;
|
||||
left = window.innerWidth / 2 - tooltipWidth / 2;
|
||||
}
|
||||
|
||||
// Clamp to viewport
|
||||
top = Math.max(margin, Math.min(top, window.innerHeight - tooltipHeight - margin));
|
||||
left = Math.max(margin, Math.min(left, window.innerWidth - tooltipWidth - margin));
|
||||
|
||||
setTooltipPos({ top, left });
|
||||
}, [isActive, currentStep, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
// Small delay to allow DOM to settle after potential route changes
|
||||
const timer = setTimeout(updatePosition, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isActive, step, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const handler = () => updatePosition();
|
||||
window.addEventListener('resize', handler);
|
||||
window.addEventListener('scroll', handler, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handler);
|
||||
window.removeEventListener('scroll', handler, true);
|
||||
};
|
||||
}, [isActive, updatePosition]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
if (step > 0) prev();
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
handleSkip();
|
||||
break;
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isActive, step]);
|
||||
|
||||
const handleNext = useCallback(() => {
|
||||
if (isLastStep) {
|
||||
complete();
|
||||
upsertPreference.mutate({ key: 'onboarding_completed', value: true });
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}, [isLastStep, complete, next, upsertPreference]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
skip();
|
||||
upsertPreference.mutate({ key: 'onboarding_completed', value: true });
|
||||
}, [skip, upsertPreference]);
|
||||
|
||||
if (!isActive || !currentStep) return null;
|
||||
|
||||
const title = t(currentStep.titleKey, currentStep.titleFallback);
|
||||
const description = t(currentStep.descKey, currentStep.descFallback);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Dark overlay with cut-out for highlighted element */}
|
||||
<div
|
||||
className="fixed inset-0 z-[55] pointer-events-auto"
|
||||
aria-hidden="true"
|
||||
data-testid="onboarding-overlay"
|
||||
onClick={handleSkip}
|
||||
style={{
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.65)',
|
||||
// Use box-shadow trick to create a cut-out: huge shadow from the highlight rect
|
||||
...(targetRect && !targetMissing
|
||||
? {
|
||||
boxShadow: `0 0 0 9999px rgba(15, 23, 42, 0.65)`,
|
||||
borderRadius: '8px',
|
||||
top: targetRect.top,
|
||||
left: targetRect.left,
|
||||
width: targetRect.width,
|
||||
height: targetRect.height,
|
||||
inset: 'auto',
|
||||
backgroundColor: 'transparent',
|
||||
transition: 'all 0.3s ease',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Highlight border around target */}
|
||||
{targetRect && !targetMissing && (
|
||||
<div
|
||||
className="fixed z-[56] pointer-events-none rounded-lg ring-2 ring-primary-500 ring-offset-2 ring-offset-transparent"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
top: targetRect.top,
|
||||
left: targetRect.left,
|
||||
width: targetRect.width,
|
||||
height: targetRect.height,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tooltip card */}
|
||||
{tooltipPos && (
|
||||
<div
|
||||
className="fixed z-[57] w-[360px] bg-white rounded-xl shadow-2xl border border-secondary-200 overflow-hidden"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="tour-step-title"
|
||||
data-testid="onboarding-tooltip"
|
||||
style={{
|
||||
top: tooltipPos.top,
|
||||
left: tooltipPos.left,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* Header with step indicator + close */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-secondary-50 border-b border-secondary-100">
|
||||
<span className="text-xs font-semibold text-primary-600 uppercase tracking-wider">
|
||||
{t('onboarding.stepProgress', 'Schritt {{current}} von {{total}}', {
|
||||
current: step + 1,
|
||||
total: totalSteps,
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="p-1 rounded-md hover:bg-secondary-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400"
|
||||
aria-label={t('onboarding.skip', 'Überspringen')}
|
||||
>
|
||||
<X className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-secondary-100">
|
||||
<div
|
||||
className="h-full bg-primary-500 transition-all duration-300"
|
||||
style={{ width: `${((step + 1) / totalSteps) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
<h3 id="tour-step-title" className="text-lg font-bold text-secondary-900 mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
{targetMissing && (
|
||||
<p className="text-xs text-warning-600 mt-2 italic">
|
||||
{t('onboarding.elementNotFound', 'Dieses Element ist derzeit nicht sichtbar. Klicken Sie auf Weiter, um fortzufahren.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with navigation buttons */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-secondary-50 border-t border-secondary-100">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-sm text-secondary-500 hover:text-secondary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 rounded min-h-touch px-2"
|
||||
>
|
||||
{t('onboarding.skipTour', 'Tour überspringen')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{step > 0 && (
|
||||
<button
|
||||
onClick={prev}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg text-sm font-medium text-secondary-600 hover:bg-secondary-200 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 min-h-touch"
|
||||
aria-label={t('onboarding.back', 'Zurück')}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={2} />
|
||||
{t('onboarding.back', 'Zurück')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-semibold bg-primary-600 text-white hover:bg-primary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch shadow-sm"
|
||||
aria-label={isLastStep ? t('onboarding.finish', 'Fertig') : t('onboarding.next', 'Weiter')}
|
||||
>
|
||||
{isLastStep ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" strokeWidth={2} />
|
||||
{t('onboarding.finish', 'Fertig')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t('onboarding.next', 'Weiter')}
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={2} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOnboardingStore } from '@/store/onboardingStore';
|
||||
import { Sparkles, X, ChevronRight } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* WelcomeDialog — shown on first login when onboarding has not been
|
||||
* completed or skipped. Presents a brief intro and offers to start the
|
||||
* guided tour or skip it.
|
||||
*
|
||||
* Visibility is controlled by the parent component (AppShell). This
|
||||
* component only handles the dialog UI and delegates actions to the
|
||||
* onboarding store.
|
||||
*/
|
||||
|
||||
export interface WelcomeDialogProps {
|
||||
/** Controls whether the dialog is visible. */
|
||||
open: boolean;
|
||||
/** Called when the dialog should close without starting the tour. */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function WelcomeDialog({ open, onClose }: WelcomeDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const startTour = useOnboardingStore((s) => s.start);
|
||||
const skip = useOnboardingStore((s) => s.skip);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleSkip();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEsc);
|
||||
return () => document.removeEventListener('keydown', handleEsc);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleStartTour = () => {
|
||||
startTour();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
skip();
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-secondary-900/60 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="welcome-dialog-title"
|
||||
data-testid="welcome-dialog"
|
||||
>
|
||||
<div className="relative w-full max-w-lg mx-4 bg-white rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Decorative header banner */}
|
||||
<div className="bg-gradient-to-br from-primary-600 to-accent-600 px-8 py-8 text-white relative">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-md hover:bg-white/20 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
|
||||
aria-label={t('onboarding.skip', 'Überspringen')}
|
||||
>
|
||||
<X className="w-5 h-5" strokeWidth={2} />
|
||||
</button>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Sparkles className="w-8 h-8" strokeWidth={2} />
|
||||
<span className="text-sm font-medium uppercase tracking-wider opacity-90">
|
||||
{t('onboarding.welcomeBadge', 'Neu hier?')}
|
||||
</span>
|
||||
</div>
|
||||
<h2 id="welcome-dialog-title" className="text-2xl font-bold">
|
||||
{t('onboarding.welcomeTitle', 'Willkommen bei LeoCRM!')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-8 py-6">
|
||||
<p className="text-secondary-600 text-base leading-relaxed mb-4">
|
||||
{t(
|
||||
'onboarding.welcomeIntro',
|
||||
'LeoCRM ist Ihre zentrale Plattform für Kontaktverwaltung, Kalender, KI-gestützte Automatisierung und mehr. Lernen Sie in einer kurzen Tour die wichtigsten Funktionen kennen.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-6">
|
||||
<FeatureCard
|
||||
icon="🗂️"
|
||||
title={t('onboarding.featureContacts', 'Kontakte')}
|
||||
desc={t('onboarding.featureContactsDesc', 'Verwalten Sie Firmen und Personen')}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="📅"
|
||||
title={t('onboarding.featureCalendar', 'Kalender')}
|
||||
desc={t('onboarding.featureCalendarDesc', 'Termine und Veranstaltungen im Blick')}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🤖"
|
||||
title={t('onboarding.featureAI', 'KI-Assistent')}
|
||||
desc={t('onboarding.featureAIDesc', 'Intelligente Automatisierung')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:justify-end">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="px-5 py-2.5 rounded-lg text-sm font-medium text-secondary-600 hover:bg-secondary-100 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-secondary-400 min-h-touch"
|
||||
>
|
||||
{t('onboarding.skip', 'Überspringen')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStartTour}
|
||||
className="flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg text-sm font-semibold bg-primary-600 text-white hover:bg-primary-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 min-h-touch shadow-sm"
|
||||
>
|
||||
{t('onboarding.startTour', 'Tour starten')}
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({ icon, title, desc }: { icon: string; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center p-3 rounded-lg bg-secondary-50 border border-secondary-100">
|
||||
<span className="text-2xl mb-1" aria-hidden="true">{icon}</span>
|
||||
<span className="text-sm font-semibold text-secondary-800">{title}</span>
|
||||
<span className="text-xs text-secondary-500 mt-0.5">{desc}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user