From 7201383b125b566b8e92b6d375e42c347dfa0634 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Thu, 12 Feb 2026 17:22:15 +0900 Subject: [PATCH 1/3] refactor: move onboarding into main app flow - Remove separate onboarding route, integrate into main layout - Add onboarding body component for in-app onboarding flow - Add calendar and folder-location onboarding steps - Consolidate onboarding config, login, permissions components - Remove useOnboardingState hook, welcome.tsx - Update routes and store initialization Co-authored-by: Cursor --- apps/api/AGENTS.md | 2 +- apps/desktop/src-tauri/src/lib.rs | 16 +- .../src/components/main/body/index.tsx | 82 ++++-- .../components/main/body/onboarding/index.tsx | 200 ++++++++++++++ .../src/components/main/body/shared.tsx | 45 ++-- .../src/components/main/sidebar/devtool.tsx | 9 +- .../src/components/main/sidebar/index.tsx | 2 +- .../src/components/onboarding/calendar.tsx | 114 ++++++++ .../src/components/onboarding/config.tsx | 105 ++++---- .../src/components/onboarding/final.tsx | 162 ++---------- .../components/onboarding/folder-location.tsx | 83 ++++++ .../src/components/onboarding/login.tsx | 247 ++++++++++++++---- .../src/components/onboarding/permissions.tsx | 171 +++++------- .../src/components/onboarding/shared.tsx | 133 +++++----- .../src/components/onboarding/welcome.tsx | 51 ---- .../configure/apple/calendar-selection.tsx | 2 +- .../settings/calendar/configure/shared.tsx | 79 ++++-- apps/desktop/src/hooks/useOnboardingState.ts | 11 - apps/desktop/src/routeTree.gen.ts | 57 +--- .../src/routes/app/main/_layout.index.tsx | 13 +- apps/desktop/src/routes/app/main/_layout.tsx | 8 +- .../routes/app/onboarding/_layout.index.tsx | 91 ------- .../src/routes/app/onboarding/_layout.tsx | 13 - .../src/store/tinybase/store/initialize.ts | 15 -- apps/desktop/src/store/zustand/tabs/schema.ts | 7 +- plugins/windows/js/bindings.gen.ts | 4 +- plugins/windows/src/events.rs | 4 - plugins/windows/src/tab/mod.rs | 2 + plugins/windows/src/window/v1.rs | 14 - 29 files changed, 984 insertions(+), 758 deletions(-) create mode 100644 apps/desktop/src/components/main/body/onboarding/index.tsx create mode 100644 apps/desktop/src/components/onboarding/calendar.tsx create mode 100644 apps/desktop/src/components/onboarding/folder-location.tsx delete mode 100644 apps/desktop/src/components/onboarding/welcome.tsx delete mode 100644 apps/desktop/src/hooks/useOnboardingState.ts delete mode 100644 apps/desktop/src/routes/app/onboarding/_layout.index.tsx delete mode 100644 apps/desktop/src/routes/app/onboarding/_layout.tsx diff --git a/apps/api/AGENTS.md b/apps/api/AGENTS.md index 7d639a827e..0f6fe28c2c 100644 --- a/apps/api/AGENTS.md +++ b/apps/api/AGENTS.md @@ -6,5 +6,5 @@ infisical export \ --format=dotenv \ --output-file="$REPO/apps/api/.env" \ --projectId=87dad7b5-72a6-4791-9228-b3b86b169db1 \ - --path="/api" + --path="/ai" ``` diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 1d076e79f7..8ca483ecba 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -242,13 +242,7 @@ pub async fn main() { { let app_handle = app.handle().clone(); - if app.get_onboarding_needed().unwrap_or(true) { - AppWindow::Main.hide(&app_handle).unwrap(); - AppWindow::Onboarding.show(&app_handle).unwrap(); - } else { - AppWindow::Onboarding.destroy(&app_handle).unwrap(); - AppWindow::Main.show(&app_handle).unwrap(); - } + AppWindow::Main.show(&app_handle).unwrap(); } #[cfg(target_os = "macos")] @@ -269,13 +263,7 @@ pub async fn main() { app.run(move |app, event| match event { #[cfg(target_os = "macos")] tauri::RunEvent::Reopen { .. } => { - if app.get_onboarding_needed().unwrap_or(true) { - AppWindow::Main.hide(&app).unwrap(); - AppWindow::Onboarding.show(&app).unwrap(); - } else { - AppWindow::Onboarding.destroy(&app).unwrap(); - AppWindow::Main.show(&app).unwrap(); - } + AppWindow::Main.show(&app).unwrap(); } #[cfg(target_os = "macos")] tauri::RunEvent::ExitRequested { api, .. } => { diff --git a/apps/desktop/src/components/main/body/index.tsx b/apps/desktop/src/components/main/body/index.tsx index 03f8a71e47..bad06e89c4 100644 --- a/apps/desktop/src/components/main/body/index.tsx +++ b/apps/desktop/src/components/main/body/index.tsx @@ -52,6 +52,7 @@ import { import { loadExtensionPanels } from "./extensions/registry"; import { TabContentFolder, TabItemFolder } from "./folders"; import { TabContentHuman, TabItemHuman } from "./humans"; +import { TabContentOnboarding, TabItemOnboarding } from "./onboarding"; import { Search } from "./search"; import { TabContentNote, TabItemNote } from "./sessions"; import { useCaretPosition } from "./sessions/caret-position-context"; @@ -89,6 +90,9 @@ function Header({ tabs }: { tabs: Tab[] }) { const { leftsidebar } = useShell(); const isLinux = platform() === "linux"; const notifications = useNotifications(); + const currentTab = useTabs((state) => state.currentTab); + const isOnboarding = currentTab?.type === "onboarding"; + const isSidebarHidden = isOnboarding || !leftsidebar.expanded; const { select, close, @@ -168,11 +172,11 @@ function Header({ tabs }: { tabs: Tab[] }) { data-tauri-drag-region className={cn([ "w-full h-9 flex items-center", - !leftsidebar.expanded && (isLinux ? "pl-3" : "pl-18"), + isSidebarHidden && (isLinux ? "pl-3" : "pl-20"), ])} > - {!leftsidebar.expanded && isLinux && } - {!leftsidebar.expanded && ( + {isSidebarHidden && isLinux && } + {!leftsidebar.expanded && !isOnboarding && (
@@ -197,24 +201,26 @@ function Header({ tabs }: { tabs: Tab[] }) {
)} -
- - -
+ {!isOnboarding && ( +
+ + +
+ )} {listeningTab && (
@@ -307,11 +313,15 @@ function Header({ tabs }: { tabs: Tab[] }) { > {!isSearchManuallyExpanded && ( @@ -319,7 +329,9 @@ function Header({ tabs }: { tabs: Tab[] }) {
- + {!isOnboarding && ( + + )}
@@ -566,6 +578,20 @@ function TabItem({ /> ); } + if (tab.type === "onboarding") { + return ( + + ); + } return null; } @@ -616,6 +642,9 @@ function ContentWrapper({ tab }: { tab: Tab }) { if (tab.type === "chat") { return ; } + if (tab.type === "onboarding") { + return ; + } return null; } @@ -652,7 +681,8 @@ function TabChatButton({ if ( currentTab?.type === "ai" || currentTab?.type === "settings" || - currentTab?.type === "chat" + currentTab?.type === "chat" || + currentTab?.type === "onboarding" ) { return null; } diff --git a/apps/desktop/src/components/main/body/onboarding/index.tsx b/apps/desktop/src/components/main/body/onboarding/index.tsx new file mode 100644 index 0000000000..ecdb3c5f20 --- /dev/null +++ b/apps/desktop/src/components/main/body/onboarding/index.tsx @@ -0,0 +1,200 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { platform } from "@tauri-apps/plugin-os"; +import { Volume2Icon, VolumeXIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { commands as sfxCommands } from "@hypr/plugin-sfx"; + +import { usePermissions } from "../../../../hooks/usePermissions"; +import { type Tab, useTabs } from "../../../../store/zustand/tabs"; +import { CalendarSection } from "../../../onboarding/calendar"; +import { + getInitialStep, + getNextStep, + getPrevStep, + getStepStatus, +} from "../../../onboarding/config"; +import { FinalSection, finishOnboarding } from "../../../onboarding/final"; +import { FolderLocationSection } from "../../../onboarding/folder-location"; +import { LoginSection } from "../../../onboarding/login"; +import { PermissionsSection } from "../../../onboarding/permissions"; +import { OnboardingSection } from "../../../onboarding/shared"; +import { StandardTabWrapper } from "../index"; +import { type TabItem, TabItemBase } from "../shared"; + +export const TabItemOnboarding: TabItem< + Extract +> = ({ + tab, + tabIndex, + handleCloseThis, + handleSelectThis, + handleCloseOthers, + handleCloseAll, + handlePinThis, + handleUnpinThis, +}) => { + return ( + 👋} + title="Welcome" + selected={tab.active} + allowPin={false} + allowClose={false} + tabIndex={tabIndex} + handleCloseThis={() => handleCloseThis(tab)} + handleSelectThis={() => handleSelectThis(tab)} + handleCloseOthers={handleCloseOthers} + handleCloseAll={handleCloseAll} + handlePinThis={() => handlePinThis(tab)} + handleUnpinThis={() => handleUnpinThis(tab)} + /> + ); +}; + +export function TabContentOnboarding({ + tab: _tab, +}: { + tab: Extract; +}) { + const queryClient = useQueryClient(); + const close = useTabs((state) => state.close); + const currentTab = useTabs((state) => state.currentTab); + const [isMuted, setIsMuted] = useState(false); + const [currentStep, setCurrentStep] = useState(getInitialStep); + + const isMacOS = platform() === "macos"; + + const { + micPermissionStatus, + systemAudioPermissionStatus, + accessibilityPermissionStatus, + } = usePermissions(); + + const allPermissionsGranted = + micPermissionStatus.data === "authorized" && + systemAudioPermissionStatus.data === "authorized" && + accessibilityPermissionStatus.data === "authorized"; + + useEffect(() => { + if (currentStep === "permissions" && allPermissionsGranted) { + setCurrentStep("login"); + } + }, [currentStep, allPermissionsGranted]); + + const goNext = useCallback(() => { + const next = getNextStep(currentStep); + if (next) setCurrentStep(next); + }, [currentStep]); + + const goBack = useCallback(() => { + const prev = getPrevStep(currentStep); + if (prev) setCurrentStep(prev); + }, [currentStep]); + + useEffect(() => { + void analyticsCommands.event({ + event: "onboarding_step_viewed", + step: currentStep, + platform: platform(), + }); + }, [currentStep]); + + useEffect(() => { + sfxCommands + .play("BGM") + .then(() => sfxCommands.setVolume("BGM", 0.2)) + .catch(console.error); + return () => { + sfxCommands.stop("BGM").catch(console.error); + }; + }, []); + + useEffect(() => { + sfxCommands.setVolume("BGM", isMuted ? 0 : 0.2).catch(console.error); + }, [isMuted]); + + const onFinish = useCallback(() => { + void queryClient.invalidateQueries({ queryKey: ["onboarding-needed"] }); + if (currentTab) { + close(currentTab); + } + }, [close, currentTab, queryClient]); + + return ( + +
+ + +
+

+ Welcome to Hyprnote +

+ + {isMacOS && ( + + + + )} + + + + + + {isMacOS && ( + + + + )} + + + + + + void finishOnboarding(onFinish)} + > + + +
+
+
+ ); +} diff --git a/apps/desktop/src/components/main/body/shared.tsx b/apps/desktop/src/components/main/body/shared.tsx index 04de9c54d3..a594f4a112 100644 --- a/apps/desktop/src/components/main/body/shared.tsx +++ b/apps/desktop/src/components/main/body/shared.tsx @@ -70,6 +70,7 @@ type TabItemBaseProps = { finalizing?: boolean; pinned?: boolean; allowPin?: boolean; + allowClose?: boolean; isEmptyTab?: boolean; tabIndex?: number; accent?: TabAccent; @@ -96,6 +97,7 @@ export function TabItemBase({ finalizing = false, pinned = false, allowPin = true, + allowClose = true, isEmptyTab = false, tabIndex, accent = "neutral", @@ -158,15 +160,15 @@ export function TabItemBase({ }, [isConfirmationOpen, handleConfirmClose]); const handleMouseDown = (e: React.MouseEvent) => { - if (e.button === 1 && !active) { + if (e.button === 1 && !active && allowClose) { e.preventDefault(); e.stopPropagation(); handleCloseThis(); } }; - const contextMenu = - active || (selected && !isEmptyTab) + const contextMenu = allowClose + ? active || (selected && !isEmptyTab) ? [ { id: "close-tab", text: "Close", action: handleAttemptClose }, ...(allowPin @@ -202,7 +204,8 @@ export function TabItemBase({ : { id: "pin-tab", text: "Pin tab", action: handlePinThis }, ] : []), - ]; + ] + : []; const showShortcut = isCmdPressed && tabIndex !== undefined; @@ -258,25 +261,27 @@ export function TabItemBase({ icon )} -
- -
+ + + )} {title} diff --git a/apps/desktop/src/components/main/sidebar/devtool.tsx b/apps/desktop/src/components/main/sidebar/devtool.tsx index 1dd0159861..5a2df5a750 100644 --- a/apps/desktop/src/components/main/sidebar/devtool.tsx +++ b/apps/desktop/src/components/main/sidebar/devtool.tsx @@ -247,13 +247,8 @@ function NavigationCard() { }, []); const handleShowOnboarding = useCallback(() => { - void windowsCommands.windowShow({ type: "onboarding" }).then(() => { - void windowsCommands.windowEmitNavigate( - { type: "onboarding" }, - { path: "/app/onboarding", search: {} }, - ); - }); - }, []); + openNew({ type: "onboarding" }); + }, [openNew]); const handleShowControl = useCallback(() => { void windowsCommands.windowShow({ type: "control" }); diff --git a/apps/desktop/src/components/main/sidebar/index.tsx b/apps/desktop/src/components/main/sidebar/index.tsx index 4dc7d3b4a6..167b52e220 100644 --- a/apps/desktop/src/components/main/sidebar/index.tsx +++ b/apps/desktop/src/components/main/sidebar/index.tsx @@ -45,7 +45,7 @@ export function LeftSidebar() { className={cn([ "flex flex-row items-center", "w-full h-9 py-1", - isLinux ? "pl-3 justify-between" : "pl-18 justify-end", + isLinux ? "pl-3 justify-between" : "pl-20 justify-end", "shrink-0", "rounded-xl bg-neutral-50", ])} diff --git a/apps/desktop/src/components/onboarding/calendar.tsx b/apps/desktop/src/components/onboarding/calendar.tsx new file mode 100644 index 0000000000..f1d0944ebc --- /dev/null +++ b/apps/desktop/src/components/onboarding/calendar.tsx @@ -0,0 +1,114 @@ +import { Icon } from "@iconify-icon/react"; +import type { ReactNode } from "react"; +import { useState } from "react"; + +import { OutlookIcon } from "@hypr/ui/components/icons/outlook"; +import { cn } from "@hypr/utils"; + +import { usePermission } from "../../hooks/usePermissions"; +import { useAppleCalendarSelection } from "../settings/calendar/configure/apple/calendar-selection"; +import { SyncProvider } from "../settings/calendar/configure/apple/context"; +import { AccessPermissionRow } from "../settings/calendar/configure/apple/permission"; +import { CalendarSelection } from "../settings/calendar/configure/shared"; + +const PROVIDERS = [ + { + id: "apple", + label: "Apple", + icon: , + disabled: false, + }, + { + id: "google", + label: "Google", + icon: , + disabled: true, + }, + { + id: "outlook", + label: "Outlook", + icon: , + disabled: true, + }, +] as const satisfies readonly { + id: string; + label: string; + icon: ReactNode; + disabled: boolean; +}[]; + +type ProviderId = (typeof PROVIDERS)[number]["id"]; + +function BareAppleCalendarSelection() { + const { groups, handleToggle } = useAppleCalendarSelection(); + return ; +} + +export function CalendarSection({ onContinue }: { onContinue: () => void }) { + const [provider, setProvider] = useState("apple"); + const calendar = usePermission("calendar"); + const contacts = usePermission("contacts"); + + return ( +
+
+ {PROVIDERS.map((p) => ( + + ))} +
+ + {provider === "apple" && ( +
+
+ + +
+ + + + +
+ )} + + +
+ ); +} diff --git a/apps/desktop/src/components/onboarding/config.tsx b/apps/desktop/src/components/onboarding/config.tsx index 8e10672fe3..330c74722e 100644 --- a/apps/desktop/src/components/onboarding/config.tsx +++ b/apps/desktop/src/components/onboarding/config.tsx @@ -1,64 +1,55 @@ -import type { ComponentType } from "react"; - -import type { Search } from "../../routes/app/onboarding/_layout.index"; -import { Final, STEP_ID_FINAL } from "./final"; -import { Login, STEP_ID_LOGIN } from "./login"; -import { Permissions, STEP_ID_PERMISSIONS } from "./permissions"; -import { STEP_ID_WELCOME, Welcome } from "./welcome"; - -export type NavigateTarget = Search; - -export type StepProps = { - onNavigate: (ctx: NavigateTarget) => void; -}; +import { platform } from "@tauri-apps/plugin-os"; + +import type { SectionStatus } from "./shared"; + +export type OnboardingStep = + | "permissions" + | "login" + | "calendar" + | "folder-location" + | "final"; + +const STEPS_MACOS: OnboardingStep[] = [ + "permissions", + "login", + "calendar", + "folder-location", + "final", +]; +const STEPS_OTHER: OnboardingStep[] = ["login", "folder-location", "final"]; -export function getNext(ctx: Search): Search["step"] | null { - switch (ctx.step) { - case STEP_ID_WELCOME: - if (ctx.skipLogin) { - return ctx.platform === "macos" ? STEP_ID_PERMISSIONS : STEP_ID_FINAL; - } - return STEP_ID_LOGIN; - case STEP_ID_LOGIN: - return ctx.platform === "macos" ? STEP_ID_PERMISSIONS : STEP_ID_FINAL; - case STEP_ID_PERMISSIONS: - return STEP_ID_FINAL; - case STEP_ID_FINAL: - return null; - } +export function getOnboardingSteps(): OnboardingStep[] { + return platform() === "macos" ? STEPS_MACOS : STEPS_OTHER; } -export function getBack(ctx: Search): Search["step"] | null { - switch (ctx.step) { - case STEP_ID_WELCOME: - return null; - case STEP_ID_LOGIN: - return STEP_ID_WELCOME; - case STEP_ID_PERMISSIONS: - return ctx.skipLogin ? STEP_ID_WELCOME : STEP_ID_LOGIN; - case STEP_ID_FINAL: - if (ctx.platform === "macos") { - return STEP_ID_PERMISSIONS; - } - return ctx.skipLogin ? STEP_ID_WELCOME : STEP_ID_LOGIN; - } +export function getInitialStep(): OnboardingStep { + return getOnboardingSteps()[0]; } -type StepConfig = { - id: Search["step"]; - component: ComponentType; -}; +export function getNextStep( + currentStep: OnboardingStep, +): OnboardingStep | null { + const steps = getOnboardingSteps(); + const idx = steps.indexOf(currentStep); + return idx < steps.length - 1 ? steps[idx + 1] : null; +} -export const STEP_IDS = [ - STEP_ID_WELCOME, - STEP_ID_LOGIN, - STEP_ID_PERMISSIONS, - STEP_ID_FINAL, -] as const; +export function getPrevStep( + currentStep: OnboardingStep, +): OnboardingStep | null { + const steps = getOnboardingSteps(); + const idx = steps.indexOf(currentStep); + return idx > 0 ? steps[idx - 1] : null; +} -export const STEP_CONFIGS: StepConfig[] = [ - { id: STEP_ID_WELCOME, component: Welcome }, - { id: STEP_ID_LOGIN, component: Login }, - { id: STEP_ID_PERMISSIONS, component: Permissions }, - { id: STEP_ID_FINAL, component: Final }, -]; +export function getStepStatus( + step: OnboardingStep, + currentStep: OnboardingStep, +): SectionStatus { + const steps = getOnboardingSteps(); + const stepIdx = steps.indexOf(step); + const currentIdx = steps.indexOf(currentStep); + if (stepIdx < currentIdx) return "completed"; + if (stepIdx === currentIdx) return "active"; + return "upcoming"; +} diff --git a/apps/desktop/src/components/onboarding/final.tsx b/apps/desktop/src/components/onboarding/final.tsx index c9367c5f83..fccfcc1d0e 100644 --- a/apps/desktop/src/components/onboarding/final.tsx +++ b/apps/desktop/src/components/onboarding/final.tsx @@ -1,156 +1,50 @@ -import * as Sentry from "@sentry/react"; -import { CheckCircle2Icon, Loader2Icon } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; - -import { - canStartTrial as canStartTrialApi, - startTrial, -} from "@hypr/api-client"; -import { createClient } from "@hypr/api-client/client"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; +import { commands as openerCommands } from "@hypr/plugin-opener2"; import { commands as sfxCommands } from "@hypr/plugin-sfx"; -import { commands as windowsCommands } from "@hypr/plugin-windows"; -import { useAuth } from "../../auth"; -import { env } from "../../env"; -import { Route } from "../../routes/app/onboarding/_layout.index"; -import * as settings from "../../store/tinybase/store/settings"; import { commands } from "../../types/tauri.gen"; -import { configureProSettings } from "../../utils"; -import { getBack, type StepProps } from "./config"; -import { OnboardingContainer } from "./shared"; - -export const STEP_ID_FINAL = "final" as const; - -export function Final({ onNavigate }: StepProps) { - const search = Route.useSearch(); - const auth = useAuth(); - const store = settings.UI.useStore(settings.STORE_ID); - const [isLoading, setIsLoading] = useState(true); - const [trialStarted, setTrialStarted] = useState(false); - const hasHandledRef = useRef(false); - - const backStep = getBack(search); - - useEffect(() => { - if (hasHandledRef.current) { - return; - } - hasHandledRef.current = true; - - const handle = async () => { - if (!auth?.session) { - setIsLoading(false); - return; - } - const headers = auth.getHeaders(); - if (!headers) { - setIsLoading(false); - return; - } - - try { - const started = await tryStartTrial(headers, store); - setTrialStarted(started); - if (started) { - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - await auth.refreshSession(); - } catch (e) { - Sentry.captureException(e); - console.error(e); - } - - setIsLoading(false); - }; - - void handle(); - }, [auth, store]); - - if (isLoading) { - return ( - onNavigate({ ...search, step: backStep }) : undefined - } - > -
- -
-
- ); - } +const SOCIALS = [ + { label: "Discord", url: "https://discord.gg/CX8gTH2tj9" }, + { label: "GitHub", url: "https://github.com/fastrepl/hyprnote" }, + { label: "X", url: "https://x.com/tryhyprnote" }, +] as const; +export function FinalSection({ onFinish }: { onFinish: () => void }) { return ( - onNavigate({ ...search, step: backStep }) : undefined - } - > -
- +
+
+

+ Join our community and stay updated: +

+
+ {SOCIALS.map(({ label, url }) => ( + + ))} +
- +
); } -async function tryStartTrial( - headers: Record, - store: Parameters[0] | undefined, -) { - const client = createClient({ baseUrl: env.VITE_API_URL, headers }); - const { data } = await canStartTrialApi({ client }); - - if (!data?.canStartTrial) { - return false; - } - - const { data: startData, error } = await startTrial({ - client, - query: { interval: "monthly" }, - }); - - if (error || !startData?.started) { - Sentry.captureMessage("Trial start failed", { - level: "warning", - extra: { error }, - }); - return false; - } - - if (store) { - configureProSettings(store); - } - - void analyticsCommands.event({ event: "trial_started", plan: "pro" }); - const trialEndDate = new Date(); - trialEndDate.setDate(trialEndDate.getDate() + 14); - void analyticsCommands.setProperties({ - set: { plan: "pro", trial_end_date: trialEndDate.toISOString() }, - }); - - return true; -} - -async function finishOnboarding() { +export async function finishOnboarding(onFinish?: () => void) { await sfxCommands.stop("BGM").catch(console.error); await new Promise((resolve) => setTimeout(resolve, 100)); await commands.setOnboardingNeeded(false).catch(console.error); await new Promise((resolve) => setTimeout(resolve, 100)); await analyticsCommands.event({ event: "onboarding_completed" }); - await windowsCommands.windowShow({ type: "main" }); - await new Promise((resolve) => setTimeout(resolve, 100)); - await windowsCommands.windowDestroy({ type: "onboarding" }); + onFinish?.(); } diff --git a/apps/desktop/src/components/onboarding/folder-location.tsx b/apps/desktop/src/components/onboarding/folder-location.tsx new file mode 100644 index 0000000000..ec61c0bb52 --- /dev/null +++ b/apps/desktop/src/components/onboarding/folder-location.tsx @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { open as selectFolder } from "@tauri-apps/plugin-dialog"; +import { FolderIcon } from "lucide-react"; + +import { commands as openerCommands } from "@hypr/plugin-opener2"; +import { commands as settingsCommands } from "@hypr/plugin-settings"; + +export function FolderLocationSection({ + onContinue, +}: { + onContinue: () => void; +}) { + const queryClient = useQueryClient(); + + const { data: vaultBase } = useQuery({ + queryKey: ["vault-base-path"], + queryFn: async () => { + const result = await settingsCommands.vaultBase(); + if (result.status === "error") { + throw new Error(result.error); + } + return result.data; + }, + }); + + const changeMutation = useMutation({ + mutationFn: async (newPath: string) => { + const result = await settingsCommands.changeVaultBase(newPath); + if (result.status === "error") { + throw new Error(result.error); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["vault-base-path"] }); + }, + }); + + const handleChange = async () => { + const selected = await selectFolder({ + title: "Choose storage location", + directory: true, + multiple: false, + defaultPath: vaultBase ?? undefined, + }); + + if (selected) { + changeMutation.mutate(selected); + } + }; + + const handleOpenPath = () => { + if (vaultBase) { + openerCommands.openPath(vaultBase, null); + } + }; + + return ( +
+
+ + + + +
+
+ ); +} diff --git a/apps/desktop/src/components/onboarding/login.tsx b/apps/desktop/src/components/onboarding/login.tsx index 65f05de572..3115d08a74 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -1,34 +1,206 @@ +import * as Sentry from "@sentry/react"; +import { CheckCircle2Icon, Loader2Icon, XCircleIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { startTrial } from "@hypr/api-client"; +import { createClient } from "@hypr/api-client/client"; +import { commands as analyticsCommands } from "@hypr/plugin-analytics"; + import { useAuth } from "../../auth"; -import { Route } from "../../routes/app/onboarding/_layout.index"; -import { getBack, getNext, type StepProps } from "./config"; -import { Divider, OnboardingContainer } from "./shared"; +import { useBillingAccess } from "../../billing"; +import { env } from "../../env"; +import * as settings from "../../store/tinybase/store/settings"; +import { configureProSettings } from "../../utils"; +import { Divider } from "./shared"; -export const STEP_ID_LOGIN = "login" as const; +type TrialPhase = + | { step: "checking" } + | { step: "starting" } + | { + step: "done"; + result: + | "started" + | "not-eligible" + | "failed" + | "already-pro" + | "already-trialing"; + }; -export function Login({ onNavigate }: StepProps) { - const search = Route.useSearch(); - const auth = useAutoSignIn(); +function StepRow({ + status, + label, +}: { + status: "done" | "active" | "failed"; + label: string; +}) { + return ( +
+ {status === "done" && ( + + )} + {status === "active" && ( + + )} + {status === "failed" && } + + {label} + +
+ ); +} + +export function LoginSection({ onComplete }: { onComplete: () => void }) { + const auth = useAuth(); + const billing = useBillingAccess(); + const store = settings.UI.useStore(settings.STORE_ID); const [callbackUrl, setCallbackUrl] = useState(""); + const [trialPhase, setTrialPhase] = useState(null); + const hasHandledRef = useRef(false); - usePostAuthNavigation(onNavigate); + useEffect(() => { + if (hasHandledRef.current || !auth?.session) return; - const backStep = getBack(search); + setTrialPhase((prev) => prev ?? { step: "checking" }); - return ( - onNavigate({ ...search, step: backStep }) : undefined + const billingReady = + billing.isPro || billing.isTrialing || !billing.canStartTrial.isPending; + if (!billingReady) return; + + hasHandledRef.current = true; + + if (billing.isPro && !billing.isTrialing) { + if (store) configureProSettings(store); + setTrialPhase({ step: "done", result: "already-pro" }); + setTimeout(onComplete, 1500); + return; + } + + if (billing.isTrialing) { + if (store) configureProSettings(store); + setTrialPhase({ step: "done", result: "already-trialing" }); + setTimeout(onComplete, 1500); + return; + } + + if (!billing.canStartTrial.data) { + setTrialPhase({ step: "done", result: "not-eligible" }); + onComplete(); + return; + } + + const handle = async () => { + const headers = auth.getHeaders(); + if (!headers) { + onComplete(); + return; + } + + const client = createClient({ baseUrl: env.VITE_API_URL, headers }); + setTrialPhase({ step: "starting" }); + + try { + const { data: startData, error } = await startTrial({ + client, + query: { interval: "monthly" }, + }); + + if (error || !startData?.started) { + Sentry.captureMessage("Trial start failed", { + level: "warning", + extra: { error }, + }); + setTrialPhase({ step: "done", result: "failed" }); + await auth.refreshSession(); + await new Promise((r) => setTimeout(r, 1500)); + onComplete(); + return; + } + + if (store) configureProSettings(store); + + void analyticsCommands.event({ event: "trial_started", plan: "pro" }); + const trialEndDate = new Date(); + trialEndDate.setDate(trialEndDate.getDate() + 14); + void analyticsCommands.setProperties({ + set: { plan: "pro", trial_end_date: trialEndDate.toISOString() }, + }); + + setTrialPhase({ step: "done", result: "started" }); + await auth.refreshSession(); + await new Promise((r) => setTimeout(r, 3000)); + } catch (e) { + Sentry.captureException(e); + console.error(e); + setTrialPhase({ step: "done", result: "failed" }); + await new Promise((r) => setTimeout(r, 1500)); } - > + + onComplete(); + }; + + void handle(); + }, [auth, billing, store, onComplete]); + + if (trialPhase) { + return ( +
+ + + {trialPhase.step === "checking" && ( + + )} + + {trialPhase.step === "starting" && ( + <> + + + + )} + + {trialPhase.step === "done" && trialPhase.result === "started" && ( + <> + + + + )} + + {trialPhase.step === "done" && trialPhase.result === "failed" && ( + <> + + + + )} + + {trialPhase.step === "done" && trialPhase.result === "already-pro" && ( + + )} + + {trialPhase.step === "done" && + trialPhase.result === "already-trialing" && ( + + )} +
+ ); + } + + if (auth?.session) { + return ( +
+ + Signed in +
+ ); + } + + return ( +
@@ -49,36 +221,13 @@ export function Login({ onNavigate }: StepProps) { Submit
-
- ); -} - -function useAutoSignIn() { - const auth = useAuth(); - - useEffect(() => { - if (!auth?.session) { - void auth?.signIn(); - } - }, [auth?.session, auth?.signIn]); - - return auth; -} - -function usePostAuthNavigation(onNavigate: StepProps["onNavigate"]) { - const search = Route.useSearch(); - const auth = useAuth(); - const hasHandledRef = useRef(false); - - useEffect(() => { - if (!auth?.session || hasHandledRef.current) { - return; - } - hasHandledRef.current = true; - const nextStep = getNext(search); - if (nextStep) { - onNavigate({ ...search, step: nextStep }); - } - }, [auth, search, onNavigate]); + +
+ ); } diff --git a/apps/desktop/src/components/onboarding/permissions.tsx b/apps/desktop/src/components/onboarding/permissions.tsx index 2caf622009..6d7aa583ba 100644 --- a/apps/desktop/src/components/onboarding/permissions.tsx +++ b/apps/desktop/src/components/onboarding/permissions.tsx @@ -1,13 +1,8 @@ -import { AlertCircleIcon, ArrowRightIcon, CheckIcon } from "lucide-react"; +import { AlertCircleIcon, CheckIcon } from "lucide-react"; import { cn } from "@hypr/utils"; import { usePermissions } from "../../hooks/usePermissions"; -import { Route } from "../../routes/app/onboarding/_layout.index"; -import { getBack, getNext, type StepProps } from "./config"; -import { OnboardingContainer } from "./shared"; - -export const STEP_ID_PERMISSIONS = "permissions" as const; function PermissionBlock({ name, @@ -25,56 +20,54 @@ function PermissionBlock({ const isAuthorized = status === "authorized"; return ( -
-
-
- {!isAuthorized && } - {name} -
-

- {isAuthorized ? description.authorized : description.unauthorized} -

-
- -
+ +
+ + {name} + +

+ {isAuthorized ? description.authorized : description.unauthorized} +

+
+ ); } -export function Permissions({ onNavigate }: StepProps) { - const search = Route.useSearch(); +export function PermissionsSection() { const { micPermissionStatus, systemAudioPermissionStatus, @@ -87,74 +80,40 @@ export function Permissions({ onNavigate }: StepProps) { handleAccessibilityPermissionAction, } = usePermissions(); - const allPermissionsGranted = - micPermissionStatus.data === "authorized" && - systemAudioPermissionStatus.data === "authorized" && - accessibilityPermissionStatus.data === "authorized"; - - const backStep = getBack(search); - return ( - onNavigate({ ...search, step: backStep }) : undefined - } - > -
- - - +
+ - -
+ - - + isPending={systemAudioPermission.isPending} + onAction={handleSystemAudioPermissionAction} + /> +
); } diff --git a/apps/desktop/src/components/onboarding/shared.tsx b/apps/desktop/src/components/onboarding/shared.tsx index 93137990b0..da29ed2b1b 100644 --- a/apps/desktop/src/components/onboarding/shared.tsx +++ b/apps/desktop/src/components/onboarding/shared.tsx @@ -1,81 +1,90 @@ -import { ArrowRightIcon, CheckIcon, ChevronLeftIcon } from "lucide-react"; +import { CheckIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import type { ReactNode } from "react"; -import { Button } from "@hypr/ui/components/ui/button"; +import { cn } from "@hypr/utils"; -export function OnboardingContainer({ +export type SectionStatus = "completed" | "active" | "upcoming"; + +export function OnboardingSection({ title, description, - children, + status, onBack, + onNext, + children, }: { title: string; description?: string; - children: ReactNode; + status: SectionStatus; onBack?: () => void; + onNext?: () => void; + children: ReactNode; }) { - return ( - <> - {onBack && ( - - )} - -
-

- {title} -

- {description && ( -

{description}

- )} -
- -
{children}
- - ); -} + const isActive = status === "active"; + const isCompleted = status === "completed"; -type IntegrationRowProps = { - icon: ReactNode; - name: string; - onConnect?: () => void; - connected?: boolean; - disabled?: boolean; -}; - -export function IntegrationRow({ - icon, - name, - onConnect, - connected = false, - disabled = false, -}: IntegrationRowProps) { return ( -
-
- {icon} - {name} +
+
+
+
+

+ {title} +

+ {isCompleted && ( + + )} + {import.meta.env.DEV && isActive && (onBack || onNext) && ( +
+ {onBack && ( + + )} + {onNext && ( + + )} +
+ )} +
+ {description && ( +

{description}

+ )} +
- -
+ + ); } diff --git a/apps/desktop/src/components/onboarding/welcome.tsx b/apps/desktop/src/components/onboarding/welcome.tsx deleted file mode 100644 index e148c1e1e1..0000000000 --- a/apps/desktop/src/components/onboarding/welcome.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { memo, useCallback } from "react"; - -import { Route } from "../../routes/app/onboarding/_layout.index"; -import { getNext, type StepProps } from "./config"; - -export const STEP_ID_WELCOME = "welcome" as const; - -export const Welcome = memo(function Welcome({ onNavigate }: StepProps) { - const search = Route.useSearch(); - - const goNext = useCallback( - (skipLogin: boolean) => { - const ctx = { ...search, skipLogin }; - const nextStep = getNext(ctx); - if (nextStep) { - onNavigate({ ...ctx, step: nextStep }); - } - }, - [onNavigate, search], - ); - - return ( - <> - HYPRNOTE - -

- Where Conversations Stay Yours -

- -
- - -
- - ); -}); diff --git a/apps/desktop/src/components/settings/calendar/configure/apple/calendar-selection.tsx b/apps/desktop/src/components/settings/calendar/configure/apple/calendar-selection.tsx index 69163f94da..5b260214d7 100644 --- a/apps/desktop/src/components/settings/calendar/configure/apple/calendar-selection.tsx +++ b/apps/desktop/src/components/settings/calendar/configure/apple/calendar-selection.tsx @@ -51,7 +51,7 @@ export function AppleCalendarSelection() { ); } -function useAppleCalendarSelection() { +export function useAppleCalendarSelection() { const { scheduleSync, scheduleDebouncedSync, cancelDebouncedSync } = useSync(); diff --git a/apps/desktop/src/components/settings/calendar/configure/shared.tsx b/apps/desktop/src/components/settings/calendar/configure/shared.tsx index 803417b42a..39ea4eabbc 100644 --- a/apps/desktop/src/components/settings/calendar/configure/shared.tsx +++ b/apps/desktop/src/components/settings/calendar/configure/shared.tsx @@ -1,5 +1,12 @@ import { CalendarOffIcon, CheckIcon } from "lucide-react"; +import { useMemo } from "react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@hypr/ui/components/ui/accordion"; import { cn } from "@hypr/utils"; export interface CalendarItem { @@ -23,6 +30,14 @@ export function CalendarSelection({ groups, onToggle, }: CalendarSelectionProps) { + const defaultOpen = useMemo( + () => + groups + .filter((g) => g.calendars.some((c) => c.enabled)) + .map((g) => g.sourceName), + [], + ); + if (groups.length === 0) { return (
@@ -33,25 +48,51 @@ export function CalendarSelection({ } return ( -
- {groups.map((group) => ( -
-
- {group.sourceName} -
-
- {group.calendars.map((cal) => ( - onToggle(cal, enabled)} - /> - ))} -
-
- ))} -
+ + {groups.map((group) => { + const enabledCount = group.calendars.filter((c) => c.enabled).length; + + return ( + + +
+ + {group.sourceName} + + + {enabledCount}/{group.calendars.length} + +
+
+ +
+ {group.calendars.map((cal) => ( + onToggle(cal, enabled)} + /> + ))} +
+
+
+ ); + })} +
); } diff --git a/apps/desktop/src/hooks/useOnboardingState.ts b/apps/desktop/src/hooks/useOnboardingState.ts deleted file mode 100644 index 87f4c6f10f..0000000000 --- a/apps/desktop/src/hooks/useOnboardingState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useLocation } from "@tanstack/react-router"; -import { useMemo } from "react"; - -export function useOnboardingState() { - const location = useLocation(); - - return useMemo( - () => location.pathname.startsWith("/app/onboarding"), - [location.pathname], - ); -} diff --git a/apps/desktop/src/routeTree.gen.ts b/apps/desktop/src/routeTree.gen.ts index ba529a44bb..23d98b7ea4 100644 --- a/apps/desktop/src/routeTree.gen.ts +++ b/apps/desktop/src/routeTree.gen.ts @@ -13,9 +13,7 @@ import { Route as AppRouteRouteImport } from './routes/app/route' import { Route as AuthCallbackRouteImport } from './routes/auth/callback' import { Route as AppExtHostRouteImport } from './routes/app/ext-host' import { Route as AppControlRouteImport } from './routes/app/control' -import { Route as AppOnboardingLayoutRouteImport } from './routes/app/onboarding/_layout' import { Route as AppMainLayoutRouteImport } from './routes/app/main/_layout' -import { Route as AppOnboardingLayoutIndexRouteImport } from './routes/app/onboarding/_layout.index' import { Route as AppMainLayoutIndexRouteImport } from './routes/app/main/_layout.index' const AppRouteRoute = AppRouteRouteImport.update({ @@ -38,22 +36,11 @@ const AppControlRoute = AppControlRouteImport.update({ path: '/control', getParentRoute: () => AppRouteRoute, } as any) -const AppOnboardingLayoutRoute = AppOnboardingLayoutRouteImport.update({ - id: '/onboarding/_layout', - path: '/onboarding', - getParentRoute: () => AppRouteRoute, -} as any) const AppMainLayoutRoute = AppMainLayoutRouteImport.update({ id: '/main/_layout', path: '/main', getParentRoute: () => AppRouteRoute, } as any) -const AppOnboardingLayoutIndexRoute = - AppOnboardingLayoutIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AppOnboardingLayoutRoute, - } as any) const AppMainLayoutIndexRoute = AppMainLayoutIndexRouteImport.update({ id: '/', path: '/', @@ -66,9 +53,7 @@ export interface FileRoutesByFullPath { '/app/ext-host': typeof AppExtHostRoute '/auth/callback': typeof AuthCallbackRoute '/app/main': typeof AppMainLayoutRouteWithChildren - '/app/onboarding': typeof AppOnboardingLayoutRouteWithChildren '/app/main/': typeof AppMainLayoutIndexRoute - '/app/onboarding/': typeof AppOnboardingLayoutIndexRoute } export interface FileRoutesByTo { '/app': typeof AppRouteRouteWithChildren @@ -76,7 +61,6 @@ export interface FileRoutesByTo { '/app/ext-host': typeof AppExtHostRoute '/auth/callback': typeof AuthCallbackRoute '/app/main': typeof AppMainLayoutIndexRoute - '/app/onboarding': typeof AppOnboardingLayoutIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -85,9 +69,7 @@ export interface FileRoutesById { '/app/ext-host': typeof AppExtHostRoute '/auth/callback': typeof AuthCallbackRoute '/app/main/_layout': typeof AppMainLayoutRouteWithChildren - '/app/onboarding/_layout': typeof AppOnboardingLayoutRouteWithChildren '/app/main/_layout/': typeof AppMainLayoutIndexRoute - '/app/onboarding/_layout/': typeof AppOnboardingLayoutIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -97,17 +79,9 @@ export interface FileRouteTypes { | '/app/ext-host' | '/auth/callback' | '/app/main' - | '/app/onboarding' | '/app/main/' - | '/app/onboarding/' fileRoutesByTo: FileRoutesByTo - to: - | '/app' - | '/app/control' - | '/app/ext-host' - | '/auth/callback' - | '/app/main' - | '/app/onboarding' + to: '/app' | '/app/control' | '/app/ext-host' | '/auth/callback' | '/app/main' id: | '__root__' | '/app' @@ -115,9 +89,7 @@ export interface FileRouteTypes { | '/app/ext-host' | '/auth/callback' | '/app/main/_layout' - | '/app/onboarding/_layout' | '/app/main/_layout/' - | '/app/onboarding/_layout/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -155,13 +127,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppControlRouteImport parentRoute: typeof AppRouteRoute } - '/app/onboarding/_layout': { - id: '/app/onboarding/_layout' - path: '/onboarding' - fullPath: '/app/onboarding' - preLoaderRoute: typeof AppOnboardingLayoutRouteImport - parentRoute: typeof AppRouteRoute - } '/app/main/_layout': { id: '/app/main/_layout' path: '/main' @@ -169,13 +134,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppMainLayoutRouteImport parentRoute: typeof AppRouteRoute } - '/app/onboarding/_layout/': { - id: '/app/onboarding/_layout/' - path: '/' - fullPath: '/app/onboarding/' - preLoaderRoute: typeof AppOnboardingLayoutIndexRouteImport - parentRoute: typeof AppOnboardingLayoutRoute - } '/app/main/_layout/': { id: '/app/main/_layout/' path: '/' @@ -198,29 +156,16 @@ const AppMainLayoutRouteWithChildren = AppMainLayoutRoute._addFileChildren( AppMainLayoutRouteChildren, ) -interface AppOnboardingLayoutRouteChildren { - AppOnboardingLayoutIndexRoute: typeof AppOnboardingLayoutIndexRoute -} - -const AppOnboardingLayoutRouteChildren: AppOnboardingLayoutRouteChildren = { - AppOnboardingLayoutIndexRoute: AppOnboardingLayoutIndexRoute, -} - -const AppOnboardingLayoutRouteWithChildren = - AppOnboardingLayoutRoute._addFileChildren(AppOnboardingLayoutRouteChildren) - interface AppRouteRouteChildren { AppControlRoute: typeof AppControlRoute AppExtHostRoute: typeof AppExtHostRoute AppMainLayoutRoute: typeof AppMainLayoutRouteWithChildren - AppOnboardingLayoutRoute: typeof AppOnboardingLayoutRouteWithChildren } const AppRouteRouteChildren: AppRouteRouteChildren = { AppControlRoute: AppControlRoute, AppExtHostRoute: AppExtHostRoute, AppMainLayoutRoute: AppMainLayoutRouteWithChildren, - AppOnboardingLayoutRoute: AppOnboardingLayoutRouteWithChildren, } const AppRouteRouteWithChildren = AppRouteRoute._addFileChildren( diff --git a/apps/desktop/src/routes/app/main/_layout.index.tsx b/apps/desktop/src/routes/app/main/_layout.index.tsx index c93f4cbb38..9354ea5d91 100644 --- a/apps/desktop/src/routes/app/main/_layout.index.tsx +++ b/apps/desktop/src/routes/app/main/_layout.index.tsx @@ -13,6 +13,7 @@ import { Body } from "../../../components/main/body"; import { LeftSidebar } from "../../../components/main/sidebar"; import { useSearch } from "../../../contexts/search/ui"; import { useShell } from "../../../contexts/shell"; +import { useTabs } from "../../../store/zustand/tabs"; import { commands } from "../../../types/tauri.gen"; export const Route = createFileRoute("/app/main/_layout/")({ @@ -24,12 +25,20 @@ const CHAT_MIN_WIDTH_PX = 280; function Component() { const { leftsidebar, chat } = useShell(); const { query } = useSearch(); + const currentTab = useTabs((state) => state.currentTab); + const isOnboarding = currentTab?.type === "onboarding"; const previousModeRef = useRef(chat.mode); const previousQueryRef = useRef(query); const bodyPanelRef = useRef>(null); const isChatOpen = chat.mode === "RightPanelOpen"; + useEffect(() => { + if (isOnboarding && leftsidebar.expanded) { + leftsidebar.setExpanded(false); + } + }, [isOnboarding, leftsidebar]); + useEffect(() => { const isOpeningRightPanel = chat.mode === "RightPanelOpen" && @@ -48,7 +57,7 @@ function Component() { const isStartingSearch = query.trim() !== "" && previousQueryRef.current.trim() === ""; - if (isStartingSearch && !leftsidebar.expanded) { + if (isStartingSearch && !leftsidebar.expanded && !isOnboarding) { leftsidebar.setExpanded(true); commands.resizeWindowForSidebar().catch(console.error); } @@ -61,7 +70,7 @@ function Component() { className="flex h-full overflow-hidden gap-1 p-1" data-testid="main-app-shell" > - {leftsidebar.expanded && } + {leftsidebar.expanded && !isOnboarding && } ; - -export const Route = createFileRoute("/app/onboarding/_layout/")({ - validateSearch, - component: Component, -}); - -function Component() { - const search = Route.useSearch(); - const navigate = useNavigate(); - const [isMuted, setIsMuted] = useState(false); - - useEffect(() => { - void analyticsCommands.event({ - event: "onboarding_step_viewed", - step: search.step, - platform: search.platform, - skip_login: search.skipLogin ?? false, - }); - }, [search.step]); - - useEffect(() => { - sfxCommands.play("BGM").catch(console.error); - return () => { - sfxCommands.stop("BGM").catch(console.error); - }; - }, []); - - useEffect(() => { - sfxCommands.setVolume("BGM", isMuted ? 0 : 1).catch(console.error); - }, [isMuted]); - - const toggleMute = useCallback(() => { - setIsMuted((prev) => !prev); - }, []); - - const onNavigate = useCallback( - (ctx: NavigateTarget) => { - const { step, ...rest } = ctx; - void navigate({ to: "/app/onboarding", search: { step, ...rest } }); - }, - [navigate], - ); - - const currentConfig = STEP_CONFIGS.find((s) => s.id === search.step); - if (!currentConfig) { - return null; - } - - return ( -
-
- - -
- ); -} diff --git a/apps/desktop/src/routes/app/onboarding/_layout.tsx b/apps/desktop/src/routes/app/onboarding/_layout.tsx deleted file mode 100644 index 51df26b714..0000000000 --- a/apps/desktop/src/routes/app/onboarding/_layout.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createFileRoute, Outlet } from "@tanstack/react-router"; - -import { useDeeplinkHandler } from "../../../hooks/useDeeplinkHandler"; - -export const Route = createFileRoute("/app/onboarding/_layout")({ - component: Component, -}); - -function Component() { - useDeeplinkHandler(); - - return ; -} diff --git a/apps/desktop/src/store/tinybase/store/initialize.ts b/apps/desktop/src/store/tinybase/store/initialize.ts index 94689b54d7..e323f02ee3 100644 --- a/apps/desktop/src/store/tinybase/store/initialize.ts +++ b/apps/desktop/src/store/tinybase/store/initialize.ts @@ -32,20 +32,5 @@ function initializeStore(store: Store): void { org_id: "", }); } - - if ( - !store.getTableIds().includes("sessions") || - store.getRowIds("sessions").length === 0 - ) { - const sessionId = crypto.randomUUID(); - const now = new Date().toISOString(); - - store.setRow("sessions", sessionId, { - user_id: DEFAULT_USER_ID, - created_at: now, - title: "Welcome to Hyprnote", - raw_md: "", - }); - } }); } diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts index 90c9c63ffa..a71ee9fbf8 100644 --- a/apps/desktop/src/store/zustand/tabs/schema.ts +++ b/apps/desktop/src/store/zustand/tabs/schema.ts @@ -111,7 +111,8 @@ export type Tab = | (BaseTab & { type: "chat"; state: ChatState; - }); + }) + | (BaseTab & { type: "onboarding" }); export const getDefaultState = (tab: TabInput): Tab => { const base = { active: false, slotId: "", pinned: false }; @@ -217,6 +218,8 @@ export const getDefaultState = (tab: TabInput): Tab => { chatType: null, }, }; + case "onboarding": + return { ...base, type: "onboarding" }; default: const _exhaustive: never = tab; return _exhaustive; @@ -259,6 +262,8 @@ export const uniqueIdfromTab = (tab: Tab): string => { return `search`; case "chat": return `chat`; + case "onboarding": + return `onboarding`; } }; diff --git a/plugins/windows/js/bindings.gen.ts b/plugins/windows/js/bindings.gen.ts index f42494643f..67bce5ff89 100644 --- a/plugins/windows/js/bindings.gen.ts +++ b/plugins/windows/js/bindings.gen.ts @@ -71,7 +71,7 @@ windowDestroyed: "plugin:windows:window-destroyed" export type AiState = { tab: AiTab | null } export type AiTab = "transcription" | "intelligence" | "templates" | "shortcuts" | "prompts" -export type AppWindow = { type: "onboarding" } | { type: "main" } | { type: "control" } +export type AppWindow = { type: "main" } | { type: "control" } export type ChangelogState = { previous: string | null; current: string } export type ChatShortcutsState = { isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } export type ChatState = { groupId: string | null; initialMessage: string | null; chatType: ChatType | null } @@ -85,7 +85,7 @@ export type OpenTab = { tab: TabInput } export type PromptsState = { selectedTask: string | null } export type SearchState = { selectedTypes: string[] | null; initialQuery: string | null } export type SessionsState = { view: EditorView | null; autoStart: boolean | null } -export type TabInput = { type: "sessions"; id: string; state?: SessionsState | null } | { type: "contacts"; state?: ContactsState | null } | { type: "templates"; state?: TemplatesState | null } | { type: "prompts"; state?: PromptsState | null } | { type: "chat_shortcuts"; state?: ChatShortcutsState | null } | { type: "extensions"; state?: ExtensionsState | null } | { type: "humans"; id: string } | { type: "organizations"; id: string } | { type: "folders"; id: string | null } | { type: "empty" } | { type: "extension"; extensionId: string; state?: Partial<{ [key in string]: JsonValue }> | null } | { type: "calendar" } | { type: "changelog"; state: ChangelogState } | { type: "settings" } | { type: "ai"; state?: AiState | null } | { type: "search"; state?: SearchState | null } | { type: "chat"; state?: ChatState | null } +export type TabInput = { type: "sessions"; id: string; state?: SessionsState | null } | { type: "contacts"; state?: ContactsState | null } | { type: "templates"; state?: TemplatesState | null } | { type: "prompts"; state?: PromptsState | null } | { type: "chat_shortcuts"; state?: ChatShortcutsState | null } | { type: "extensions"; state?: ExtensionsState | null } | { type: "humans"; id: string } | { type: "organizations"; id: string } | { type: "folders"; id: string | null } | { type: "empty" } | { type: "extension"; extensionId: string; state?: Partial<{ [key in string]: JsonValue }> | null } | { type: "calendar" } | { type: "changelog"; state: ChangelogState } | { type: "settings" } | { type: "ai"; state?: AiState | null } | { type: "search"; state?: SearchState | null } | { type: "chat"; state?: ChatState | null } | { type: "onboarding" } export type TemplatesState = { showHomepage: boolean | null; isWebMode: boolean | null; selectedMineId: string | null; selectedWebIndex: number | null } export type VisibilityEvent = { window: AppWindow; visible: boolean } export type WindowDestroyed = { window: AppWindow } diff --git a/plugins/windows/src/events.rs b/plugins/windows/src/events.rs index 4222f8e6b3..843f14b31a 100644 --- a/plugins/windows/src/events.rs +++ b/plugins/windows/src/events.rs @@ -15,10 +15,6 @@ pub fn on_window_event(window: &tauri::Window, event: &tauri::Window if w == AppWindow::Main && w.hide(app).is_ok() { api.prevent_close(); } - if w == AppWindow::Onboarding { - use tauri_plugin_sfx::SfxPluginExt; - app.sfx().stop(tauri_plugin_sfx::AppSounds::BGM); - } } } } diff --git a/plugins/windows/src/tab/mod.rs b/plugins/windows/src/tab/mod.rs index f668e7ddca..2b09c36e5b 100644 --- a/plugins/windows/src/tab/mod.rs +++ b/plugins/windows/src/tab/mod.rs @@ -80,5 +80,7 @@ common_derives! { #[serde(skip_serializing_if = "Option::is_none")] state: Option, }, + #[serde(rename = "onboarding")] + Onboarding, } } diff --git a/plugins/windows/src/window/v1.rs b/plugins/windows/src/window/v1.rs index 118a0825da..889c456b93 100644 --- a/plugins/windows/src/window/v1.rs +++ b/plugins/windows/src/window/v1.rs @@ -3,8 +3,6 @@ use crate::WindowImpl; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, specta::Type, PartialEq, Eq, Hash)] #[serde(tag = "type", content = "value")] pub enum AppWindow { - #[serde(rename = "onboarding")] - Onboarding, #[serde(rename = "main")] Main, #[serde(rename = "control")] @@ -14,7 +12,6 @@ pub enum AppWindow { impl std::fmt::Display for AppWindow { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Onboarding => write!(f, "onboarding"), Self::Main => write!(f, "main"), Self::Control => write!(f, "control"), } @@ -26,7 +23,6 @@ impl std::str::FromStr for AppWindow { fn from_str(s: &str) -> Result { match s { - "onboarding" => return Ok(Self::Onboarding), "main" => return Ok(Self::Main), "control" => return Ok(Self::Control), _ => {} @@ -105,7 +101,6 @@ impl AppWindow { impl WindowImpl for AppWindow { fn title(&self) -> String { match self { - Self::Onboarding => "Onboarding".into(), Self::Main => "Hyprnote".into(), Self::Control => "Control".into(), } @@ -118,15 +113,6 @@ impl WindowImpl for AppWindow { use tauri::LogicalSize; let window = match self { - Self::Onboarding => { - let builder = self - .window_builder(app, "/app/onboarding") - .resizable(false) - .min_inner_size(400.0, 600.0); - let window = builder.build()?; - window.set_size(LogicalSize::new(400.0, 600.0))?; - window - } Self::Main => { let builder = self .window_builder(app, "/app/main") From 53f90bca0b4a6ac3a05bf39b9e74c4f6a93f7da9 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Thu, 12 Feb 2026 19:28:04 +0900 Subject: [PATCH 2/3] various fixes --- .../components/main/body/onboarding/index.tsx | 51 +++++------- .../src/components/onboarding/calendar.tsx | 83 +++++-------------- .../src/components/onboarding/config.tsx | 6 +- .../src/components/onboarding/final.tsx | 14 ++-- .../src/components/onboarding/login.tsx | 27 +++--- .../src/components/onboarding/permissions.tsx | 46 ++++------ .../src/components/onboarding/shared.tsx | 15 +++- .../configure/apple/calendar-selection.tsx | 10 ++- .../calendar/configure/apple/index.tsx | 25 +----- .../calendar/configure/apple/permission.tsx | 28 +++++++ .../settings/calendar/configure/shared.tsx | 13 ++- 11 files changed, 143 insertions(+), 175 deletions(-) diff --git a/apps/desktop/src/components/main/body/onboarding/index.tsx b/apps/desktop/src/components/main/body/onboarding/index.tsx index ecdb3c5f20..85c702ef02 100644 --- a/apps/desktop/src/components/main/body/onboarding/index.tsx +++ b/apps/desktop/src/components/main/body/onboarding/index.tsx @@ -64,8 +64,6 @@ export function TabContentOnboarding({ const [isMuted, setIsMuted] = useState(false); const [currentStep, setCurrentStep] = useState(getInitialStep); - const isMacOS = platform() === "macos"; - const { micPermissionStatus, systemAudioPermissionStatus, @@ -115,7 +113,7 @@ export function TabContentOnboarding({ sfxCommands.setVolume("BGM", isMuted ? 0 : 0.2).catch(console.error); }, [isMuted]); - const onFinish = useCallback(() => { + const handleFinish = useCallback(() => { void queryClient.invalidateQueries({ queryKey: ["onboarding-needed"] }); if (currentTab) { close(currentTab); @@ -142,38 +140,35 @@ export function TabContentOnboarding({ Welcome to Hyprnote - {isMacOS && ( - - - - )} + + + - + - {isMacOS && ( - - - - )} + + + void finishOnboarding(onFinish)} + onNext={() => void finishOnboarding(handleFinish)} > - +
diff --git a/apps/desktop/src/components/onboarding/calendar.tsx b/apps/desktop/src/components/onboarding/calendar.tsx index f1d0944ebc..cba078df31 100644 --- a/apps/desktop/src/components/onboarding/calendar.tsx +++ b/apps/desktop/src/components/onboarding/calendar.tsx @@ -1,53 +1,30 @@ -import { Icon } from "@iconify-icon/react"; -import type { ReactNode } from "react"; import { useState } from "react"; -import { OutlookIcon } from "@hypr/ui/components/icons/outlook"; import { cn } from "@hypr/utils"; -import { usePermission } from "../../hooks/usePermissions"; import { useAppleCalendarSelection } from "../settings/calendar/configure/apple/calendar-selection"; import { SyncProvider } from "../settings/calendar/configure/apple/context"; -import { AccessPermissionRow } from "../settings/calendar/configure/apple/permission"; +import { ApplePermissions } from "../settings/calendar/configure/apple/permission"; import { CalendarSelection } from "../settings/calendar/configure/shared"; +import { + type CalendarProviderId, + PROVIDERS, +} from "../settings/calendar/shared"; +import { OnboardingButton } from "./shared"; -const PROVIDERS = [ - { - id: "apple", - label: "Apple", - icon: , - disabled: false, - }, - { - id: "google", - label: "Google", - icon: , - disabled: true, - }, - { - id: "outlook", - label: "Outlook", - icon: , - disabled: true, - }, -] as const satisfies readonly { - id: string; - label: string; - icon: ReactNode; - disabled: boolean; -}[]; - -type ProviderId = (typeof PROVIDERS)[number]["id"]; - -function BareAppleCalendarSelection() { +function AppleCalendarList() { const { groups, handleToggle } = useAppleCalendarSelection(); - return ; + return ( + + ); } export function CalendarSection({ onContinue }: { onContinue: () => void }) { - const [provider, setProvider] = useState("apple"); - const calendar = usePermission("calendar"); - const contacts = usePermission("contacts"); + const [provider, setProvider] = useState("apple"); return (
@@ -68,7 +45,7 @@ export function CalendarSection({ onContinue }: { onContinue: () => void }) { ])} > {p.icon} - {p.label} + {p.displayName} {p.disabled && ( (soon) )} @@ -78,37 +55,15 @@ export function CalendarSection({ onContinue }: { onContinue: () => void }) { {provider === "apple" && (
-
- - -
+ - +
)} - + Continue
); } diff --git a/apps/desktop/src/components/onboarding/config.tsx b/apps/desktop/src/components/onboarding/config.tsx index 330c74722e..89c44804c9 100644 --- a/apps/desktop/src/components/onboarding/config.tsx +++ b/apps/desktop/src/components/onboarding/config.tsx @@ -13,10 +13,9 @@ const STEPS_MACOS: OnboardingStep[] = [ "permissions", "login", "calendar", - "folder-location", "final", ]; -const STEPS_OTHER: OnboardingStep[] = ["login", "folder-location", "final"]; +const STEPS_OTHER: OnboardingStep[] = ["login", "final"]; export function getOnboardingSteps(): OnboardingStep[] { return platform() === "macos" ? STEPS_MACOS : STEPS_OTHER; @@ -45,9 +44,10 @@ export function getPrevStep( export function getStepStatus( step: OnboardingStep, currentStep: OnboardingStep, -): SectionStatus { +): SectionStatus | null { const steps = getOnboardingSteps(); const stepIdx = steps.indexOf(step); + if (stepIdx === -1) return null; const currentIdx = steps.indexOf(currentStep); if (stepIdx < currentIdx) return "completed"; if (stepIdx === currentIdx) return "active"; diff --git a/apps/desktop/src/components/onboarding/final.tsx b/apps/desktop/src/components/onboarding/final.tsx index fccfcc1d0e..f6e8263efb 100644 --- a/apps/desktop/src/components/onboarding/final.tsx +++ b/apps/desktop/src/components/onboarding/final.tsx @@ -3,6 +3,7 @@ import { commands as openerCommands } from "@hypr/plugin-opener2"; import { commands as sfxCommands } from "@hypr/plugin-sfx"; import { commands } from "../../types/tauri.gen"; +import { OnboardingButton } from "./shared"; const SOCIALS = [ { label: "Discord", url: "https://discord.gg/CX8gTH2tj9" }, @@ -10,7 +11,7 @@ const SOCIALS = [ { label: "X", url: "https://x.com/tryhyprnote" }, ] as const; -export function FinalSection({ onFinish }: { onFinish: () => void }) { +export function FinalSection({ onContinue }: { onContinue: () => void }) { return (
@@ -30,21 +31,18 @@ export function FinalSection({ onFinish }: { onFinish: () => void }) {
- +
); } -export async function finishOnboarding(onFinish?: () => void) { +export async function finishOnboarding(onContinue?: () => void) { await sfxCommands.stop("BGM").catch(console.error); await new Promise((resolve) => setTimeout(resolve, 100)); await commands.setOnboardingNeeded(false).catch(console.error); await new Promise((resolve) => setTimeout(resolve, 100)); await analyticsCommands.event({ event: "onboarding_completed" }); - onFinish?.(); + onContinue?.(); } diff --git a/apps/desktop/src/components/onboarding/login.tsx b/apps/desktop/src/components/onboarding/login.tsx index 3115d08a74..7f31faad12 100644 --- a/apps/desktop/src/components/onboarding/login.tsx +++ b/apps/desktop/src/components/onboarding/login.tsx @@ -11,7 +11,7 @@ import { useBillingAccess } from "../../billing"; import { env } from "../../env"; import * as settings from "../../store/tinybase/store/settings"; import { configureProSettings } from "../../utils"; -import { Divider } from "./shared"; +import { Divider, OnboardingButton } from "./shared"; type TrialPhase = | { step: "checking" } @@ -51,7 +51,7 @@ function StepRow({ ); } -export function LoginSection({ onComplete }: { onComplete: () => void }) { +export function LoginSection({ onContinue }: { onContinue: () => void }) { const auth = useAuth(); const billing = useBillingAccess(); const store = settings.UI.useStore(settings.STORE_ID); @@ -73,27 +73,27 @@ export function LoginSection({ onComplete }: { onComplete: () => void }) { if (billing.isPro && !billing.isTrialing) { if (store) configureProSettings(store); setTrialPhase({ step: "done", result: "already-pro" }); - setTimeout(onComplete, 1500); + setTimeout(onContinue, 1500); return; } if (billing.isTrialing) { if (store) configureProSettings(store); setTrialPhase({ step: "done", result: "already-trialing" }); - setTimeout(onComplete, 1500); + setTimeout(onContinue, 1500); return; } if (!billing.canStartTrial.data) { setTrialPhase({ step: "done", result: "not-eligible" }); - onComplete(); + onContinue(); return; } const handle = async () => { const headers = auth.getHeaders(); if (!headers) { - onComplete(); + onContinue(); return; } @@ -114,7 +114,7 @@ export function LoginSection({ onComplete }: { onComplete: () => void }) { setTrialPhase({ step: "done", result: "failed" }); await auth.refreshSession(); await new Promise((r) => setTimeout(r, 1500)); - onComplete(); + onContinue(); return; } @@ -137,11 +137,11 @@ export function LoginSection({ onComplete }: { onComplete: () => void }) { await new Promise((r) => setTimeout(r, 1500)); } - onComplete(); + onContinue(); }; void handle(); - }, [auth, billing, store, onComplete]); + }, [auth, billing, store, onContinue]); if (trialPhase) { return ( @@ -196,12 +196,9 @@ export function LoginSection({ onComplete }: { onComplete: () => void }) { return (
- + @@ -223,7 +220,7 @@ export function LoginSection({ onComplete }: { onComplete: () => void }) {