feat: onboarding wizard for first-time users + UI fixes
- 6-step onboarding wizard (welcome, language, connect providers, view style, work hours, styling) for new signups - Added hasCompletedOnboarding field to User schema - Fixed subtask progress bar in GridTaskBlock (simple/calendar views) - Fixed kanban drag-and-drop to someday area - Fixed weather/provider icon overlap in time grid tasks - Provider icon positioning: inline when weather on, top-right when off v1.56.0 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
452e1a0eab
commit
4ac73d3e03
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "my-weekly-todo-list",
|
||||
"version": "1.55.3",
|
||||
"version": "1.56.0",
|
||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -116,6 +116,7 @@ model User {
|
||||
weatherLon Float?
|
||||
weatherLocation String?
|
||||
weatherRecentCities Json?
|
||||
hasCompletedOnboarding Boolean @default(true)
|
||||
notificationsEnabled Boolean @default(false)
|
||||
somedayLists SomedayList[]
|
||||
tasks Task[]
|
||||
|
||||
@ -80,6 +80,7 @@ export async function POST(request: NextRequest) {
|
||||
emailVerificationCode: code,
|
||||
emailVerificationToken: token,
|
||||
emailVerificationExpires: expires,
|
||||
hasCompletedOnboarding: false,
|
||||
// emailVerified is NOT set — user must verify
|
||||
},
|
||||
select: {
|
||||
|
||||
@ -103,6 +103,7 @@ export async function GET(request: NextRequest) {
|
||||
weatherLocation: true,
|
||||
weatherRecentCities: true,
|
||||
viewSettings: true,
|
||||
hasCompletedOnboarding: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
@ -146,7 +147,8 @@ export async function PATCH(request: NextRequest) {
|
||||
showTaskCheckboxes, dayHeaderGap,
|
||||
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
|
||||
kanbanStages, lightTheme, darkTheme, notificationsEnabled, mobileFontScale,
|
||||
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings
|
||||
weatherEnabled, weatherLat, weatherLon, weatherLocation, weatherRecentCities, viewSettings,
|
||||
hasCompletedOnboarding
|
||||
} = body;
|
||||
|
||||
const updateData: any = {
|
||||
@ -238,6 +240,7 @@ export async function PATCH(request: NextRequest) {
|
||||
...(weatherLocation !== undefined && { weatherLocation }),
|
||||
...(weatherRecentCities !== undefined && { weatherRecentCities }),
|
||||
...(viewSettings !== undefined && { viewSettings }),
|
||||
...(hasCompletedOnboarding !== undefined && { hasCompletedOnboarding }),
|
||||
};
|
||||
if (password && password.trim() !== "") {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
@ -338,6 +341,7 @@ export async function PATCH(request: NextRequest) {
|
||||
weatherLocation: true,
|
||||
weatherRecentCities: true,
|
||||
viewSettings: true,
|
||||
hasCompletedOnboarding: true,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -4861,3 +4861,56 @@ h3 {
|
||||
.time-grid-on .time-column-header {
|
||||
z-index: 45 !important;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ONBOARDING WIZARD
|
||||
============================================ */
|
||||
.onboarding-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
animation: onboarding-appear 0.3s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes onboarding-appear {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
|
||||
.onboarding-step-forward { animation: ob-slide-right 0.25s ease-out; }
|
||||
.onboarding-step-backward { animation: ob-slide-left 0.25s ease-out; }
|
||||
|
||||
@keyframes ob-slide-right {
|
||||
from { opacity: 0; transform: translateX(30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes ob-slide-left {
|
||||
from { opacity: 0; transform: translateX(-30px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 0;
|
||||
max-height: 100vh;
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Repeat, Circle, X } from "lucide-react";
|
||||
import { Repeat, Circle, X, ChevronDown } from "lucide-react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGoogle, faMicrosoft, faApple } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
@ -36,6 +36,7 @@ interface GridTaskBlockProps {
|
||||
projects?: any[];
|
||||
onProjectAssign?: (taskId: string, projectId: string | null) => void;
|
||||
kanbanStages?: KanbanStage[];
|
||||
weatherEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function GridTaskBlock({
|
||||
@ -68,7 +69,8 @@ export function GridTaskBlock({
|
||||
showTaskCheckboxes,
|
||||
projects,
|
||||
onProjectAssign,
|
||||
kanbanStages = []
|
||||
kanbanStages = [],
|
||||
weatherEnabled = false
|
||||
}: GridTaskBlockProps) {
|
||||
const [isNotesOpen, setIsNotesOpen] = useState(false);
|
||||
const [notesValue, setNotesValue] = useState(task.markdownContent || "");
|
||||
@ -285,9 +287,11 @@ export function GridTaskBlock({
|
||||
{task.subTasks && task.subTasks.length > 0 && (() => {
|
||||
const completed = task.subTasks.filter(s => s.completed).length;
|
||||
const total = task.subTasks.length;
|
||||
const allDone = completed === total;
|
||||
const expanded = isSubTasksOpen || isSubTaskInputOpen;
|
||||
const pct = total > 0 ? (completed / total) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsSubTasksOpen(!isSubTasksOpen);
|
||||
@ -297,25 +301,45 @@ export function GridTaskBlock({
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "2px",
|
||||
padding: "1px 5px",
|
||||
borderRadius: "8px",
|
||||
background: expanded ? "rgba(99, 102, 241, 0.15)" : (completed === total) ? "rgba(34, 197, 94, 0.15)" : "rgba(0,0,0,0.06)",
|
||||
color: expanded ? "#6366f1" : (completed === total) ? "#22c55e" : (darkMode ? "#aaa" : "#666"),
|
||||
gap: "4px",
|
||||
padding: "2px 8px 2px 4px",
|
||||
borderRadius: "12px",
|
||||
background: expanded ? "rgba(99,102,241,0.08)" : "transparent",
|
||||
color: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-text-light, #9ca3af)",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.65rem",
|
||||
fontSize: "0.7rem",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
flexShrink: 0,
|
||||
whiteSpace: "nowrap",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ transform: expanded ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.2s" }}>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
{completed}/{total}
|
||||
</div>
|
||||
<ChevronDown size={12} style={{ transform: expanded ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 0.2s", flexShrink: 0 }} />
|
||||
<span style={{
|
||||
display: "inline-block",
|
||||
width: "36px",
|
||||
height: "5px",
|
||||
borderRadius: "3px",
|
||||
background: "var(--weekly-border, #e5e7eb)",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<span style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: `${pct}%`,
|
||||
borderRadius: "3px",
|
||||
background: allDone ? "var(--weekly-teal, #0d9488)" : "var(--weekly-accent, #6366f1)",
|
||||
transition: "width 0.3s ease",
|
||||
}} />
|
||||
</span>
|
||||
<span style={{ opacity: 0.8 }}>{completed}/{total}</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
@ -344,10 +368,7 @@ export function GridTaskBlock({
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
{weatherEnabled && (() => {
|
||||
const provider = task.externalProvider
|
||||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||||
if (!provider) return null;
|
||||
@ -363,13 +384,37 @@ export function GridTaskBlock({
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
title={`Synced with ${info.label}`}
|
||||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "auto", paddingLeft: "4px" }}
|
||||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "3px" }}
|
||||
>
|
||||
<FontAwesomeIcon icon={info.icon} style={{ width: 10, height: 10, color: info.color }} />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!weatherEnabled && (() => {
|
||||
const provider = task.externalProvider
|
||||
|| (task.externalId?.startsWith("synology::") ? "synology" : null);
|
||||
if (!provider) return null;
|
||||
const iconMap: Record<string, { icon: any; color: string; label: string }> = {
|
||||
google: { icon: faGoogle, color: "#4285F4", label: "Google" },
|
||||
outlook: { icon: faMicrosoft, color: "#0078D4", label: "Microsoft" },
|
||||
apple: { icon: faApple, color: "#555", label: "Apple" },
|
||||
synology: { icon: faServer, color: "#007AFF", label: "Synology" },
|
||||
};
|
||||
const info = iconMap[provider];
|
||||
if (!info) return null;
|
||||
return (
|
||||
<span
|
||||
title={`Synced with ${info.label}`}
|
||||
style={{ position: "absolute", top: "3px", right: "4px", display: "inline-flex", alignItems: "center", opacity: 0.45, zIndex: 2 }}
|
||||
>
|
||||
<FontAwesomeIcon icon={info.icon} style={{ width: 10, height: 10, color: info.color }} />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div
|
||||
className="task-actions"
|
||||
|
||||
659
src/components/OnboardingWizard.tsx
Normal file
659
src/components/OnboardingWizard.tsx
Normal file
@ -0,0 +1,659 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { LayoutGrid, CalendarDays, ListTodo, Kanban, ChevronRight, ChevronLeft, X, Sun, Moon, Check } from "lucide-react";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faGoogle, faApple, faMicrosoft } from "@fortawesome/free-brands-svg-icons";
|
||||
import { faServer } from "@fortawesome/free-solid-svg-icons";
|
||||
import FontPicker from "./FontPicker";
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
profile: any;
|
||||
darkMode: boolean;
|
||||
language: string;
|
||||
connections: any[];
|
||||
onComplete: () => void;
|
||||
onSkip: () => void;
|
||||
saveSetting: (key: string, value: any) => Promise<void>;
|
||||
onLanguageChange: (lang: string) => void;
|
||||
onDarkModeToggle: () => void;
|
||||
}
|
||||
|
||||
const STEPS = ["welcome", "language", "connect", "view", "schedule", "style"] as const;
|
||||
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
welcomeTitle: "Welcome to My Weekly ToDo List",
|
||||
welcomeSubtitle: "Let's set up your workspace in under a minute",
|
||||
getStarted: "Get Started",
|
||||
skipSetup: "Skip setup",
|
||||
next: "Next",
|
||||
back: "Back",
|
||||
finish: "Start Using My Weekly ToDo List",
|
||||
// Step 2
|
||||
langTitle: "Language & Region",
|
||||
langSubtitle: "Choose your language and timezone",
|
||||
timezone: "Timezone",
|
||||
// Step 3
|
||||
connectTitle: "Connect Your Services",
|
||||
connectSubtitle: "Sync calendars and tasks from your favorite services",
|
||||
connectGoogle: "Google Calendar & Tasks",
|
||||
connectApple: "Apple Calendar & Reminders",
|
||||
connectOutlook: "Microsoft Outlook",
|
||||
connectSynology: "Synology Calendar",
|
||||
connected: "Connected",
|
||||
connect: "Connect",
|
||||
skipConnect: "Skip for now - you can connect later in Settings",
|
||||
// Step 4
|
||||
viewTitle: "Choose Your View",
|
||||
viewSubtitle: "Pick the layout that fits how you work",
|
||||
viewSimple: "Simple",
|
||||
viewSimpleDesc: "Clean weekly grid with time slots",
|
||||
viewCalendar: "Calendar",
|
||||
viewCalendarDesc: "Google Calendar-style day view",
|
||||
viewList: "List",
|
||||
viewListDesc: "Focused task list without time grid",
|
||||
viewKanban: "Kanban",
|
||||
viewKanbanDesc: "Drag-and-drop board with stages",
|
||||
// Step 5
|
||||
scheduleTitle: "Your Work Hours",
|
||||
scheduleSubtitle: "Set when your day starts and ends",
|
||||
startHour: "Start",
|
||||
endHour: "End",
|
||||
viewDays: "Days per week",
|
||||
// Step 6
|
||||
styleTitle: "Make It Yours",
|
||||
styleSubtitle: "Customize fonts and appearance",
|
||||
headlineFont: "Headline font",
|
||||
bodyFont: "Body font",
|
||||
fontSize: "Font size",
|
||||
darkMode: "Dark mode",
|
||||
},
|
||||
de: {
|
||||
welcomeTitle: "Willkommen bei My Weekly ToDo List",
|
||||
welcomeSubtitle: "Richte deinen Arbeitsbereich in unter einer Minute ein",
|
||||
getStarted: "Los geht's",
|
||||
skipSetup: "Setup uberspringen",
|
||||
next: "Weiter",
|
||||
back: "Zuruck",
|
||||
finish: "My Weekly ToDo List starten",
|
||||
langTitle: "Sprache & Region",
|
||||
langSubtitle: "Wahle deine Sprache und Zeitzone",
|
||||
timezone: "Zeitzone",
|
||||
connectTitle: "Dienste verbinden",
|
||||
connectSubtitle: "Synchronisiere Kalender und Aufgaben",
|
||||
connectGoogle: "Google Kalender & Aufgaben",
|
||||
connectApple: "Apple Kalender & Erinnerungen",
|
||||
connectOutlook: "Microsoft Outlook",
|
||||
connectSynology: "Synology Kalender",
|
||||
connected: "Verbunden",
|
||||
connect: "Verbinden",
|
||||
skipConnect: "Jetzt uberspringen - du kannst spater in den Einstellungen verbinden",
|
||||
viewTitle: "Wahle deine Ansicht",
|
||||
viewSubtitle: "Wahle das Layout, das zu dir passt",
|
||||
viewSimple: "Einfach",
|
||||
viewSimpleDesc: "Ubersichtliches Wochenraster mit Zeitfenstern",
|
||||
viewCalendar: "Kalender",
|
||||
viewCalendarDesc: "Google Calendar-Stil Tagesansicht",
|
||||
viewList: "Liste",
|
||||
viewListDesc: "Fokussierte Aufgabenliste ohne Zeitraster",
|
||||
viewKanban: "Kanban",
|
||||
viewKanbanDesc: "Drag-and-Drop Board mit Spalten",
|
||||
scheduleTitle: "Deine Arbeitszeiten",
|
||||
scheduleSubtitle: "Lege fest, wann dein Tag beginnt und endet",
|
||||
startHour: "Start",
|
||||
endHour: "Ende",
|
||||
viewDays: "Tage pro Woche",
|
||||
styleTitle: "Gestalte es nach deinen Wunschen",
|
||||
styleSubtitle: "Passe Schriftarten und Aussehen an",
|
||||
headlineFont: "Uberschrift-Schrift",
|
||||
bodyFont: "Text-Schrift",
|
||||
fontSize: "Schriftgrosse",
|
||||
darkMode: "Dunkler Modus",
|
||||
},
|
||||
fr: {
|
||||
welcomeTitle: "Bienvenue sur My Weekly ToDo List",
|
||||
welcomeSubtitle: "Configurez votre espace en moins d'une minute",
|
||||
getStarted: "Commencer",
|
||||
skipSetup: "Passer la configuration",
|
||||
next: "Suivant",
|
||||
back: "Retour",
|
||||
finish: "Commencer a utiliser My Weekly ToDo List",
|
||||
langTitle: "Langue & Region",
|
||||
langSubtitle: "Choisissez votre langue et fuseau horaire",
|
||||
timezone: "Fuseau horaire",
|
||||
connectTitle: "Connectez vos services",
|
||||
connectSubtitle: "Synchronisez calendriers et taches",
|
||||
connectGoogle: "Google Agenda & Taches",
|
||||
connectApple: "Calendrier Apple & Rappels",
|
||||
connectOutlook: "Microsoft Outlook",
|
||||
connectSynology: "Calendrier Synology",
|
||||
connected: "Connecte",
|
||||
connect: "Connecter",
|
||||
skipConnect: "Passer pour l'instant - vous pourrez connecter plus tard",
|
||||
viewTitle: "Choisissez votre vue",
|
||||
viewSubtitle: "Selectionnez la mise en page qui vous convient",
|
||||
viewSimple: "Simple",
|
||||
viewSimpleDesc: "Grille hebdomadaire avec creneaux horaires",
|
||||
viewCalendar: "Calendrier",
|
||||
viewCalendarDesc: "Vue journaliere style Google Calendar",
|
||||
viewList: "Liste",
|
||||
viewListDesc: "Liste de taches sans grille horaire",
|
||||
viewKanban: "Kanban",
|
||||
viewKanbanDesc: "Tableau drag-and-drop avec colonnes",
|
||||
scheduleTitle: "Vos horaires de travail",
|
||||
scheduleSubtitle: "Definissez le debut et la fin de votre journee",
|
||||
startHour: "Debut",
|
||||
endHour: "Fin",
|
||||
viewDays: "Jours par semaine",
|
||||
styleTitle: "Personnalisez",
|
||||
styleSubtitle: "Choisissez vos polices et apparence",
|
||||
headlineFont: "Police de titre",
|
||||
bodyFont: "Police de texte",
|
||||
fontSize: "Taille de police",
|
||||
darkMode: "Mode sombre",
|
||||
},
|
||||
es: {
|
||||
welcomeTitle: "Bienvenido a My Weekly ToDo List",
|
||||
welcomeSubtitle: "Configura tu espacio en menos de un minuto",
|
||||
getStarted: "Empezar",
|
||||
skipSetup: "Omitir configuracion",
|
||||
next: "Siguiente",
|
||||
back: "Atras",
|
||||
finish: "Empezar a usar My Weekly ToDo List",
|
||||
langTitle: "Idioma y Region",
|
||||
langSubtitle: "Elige tu idioma y zona horaria",
|
||||
timezone: "Zona horaria",
|
||||
connectTitle: "Conecta tus servicios",
|
||||
connectSubtitle: "Sincroniza calendarios y tareas",
|
||||
connectGoogle: "Google Calendar y Tareas",
|
||||
connectApple: "Calendario Apple y Recordatorios",
|
||||
connectOutlook: "Microsoft Outlook",
|
||||
connectSynology: "Calendario Synology",
|
||||
connected: "Conectado",
|
||||
connect: "Conectar",
|
||||
skipConnect: "Omitir por ahora - puedes conectar despues en Ajustes",
|
||||
viewTitle: "Elige tu vista",
|
||||
viewSubtitle: "Selecciona el diseno que mejor se adapte a ti",
|
||||
viewSimple: "Simple",
|
||||
viewSimpleDesc: "Cuadricula semanal con franjas horarias",
|
||||
viewCalendar: "Calendario",
|
||||
viewCalendarDesc: "Vista diaria estilo Google Calendar",
|
||||
viewList: "Lista",
|
||||
viewListDesc: "Lista de tareas sin cuadricula horaria",
|
||||
viewKanban: "Kanban",
|
||||
viewKanbanDesc: "Tablero arrastrar y soltar con columnas",
|
||||
scheduleTitle: "Tu horario de trabajo",
|
||||
scheduleSubtitle: "Define cuando empieza y termina tu dia",
|
||||
startHour: "Inicio",
|
||||
endHour: "Fin",
|
||||
viewDays: "Dias por semana",
|
||||
styleTitle: "Hazlo tuyo",
|
||||
styleSubtitle: "Personaliza fuentes y apariencia",
|
||||
headlineFont: "Fuente de titulo",
|
||||
bodyFont: "Fuente de texto",
|
||||
fontSize: "Tamano de fuente",
|
||||
darkMode: "Modo oscuro",
|
||||
},
|
||||
};
|
||||
|
||||
const COMMON_TIMEZONES = [
|
||||
"Europe/Berlin", "Europe/London", "Europe/Paris", "Europe/Madrid",
|
||||
"Europe/Rome", "Europe/Amsterdam", "Europe/Zurich", "Europe/Vienna",
|
||||
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Toronto", "America/Sao_Paulo", "America/Mexico_City",
|
||||
"Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Singapore", "Asia/Dubai",
|
||||
"Australia/Sydney", "Pacific/Auckland", "UTC"
|
||||
];
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: "de", label: "Deutsch", flag: "DE" },
|
||||
{ code: "en", label: "English", flag: "EN" },
|
||||
{ code: "fr", label: "Francais", flag: "FR" },
|
||||
{ code: "es", label: "Espanol", flag: "ES" },
|
||||
];
|
||||
|
||||
export default function OnboardingWizard({
|
||||
profile,
|
||||
darkMode,
|
||||
language,
|
||||
connections,
|
||||
onComplete,
|
||||
onSkip,
|
||||
saveSetting,
|
||||
onLanguageChange,
|
||||
onDarkModeToggle,
|
||||
}: OnboardingWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [direction, setDirection] = useState<"forward" | "backward">("forward");
|
||||
const [animKey, setAnimKey] = useState(0);
|
||||
|
||||
// Local state for settings
|
||||
const [selectedLang, setSelectedLang] = useState(language || "de");
|
||||
const [selectedTimezone, setSelectedTimezone] = useState(profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
|
||||
const [selectedView, setSelectedView] = useState(profile.viewStyle || "simple");
|
||||
const [selectedStartHour, setSelectedStartHour] = useState(profile.startHour ?? 8);
|
||||
const [selectedEndHour, setSelectedEndHour] = useState(profile.endHour ?? 18);
|
||||
const [selectedViewDays, setSelectedViewDays] = useState(profile.viewDays ?? 7);
|
||||
const [selectedHeadlineFont, setSelectedHeadlineFont] = useState(profile.headlineFont || "Oswald");
|
||||
const [selectedBodyFont, setSelectedBodyFont] = useState(profile.bodyFont || "Inter");
|
||||
const [selectedFontSize, setSelectedFontSize] = useState(profile.fontSize || "M");
|
||||
|
||||
// Resume from localStorage if returning from OAuth
|
||||
useEffect(() => {
|
||||
const savedStep = localStorage.getItem("onboarding_step");
|
||||
if (savedStep) {
|
||||
setCurrentStep(parseInt(savedStep, 10));
|
||||
localStorage.removeItem("onboarding_step");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const t = translations[selectedLang] || translations.en;
|
||||
|
||||
const goNext = async () => {
|
||||
await saveCurrentStep();
|
||||
setDirection("forward");
|
||||
setAnimKey((k) => k + 1);
|
||||
setCurrentStep((s) => Math.min(s + 1, STEPS.length - 1));
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
setDirection("backward");
|
||||
setAnimKey((k) => k + 1);
|
||||
setCurrentStep((s) => Math.max(s - 1, 0));
|
||||
};
|
||||
|
||||
const saveCurrentStep = async () => {
|
||||
const step = STEPS[currentStep];
|
||||
if (step === "language") {
|
||||
await saveSetting("language", selectedLang);
|
||||
await saveSetting("timezone", selectedTimezone);
|
||||
onLanguageChange(selectedLang);
|
||||
} else if (step === "view") {
|
||||
await saveSetting("viewStyle", selectedView);
|
||||
await saveSetting("showTimeGrid", selectedView === "simple" || selectedView === "calendar");
|
||||
} else if (step === "schedule") {
|
||||
await saveSetting("startHour", selectedStartHour);
|
||||
await saveSetting("endHour", selectedEndHour);
|
||||
await saveSetting("viewDays", selectedViewDays);
|
||||
} else if (step === "style") {
|
||||
await saveSetting("headlineFont", selectedHeadlineFont);
|
||||
await saveSetting("bodyFont", selectedBodyFont);
|
||||
await saveSetting("fontSize", selectedFontSize);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
await saveCurrentStep();
|
||||
onComplete();
|
||||
};
|
||||
|
||||
const connectedProviders = new Set(connections.map((c: any) => c.provider));
|
||||
|
||||
const handleOAuthConnect = (url: string) => {
|
||||
localStorage.setItem("onboarding_step", String(currentStep));
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
const bg = darkMode ? "#1f2937" : "#ffffff";
|
||||
const textColor = darkMode ? "#e5e7eb" : "#333333";
|
||||
const mutedColor = darkMode ? "#9ca3af" : "#6b7280";
|
||||
const borderColor = darkMode ? "#374151" : "#e5e7eb";
|
||||
const accentColor = "#0d9488";
|
||||
const cardBg = darkMode ? "#111827" : "#f9fafb";
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
display: "inline-flex", alignItems: "center", gap: "6px",
|
||||
padding: "10px 24px", borderRadius: "10px", border: "none",
|
||||
fontSize: "0.95rem", fontWeight: 600, cursor: "pointer",
|
||||
transition: "all 0.15s",
|
||||
};
|
||||
const primaryBtn: React.CSSProperties = { ...btnStyle, background: accentColor, color: "#fff" };
|
||||
const secondaryBtn: React.CSSProperties = { ...btnStyle, background: darkMode ? "#374151" : "#f3f4f6", color: textColor };
|
||||
|
||||
const isLastStep = currentStep === STEPS.length - 1;
|
||||
|
||||
const renderStep = () => {
|
||||
const step = STEPS[currentStep];
|
||||
|
||||
if (step === "welcome") {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 30px" }}>
|
||||
<div style={{ fontSize: "3rem", marginBottom: "16px" }}>✓</div>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, margin: "0 0 12px", color: textColor }}>
|
||||
{t.welcomeTitle}
|
||||
</h1>
|
||||
<p style={{ color: mutedColor, fontSize: "1rem", margin: "0 0 32px", lineHeight: 1.5 }}>
|
||||
{t.welcomeSubtitle}
|
||||
</p>
|
||||
<button onClick={goNext} style={primaryBtn}>
|
||||
{t.getStarted} <ChevronRight size={16} />
|
||||
</button>
|
||||
<div style={{ marginTop: "16px" }}>
|
||||
<button onClick={onSkip} style={{ background: "none", border: "none", color: mutedColor, cursor: "pointer", fontSize: "0.85rem", textDecoration: "underline" }}>
|
||||
{t.skipSetup}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "language") {
|
||||
return (
|
||||
<div style={{ padding: "30px" }}>
|
||||
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.langTitle}</h2>
|
||||
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.langSubtitle}</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px", marginBottom: "24px" }}>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => setSelectedLang(lang.code)}
|
||||
style={{
|
||||
padding: "14px 16px", borderRadius: "10px", border: `2px solid ${selectedLang === lang.code ? accentColor : borderColor}`,
|
||||
background: selectedLang === lang.code ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
|
||||
cursor: "pointer", textAlign: "left", display: "flex", alignItems: "center", gap: "10px",
|
||||
color: textColor, fontSize: "0.95rem", fontWeight: selectedLang === lang.code ? 600 : 400,
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 700, fontSize: "0.75rem", background: darkMode ? "#374151" : "#e5e7eb", padding: "2px 6px", borderRadius: "4px" }}>{lang.flag}</span>
|
||||
{lang.label}
|
||||
{selectedLang === lang.code && <Check size={16} style={{ marginLeft: "auto", color: accentColor }} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.timezone}</label>
|
||||
<select
|
||||
value={selectedTimezone}
|
||||
onChange={(e) => setSelectedTimezone(e.target.value)}
|
||||
style={{
|
||||
width: "100%", padding: "10px 12px", borderRadius: "8px", border: `1px solid ${borderColor}`,
|
||||
background: darkMode ? "#111827" : "#fff", color: textColor, fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
{COMMON_TIMEZONES.map((tz) => (
|
||||
<option key={tz} value={tz}>{tz.replace(/_/g, " ")}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "connect") {
|
||||
const providers = [
|
||||
{ key: "google", label: t.connectGoogle, icon: faGoogle, color: "#4285F4", action: () => handleOAuthConnect("/api/calendar/google/start") },
|
||||
{ key: "apple", label: t.connectApple, icon: faApple, color: darkMode ? "#ccc" : "#333", action: () => {} },
|
||||
{ key: "outlook", label: t.connectOutlook, icon: faMicrosoft, color: "#0078D4", action: () => {} },
|
||||
{ key: "synology", label: t.connectSynology, icon: faServer, color: "#007AFF", action: () => {} },
|
||||
];
|
||||
return (
|
||||
<div style={{ padding: "30px" }}>
|
||||
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.connectTitle}</h2>
|
||||
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.connectSubtitle}</p>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
{providers.map((p) => {
|
||||
const isConnected = connectedProviders.has(p.key);
|
||||
return (
|
||||
<div
|
||||
key={p.key}
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "12px",
|
||||
padding: "14px 16px", borderRadius: "10px",
|
||||
border: `1px solid ${isConnected ? accentColor : borderColor}`,
|
||||
background: isConnected ? (darkMode ? "rgba(13,148,136,0.1)" : "rgba(13,148,136,0.04)") : "transparent",
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={p.icon} style={{ width: 20, height: 20, color: p.color }} />
|
||||
<span style={{ flex: 1, fontSize: "0.95rem", color: textColor, fontWeight: 500 }}>{p.label}</span>
|
||||
{isConnected ? (
|
||||
<span style={{ fontSize: "0.8rem", color: accentColor, fontWeight: 600, display: "flex", alignItems: "center", gap: "4px" }}>
|
||||
<Check size={14} /> {t.connected}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={p.action}
|
||||
style={{
|
||||
padding: "6px 16px", borderRadius: "6px", border: `1px solid ${borderColor}`,
|
||||
background: "transparent", color: textColor, fontSize: "0.8rem",
|
||||
fontWeight: 500, cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{t.connect}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ color: mutedColor, fontSize: "0.8rem", marginTop: "16px", textAlign: "center" }}>{t.skipConnect}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "view") {
|
||||
const views = [
|
||||
{ key: "simple", label: t.viewSimple, desc: t.viewSimpleDesc, Icon: LayoutGrid },
|
||||
{ key: "calendar", label: t.viewCalendar, desc: t.viewCalendarDesc, Icon: CalendarDays },
|
||||
{ key: "list", label: t.viewList, desc: t.viewListDesc, Icon: ListTodo },
|
||||
{ key: "kanban", label: t.viewKanban, desc: t.viewKanbanDesc, Icon: Kanban },
|
||||
];
|
||||
return (
|
||||
<div style={{ padding: "30px" }}>
|
||||
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.viewTitle}</h2>
|
||||
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.viewSubtitle}</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "10px" }}>
|
||||
{views.map((v) => {
|
||||
const selected = selectedView === v.key;
|
||||
return (
|
||||
<button
|
||||
key={v.key}
|
||||
onClick={() => setSelectedView(v.key)}
|
||||
style={{
|
||||
padding: "20px 16px", borderRadius: "12px",
|
||||
border: `2px solid ${selected ? accentColor : borderColor}`,
|
||||
background: selected ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
|
||||
cursor: "pointer", textAlign: "center",
|
||||
display: "flex", flexDirection: "column", alignItems: "center", gap: "8px",
|
||||
color: textColor, transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
<v.Icon size={28} strokeWidth={1.5} style={{ color: selected ? accentColor : mutedColor }} />
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>{v.label}</span>
|
||||
<span style={{ fontSize: "0.75rem", color: mutedColor, lineHeight: 1.3 }}>{v.desc}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "schedule") {
|
||||
return (
|
||||
<div style={{ padding: "30px" }}>
|
||||
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.scheduleTitle}</h2>
|
||||
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.scheduleSubtitle}</p>
|
||||
|
||||
<div style={{ display: "flex", gap: "16px", marginBottom: "24px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.startHour}</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<input
|
||||
type="range" min={0} max={12} value={selectedStartHour}
|
||||
onChange={(e) => setSelectedStartHour(parseInt(e.target.value))}
|
||||
style={{ flex: 1, accentColor }}
|
||||
/>
|
||||
<span style={{ fontSize: "1.1rem", fontWeight: 700, color: textColor, minWidth: "40px", textAlign: "center" }}>
|
||||
{String(selectedStartHour).padStart(2, "0")}:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.endHour}</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||
<input
|
||||
type="range" min={12} max={24} value={selectedEndHour}
|
||||
onChange={(e) => setSelectedEndHour(parseInt(e.target.value))}
|
||||
style={{ flex: 1, accentColor }}
|
||||
/>
|
||||
<span style={{ fontSize: "1.1rem", fontWeight: 700, color: textColor, minWidth: "40px", textAlign: "center" }}>
|
||||
{String(selectedEndHour).padStart(2, "0")}:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "10px" }}>{t.viewDays}</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{[5, 7].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setSelectedViewDays(d)}
|
||||
style={{
|
||||
flex: 1, padding: "12px", borderRadius: "10px",
|
||||
border: `2px solid ${selectedViewDays === d ? accentColor : borderColor}`,
|
||||
background: selectedViewDays === d ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
|
||||
color: textColor, fontSize: "1rem", fontWeight: 600, cursor: "pointer",
|
||||
transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "style") {
|
||||
return (
|
||||
<div style={{ padding: "30px" }}>
|
||||
<h2 style={{ fontSize: "1.3rem", fontWeight: 700, margin: "0 0 6px", color: textColor }}>{t.styleTitle}</h2>
|
||||
<p style={{ color: mutedColor, margin: "0 0 24px", fontSize: "0.9rem" }}>{t.styleSubtitle}</p>
|
||||
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.headlineFont}</label>
|
||||
<FontPicker value={selectedHeadlineFont} onChange={setSelectedHeadlineFont} darkMode={darkMode} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "6px" }}>{t.bodyFont}</label>
|
||||
<FontPicker value={selectedBodyFont} onChange={setSelectedBodyFont} darkMode={darkMode} />
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<label style={{ display: "block", fontSize: "0.85rem", fontWeight: 600, color: textColor, marginBottom: "10px" }}>{t.fontSize}</label>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
{(["S", "M", "L"] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setSelectedFontSize(size)}
|
||||
style={{
|
||||
flex: 1, padding: "10px", borderRadius: "8px",
|
||||
border: `2px solid ${selectedFontSize === size ? accentColor : borderColor}`,
|
||||
background: selectedFontSize === size ? (darkMode ? "rgba(13,148,136,0.15)" : "rgba(13,148,136,0.06)") : "transparent",
|
||||
color: textColor, fontSize: size === "S" ? "0.8rem" : size === "M" ? "0.95rem" : "1.1rem",
|
||||
fontWeight: 600, cursor: "pointer", transition: "all 0.15s",
|
||||
}}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 16px", borderRadius: "10px", border: `1px solid ${borderColor}` }}>
|
||||
<span style={{ fontSize: "0.95rem", fontWeight: 500, color: textColor }}>{t.darkMode}</span>
|
||||
<button
|
||||
onClick={onDarkModeToggle}
|
||||
style={{
|
||||
width: "44px", height: "26px", borderRadius: "13px", border: "none",
|
||||
background: darkMode ? accentColor : "#d1d5db", cursor: "pointer",
|
||||
position: "relative", transition: "background 0.2s",
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
width: "20px", height: "20px", borderRadius: "50%",
|
||||
background: "#fff", position: "absolute", top: "3px",
|
||||
left: darkMode ? "21px" : "3px", transition: "left 0.2s",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
}}>
|
||||
{darkMode ? <Moon size={12} color="#333" /> : <Sun size={12} color="#333" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="onboarding-overlay">
|
||||
<div className="onboarding-card" style={{ background: bg, color: textColor }}>
|
||||
{/* Close button */}
|
||||
{currentStep > 0 && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
style={{
|
||||
position: "absolute", top: "12px", right: "12px", background: "none", border: "none",
|
||||
color: mutedColor, cursor: "pointer", padding: "4px", zIndex: 10,
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Step content with animation */}
|
||||
<div
|
||||
key={animKey}
|
||||
className={direction === "forward" ? "onboarding-step-forward" : "onboarding-step-backward"}
|
||||
>
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
{/* Footer with progress + nav (not on welcome) */}
|
||||
{currentStep > 0 && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
padding: "16px 30px 24px", borderTop: `1px solid ${borderColor}`,
|
||||
}}>
|
||||
<button onClick={goBack} style={secondaryBtn}>
|
||||
<ChevronLeft size={16} /> {t.back}
|
||||
</button>
|
||||
|
||||
{/* Progress dots */}
|
||||
<div style={{ display: "flex", gap: "6px" }}>
|
||||
{STEPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: i === currentStep ? "20px" : "8px", height: "8px",
|
||||
borderRadius: "4px", transition: "all 0.3s",
|
||||
background: i === currentStep ? accentColor : i < currentStep ? accentColor : (darkMode ? "#4b5563" : "#d1d5db"),
|
||||
opacity: i < currentStep ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLastStep ? (
|
||||
<button onClick={handleFinish} style={primaryBtn}>
|
||||
{t.finish}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={goNext} style={primaryBtn}>
|
||||
{t.next} <ChevronRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -88,6 +88,7 @@ import SimpleDatePicker from "./SimpleDatePicker";
|
||||
import RecurringTasksManager from "./RecurringTasksManager";
|
||||
export interface RecurringTaskException { id: string; taskId: string; originalDate: string; newDate?: string | null; isCancelled: boolean; createdAt: Date; updatedAt: Date; }
|
||||
import { ImportListModal } from "./ImportListModal";
|
||||
import OnboardingWizard from "./OnboardingWizard";
|
||||
import { getRandomLocalQuote } from "@/lib/quotes";
|
||||
|
||||
// Cookie helpers for per-device settings
|
||||
@ -1822,6 +1823,7 @@ export default function WeeklyView() {
|
||||
|
||||
// Moved state definitions to the top
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"calendar" | "general" | "account" | "styling" | "motivation" | "about"
|
||||
>("general");
|
||||
@ -2236,6 +2238,11 @@ export default function WeeklyView() {
|
||||
if (profileData.showSubHourSlots !== undefined) setShowSubHourSlots(profileData.showSubHourSlots);
|
||||
if (profileData.allDayPosition) setAllDayPosition(profileData.allDayPosition);
|
||||
if (profileData.viewSettings) setViewSettings(profileData.viewSettings);
|
||||
|
||||
// Show onboarding wizard for new users
|
||||
if (profileData.hasCompletedOnboarding === false) {
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@ -6463,8 +6470,11 @@ export default function WeeklyView() {
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/kanban-task", task.id);
|
||||
e.dataTransfer.setData("text/plain", task.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDraggedTask(task);
|
||||
}}
|
||||
onDragEnd={() => setDraggedTask(null)}
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.contentEditable === "true" || target.closest("button") || target.closest(".kanban-card-subtask-list")) return;
|
||||
@ -7280,6 +7290,7 @@ export default function WeeklyView() {
|
||||
projects={projects}
|
||||
onProjectAssign={assignProject}
|
||||
kanbanStages={kanbanStages}
|
||||
weatherEnabled={effectiveWeatherEnabled}
|
||||
/>
|
||||
))}
|
||||
{visibleSlots.map((slot) => {
|
||||
@ -7369,7 +7380,7 @@ export default function WeeklyView() {
|
||||
if (d.includes("humidity") && w.humidity != null) parts.push(`💧${w.humidity}%`);
|
||||
if (d.includes("uv") && w.uv != null && w.uv > 0) parts.push(`UV${w.uv}`);
|
||||
return (
|
||||
<div style={{ position: "absolute", top: "1px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", opacity: 0.7, pointerEvents: "none", zIndex: 1, lineHeight: 1, color: darkMode ? "#aaa" : "#555" }}>
|
||||
<div className="weather-chip" style={{ position: "absolute", top: "2px", right: "3px", display: "flex", alignItems: "center", gap: "2px", fontSize: "10px", pointerEvents: "none", zIndex: 20, lineHeight: 1, color: darkMode ? "#bbb" : "#555", background: darkMode ? "rgba(17,17,17,0.9)" : "rgba(255,255,255,0.92)", padding: "1px 5px", borderRadius: "6px", backdropFilter: "blur(4px)", boxShadow: "0 0 0 1px " + (darkMode ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)") }}>
|
||||
{d.includes("icon") && <span>{getWeatherIcon(w.code)}</span>}
|
||||
{parts.length > 0 && <span style={{ fontWeight: 600 }}>{parts.join(" ")}</span>}
|
||||
</div>
|
||||
@ -8879,6 +8890,21 @@ export default function WeeklyView() {
|
||||
)
|
||||
}
|
||||
|
||||
{/* Onboarding Wizard */}
|
||||
{showOnboarding && (
|
||||
<OnboardingWizard
|
||||
profile={profile}
|
||||
darkMode={darkMode}
|
||||
language={language}
|
||||
connections={connections}
|
||||
onComplete={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
|
||||
onSkip={() => { saveSetting("hasCompletedOnboarding", true); setShowOnboarding(false); }}
|
||||
saveSetting={saveSetting}
|
||||
onLanguageChange={(lang: string) => { setLanguage(lang); }}
|
||||
onDarkModeToggle={() => setDarkMode(!darkMode)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
selectedTaskForNotes && (
|
||||
<NotesSidebar
|
||||
@ -9725,7 +9751,7 @@ function TaskItem({
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
title={`Synced with ${info.label}`}
|
||||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, marginLeft: "auto", paddingLeft: "4px" }}
|
||||
style={{ display: "inline-flex", alignItems: "center", opacity: 0.55, paddingLeft: "4px" }}
|
||||
>
|
||||
<FontAwesomeIcon icon={info.icon} style={{ width: 12, height: 12, color: info.color }} />
|
||||
</span>
|
||||
|
||||
@ -136,5 +136,16 @@ export const authOptions: NextAuthOptions = {
|
||||
return session;
|
||||
}
|
||||
},
|
||||
events: {
|
||||
async createUser({ user }) {
|
||||
// New OAuth users should see the onboarding wizard
|
||||
if (user.id) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { hasCompletedOnboarding: false },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user