Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 72 additions & 44 deletions src/components/Learn/FeaturedTours.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Button } from "@/components/ui/button";
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Heading, Paragraph, Text } from "@/components/ui/typography";
import { useTours } from "@/providers/TourProvider";
import { APP_ROUTES } from "@/routes/router";
import { tracking } from "@/utils/tracking";

import { tours as tourCards } from "./tours";
Expand All @@ -20,7 +20,7 @@ interface FeaturedTour {
}

const FEATURED_TOUR_IDS: Array<Pick<FeaturedTour, "id" | "tag">> = [
{ id: "navigating-editor", tag: "new" },
{ id: "navigating-the-editor", tag: "new" },
{ id: "first-pipeline", tag: "popular" },
{ id: "using-secrets" },
{ id: "multinode-tasks" },
Expand All @@ -43,7 +43,6 @@ function buildFeaturedTours(): FeaturedTour[] {
}

export function FeaturedTours() {
const { startTour } = useTours();
const featured = buildFeaturedTours();

return (
Expand Down Expand Up @@ -73,51 +72,80 @@ export function FeaturedTours() {
<ul className="list-none p-0 m-0 flex flex-col gap-1 flex-1">
{featured.map((tour) => (
<li key={tour.id}>
<Button
type="button"
variant="ghost"
disabled={!tour.available}
onClick={() => {
if (tour.available) void startTour(tour.id);
}}
className="w-full h-auto justify-between gap-3 px-3 py-2 text-left"
{...tracking("learning_hub.tours.start", { tour_id: tour.id })}
>
<BlockStack gap="0" className="min-w-0">
<InlineStack gap="2" blockAlign="center">
<Paragraph size="sm" weight="semibold" className="truncate">
{tour.title}
</Paragraph>
{tour.tag && (
<Badge
size="sm"
variant={tour.tag === "new" ? "default" : "secondary"}
className="capitalize"
>
{tour.tag}
</Badge>
)}
{!tour.available && (
<Badge size="sm" variant="outline">
Coming soon
</Badge>
)}
</InlineStack>
<Text size="xs" tone="subdued">
{tour.duration}
</Text>
</BlockStack>
<Icon
name="Play"
size="sm"
className="text-muted-foreground shrink-0"
aria-hidden="true"
/>
</Button>
{tour.available ? (
<Button
asChild
variant="ghost"
className="w-full h-auto justify-between gap-3 px-3 py-2 text-left"
{...tracking("learning_hub.tours.start", {
tour_id: tour.id,
})}
>
<Link
to={APP_ROUTES.TOUR_DETAIL}
params={{ tourId: tour.id }}
>
<FeaturedTourLabel tour={tour} />
<Icon
name="Play"
size="sm"
className="text-muted-foreground shrink-0"
aria-hidden="true"
/>
</Link>
</Button>
) : (
<Button
type="button"
variant="ghost"
disabled
className="w-full h-auto justify-between gap-3 px-3 py-2 text-left"
{...tracking("learning_hub.tours.start", {
tour_id: tour.id,
})}
>
<FeaturedTourLabel tour={tour} />
<Icon
name="Play"
size="sm"
className="text-muted-foreground shrink-0"
aria-hidden="true"
/>
</Button>
)}
</li>
))}
</ul>
</BlockStack>
</div>
);
}

function FeaturedTourLabel({ tour }: { tour: FeaturedTour }) {
return (
<BlockStack gap="0" className="min-w-0">
<InlineStack gap="2" blockAlign="center">
<Paragraph size="sm" weight="semibold" className="truncate">
{tour.title}
</Paragraph>
{tour.tag && (
<Badge
size="sm"
variant={tour.tag === "new" ? "default" : "secondary"}
className="capitalize"
>
{tour.tag}
</Badge>
)}
{!tour.available && (
<Badge size="sm" variant="outline">
Coming soon
</Badge>
)}
</InlineStack>
<Text size="xs" tone="subdued">
{tour.duration}
</Text>
</BlockStack>
);
}
37 changes: 25 additions & 12 deletions src/components/Learn/ToursLibrary.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Link } from "@tanstack/react-router";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Expand All @@ -10,7 +12,7 @@ import {
import { Icon } from "@/components/ui/icon";
import { BlockStack, InlineStack } from "@/components/ui/layout";
import { Heading, Paragraph, Text } from "@/components/ui/typography";
import { useTours } from "@/providers/TourProvider";
import { APP_ROUTES } from "@/routes/router";
import { tracking } from "@/utils/tracking";

import {
Expand All @@ -25,7 +27,6 @@ import {
import { getTour } from "./tours/registry";

function TourCard({ tour }: { tour: Tour }) {
const { startTour } = useTours();
const isAvailable = getTour(tour.id) !== undefined;

return (
Expand All @@ -46,16 +47,28 @@ function TourCard({ tour }: { tour: Tour }) {
{tour.duration}
</Text>
</InlineStack>
<Button
size="sm"
variant="ghost"
disabled={!isAvailable}
onClick={() => startTour(tour.id)}
{...tracking("learning_hub.tours.start", { tour_id: tour.id })}
>
{isAvailable ? "Start tour" : "Coming soon"}
{isAvailable && <Icon name="Play" size="sm" aria-hidden="true" />}
</Button>
{isAvailable ? (
<Button
asChild
size="sm"
variant="ghost"
{...tracking("learning_hub.tours.start", { tour_id: tour.id })}
>
<Link to={APP_ROUTES.TOUR_DETAIL} params={{ tourId: tour.id }}>
Start tour
<Icon name="Play" size="sm" aria-hidden="true" />
</Link>
</Button>
) : (
<Button
size="sm"
variant="ghost"
disabled
{...tracking("learning_hub.tours.start", { tour_id: tour.id })}
>
Coming soon
</Button>
)}
</InlineStack>
</CardContent>
</Card>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Learn/tours.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"area": "Editor"
},
{
"id": "navigating-editor",
"id": "navigating-the-editor",
"title": "Find your way around the editor",
"description": "Get oriented with the canvas, dockable panels, properties view, canvas tools, and the menu bar so you know where every feature lives.",
"difficulty": "Beginner",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Learn/tours/navigatingEditor.tour.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ const steps: TourStep[] = [
];

export const navigatingEditorTour: TourDefinition = {
id: "navigating-editor",
id: "navigating-the-editor",
displayName: "Guided Tour: Navigating the Editor",
requiresEditor: true,
starterPipelineUrl:
Expand Down
4 changes: 4 additions & 0 deletions src/components/layout/AppMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ const AppMenu = () => {
return null;
}

if (pathname.startsWith(APP_ROUTES.TOUR)) {
return null;
}

return <DefaultAppMenu />;
};

Expand Down
45 changes: 45 additions & 0 deletions src/providers/TourProvider/TourModeContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createContext, type ReactNode, useContext } from "react";

import type { TourDefinition } from "@/components/Learn/tours/registry";

/**
* Provided by the `/tour/$tourId` route. Editor components inside use
* `useTourMode()` to detect tour mode and adapt their UI — e.g. show the
* tour title instead of the pipeline storage key, hide actions that don't
* make sense for a transient tour pipeline.
*/
export interface TourModeValue {
tour: TourDefinition;
/**
* Storage key of the current temp pipeline backing this tour. The "Save
* as new pipeline" action uses this to know what file to promote.
*/
tempPipelineName: string;
/**
* Called after the temp pipeline has been promoted to a real pipeline
* (renamed / saved-as). The route uses this to skip its on-unmount
* delete and avoid clobbering the just-saved file.
*/
markPipelinePromoted: () => void;
}

const TourModeContext = createContext<TourModeValue | null>(null);

export function TourModeProvider({
value,
children,
}: {
value: TourModeValue;
children: ReactNode;
}) {
return (
<TourModeContext.Provider value={value}>
{children}
</TourModeContext.Provider>
);
}

/** Returns the current tour-mode value, or null when not inside a tour. */
export function useTourMode(): TourModeValue | null {
return useContext(TourModeContext);
}
29 changes: 29 additions & 0 deletions src/providers/TourProvider/TourOrphanCleanup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useRouterState } from "@tanstack/react-router";
import { useEffect } from "react";

import { APP_ROUTES } from "@/routes/router";
import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider";

import { cleanupOrphanTourPipelines } from "./tourPipelineLifecycle";

/**
* Removes any `__tour__*` pipelines lingering in storage. Tour pipelines
* are deleted by the `/tour/$tourId` route on unmount, but a tab close or
* crash skips that path — this sweep handles those orphans on app load.
*
* Skipped while the user is actually on a tour route so we don't race
* with that route's own create-pipeline flow.
*/
export function TourOrphanCleanup() {
const storage = usePipelineStorage();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});

useEffect(() => {
if (pathname.startsWith(APP_ROUTES.TOUR)) return;
void cleanupOrphanTourPipelines(storage);
}, [storage, pathname]);

return null;
}
Loading
Loading