From 60bf8672039c6b547ad7bdf6d8df329468e3fc48 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Wed, 20 May 2026 09:43:00 +0200 Subject: [PATCH] feat(onboarding): add board tour on dashboard pages Scope the board tour to content pages, target header actions, remove the loading overlay, and persist completion with optimistic cache updates. --- .../app/[locale]/boards/(content)/_client.tsx | 13 +--- .../[locale]/boards/(content)/_creator.tsx | 1 + .../boards/(content)/_header-actions.tsx | 65 +++++++++++-------- .../app/[locale]/boards/_layout-creator.tsx | 4 +- .../src/components/onboarding/board-tour.tsx | 59 ++++++++++------- 5 files changed, 78 insertions(+), 64 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index e9551af52..649aa867b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useRef } from "react"; -import { Box, LoadingOverlay, Stack } from "@mantine/core"; +import { Box, Stack } from "@mantine/core"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; @@ -10,8 +10,6 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardBackgroundVideo } from "~/components/layout/background"; -import { fullHeightWithoutHeaderAndFooter } from "~/constants"; -import { useIsBoardReady } from "./_ready-context"; let boardName: string | null = null; @@ -44,7 +42,6 @@ export const useUpdateBoard = () => { export const ClientBoard = () => { const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); - const isReady = useIsBoardReady(); const fullWidthSortedSections = board.sections .filter((section) => section.kind === "empty" || section.kind === "category") @@ -55,13 +52,7 @@ export const ClientBoard = () => { return ( - - + {fullWidthSortedSections.map((section) => section.kind === "empty" ? ( // Unique keys per layout to always reinitialize the gridstack diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 64c9289e3..0ad398608 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -38,6 +38,7 @@ export const createBoardContentPage = >( layout: createBoardLayout({ headerActions: , getInitialBoardAsync: getInitialBoard, + withTour: true, }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 18c0fe241..c76d29ea0 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; -import { Group, Menu, ScrollArea } from "@mantine/core"; +import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; +import { Box, Group, Menu, ScrollArea } from "@mantine/core"; import { useHotkeys } from "@mantine/hooks"; import { IconBox, @@ -53,9 +54,11 @@ export const BoardContentHeaderActions = () => { - - - + + + + + @@ -173,9 +176,11 @@ const EditModeMenu = () => { usePreventLeaveWithDirty(isEditMode); return ( - - {isEditMode ? : } - + + + {isEditMode ? : } + + ); }; @@ -183,27 +188,31 @@ const SelectBoardsMenu = () => { const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); return ( - - - - - - - - - {boards.map((board) => ( - } - > - {board.name} - - ))} - - - + + + + + + + + + + + {boards.map((board) => ( + } + > + {board.name} + + ))} + + + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx index 972577ed7..3cddc6685 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_layout-creator.tsx @@ -29,11 +29,13 @@ const BoardTourWrapper = ({ hasSession, children }: PropsWithChildren<{ hasSessi interface CreateBoardLayoutProps { headerActions: JSX.Element; getInitialBoardAsync: (params: TParams) => Promise; + withTour?: boolean; } export const createBoardLayout = ({ headerActions, getInitialBoardAsync: getInitialBoard, + withTour = false, }: CreateBoardLayoutProps) => { const Layout = async ({ params, @@ -67,7 +69,7 @@ export const createBoardLayout = ({ - + } diff --git a/apps/nextjs/src/components/onboarding/board-tour.tsx b/apps/nextjs/src/components/onboarding/board-tour.tsx index b7a9d76da..d7f5d2765 100644 --- a/apps/nextjs/src/components/onboarding/board-tour.tsx +++ b/apps/nextjs/src/components/onboarding/board-tour.tsx @@ -3,26 +3,35 @@ import type { PropsWithChildren } from "react"; import { useMemo } from "react"; import type { OnboardingTourStep } from "@gfazioli/mantine-onboarding-tour"; -import { OnboardingTour } from "@gfazioli/mantine-onboarding-tour"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; +import { createDocumentationLink } from "@homarr/definitions"; import { useScopedI18n } from "@homarr/translation/client"; import { useBoardPermissions } from "~/components/board/permissions/client"; -import { SkipHoldButton } from "./skip-hold-button"; +import { TourShell } from "./tour-shell"; import { TourStepContent } from "./tour-step-content"; export const BoardTourProvider = ({ children }: PropsWithChildren) => { const t = useScopedI18n("onboardingTour.board"); const board = useRequiredBoard(); const { hasChangeAccess } = useBoardPermissions(board); + const utils = clientApi.useUtils(); const { data: tourStatus } = clientApi.user.getTourStatus.useQuery(); - const { mutate: completeTour } = clientApi.user.completeTour.useMutation(); + const { mutate: completeTour } = clientApi.user.completeTour.useMutation({ + onSuccess() { + void utils.user.getTourStatus.invalidate(); + }, + }); const started = tourStatus !== undefined && !tourStatus.completedBoardTour; const handleEnd = () => { + utils.user.getTourStatus.setData(undefined, { + completedManageTour: tourStatus?.completedManageTour ?? false, + completedBoardTour: true, + }); completeTour({ tour: "board" }); }; @@ -34,13 +43,12 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => { content: ( ), - footer: (controller: { skipTour: () => void }) => ( - controller.skipTour()} /> - ), }, { id: "board-edit-mode", @@ -49,8 +57,10 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => { content: ( ), }, @@ -59,39 +69,40 @@ export const BoardTourProvider = ({ children }: PropsWithChildren) => { title: t("settings.title"), requiresChangeAccess: true, content: ( - + ), }, { id: "board-switcher", title: t("switcher.title"), content: ( - + ), }, { id: "board-user-menu", title: t("userMenu.title"), content: ( - + ), }, ]; - return allSteps.filter( - (step) => !step.requiresChangeAccess || hasChangeAccess, - ) as OnboardingTourStep[]; + return allSteps.filter((step) => !step.requiresChangeAccess || hasChangeAccess) as OnboardingTourStep[]; }, [hasChangeAccess, t]); return ( - + {children} - + ); };