diff --git a/apps/dashboard/src/app/(dashboard)/onboarding/client.tsx b/apps/dashboard/src/app/(dashboard)/onboarding/client.tsx index bf83003c..4a69a3e1 100644 --- a/apps/dashboard/src/app/(dashboard)/onboarding/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/onboarding/client.tsx @@ -24,73 +24,72 @@ import { } from "@/components/forms/form-card"; import { CreateMonitorForm } from "@/components/forms/onboarding/create-monitor"; import { CreatePageForm } from "@/components/forms/onboarding/create-page"; -import { LearnFromForm } from "@/components/forms/onboarding/learn-from"; +import { QuestionForm } from "@/components/forms/onboarding/question"; import { extractDomain } from "@/lib/domains"; import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowUpRight } from "lucide-react"; +import { Activity, ArrowUpRight, Layers, PanelTop } from "lucide-react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { useQueryStates } from "nuqs"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; +import type { Intent } from "./search-params"; import { searchParamsParsers } from "./search-params"; -const moreActions = [ +const intents: { + id: Intent; + title: string; + description: string; + icon: typeof Activity; +}[] = [ { - id: "notifier", - title: "Create a notifier", - description: "Get notified when your website or API is down.", - href: "/notifications", + id: "status-page", + title: "Status Page", + description: + "Keep your users informed with a public status page showing your system health.", + icon: PanelTop, }, { - id: "workspace", - title: "Setup workspace", - description: "Add a name to your workspace and share it with your team.", - href: "/settings/general", + id: "monitoring", + title: "Monitoring", + description: + "Track uptime and performance of your websites and APIs from multiple locations.", + icon: Activity, }, { - id: "monitor", - title: "Update monitor", - description: "Change region, schedule, timeout and more.", - href: "/monitors", - }, - { - id: "cal", - title: "Schedule a call", - description: "Book a meeting with us to get you started with OpenStatus.", - href: "https://openstatus.dev/cal", - }, - { - id: "docs", - title: "Documentation", - description: "Read our documentation to get started with OpenStatus.", - href: "https://docs.openstatus.dev", - }, - { - id: "changelog", - title: "Changelog", - description: "See what's new in OpenStatus.", - href: "https://openstatus.dev/changelog", - }, - { - id: "discord", - title: "Discord", - description: "Join our Discord server if you get stuck.", - href: "https://discord.gg/openstatus", - }, - { - id: "github", - title: "GitHub", - description: "Leave a star on GitHub, request features or report issues.", - href: "https://github.com/openstatus-dev/openstatus", + id: "both", + title: "Status Page & Monitoring", + description: + "Monitor your services and share real-time status with your users.", + icon: Layers, }, ]; +function getTotalSteps(intent: Intent | null): number { + if (intent === "both") return 4; + return 3; +} + +function getCurrentStepNumber( + step: string, + intent: Intent | null, +): number | null { + if (step === "1") return 1; + if (step === "2") return 2; + if (step === "3" && intent === "both") return 3; + if (step === "next") return null; // no counter on final page + return null; +} + export function Client() { - const [{ step, callbackUrl }, setSearchParams] = - useQueryStates(searchParamsParsers); + const [{ step, intent, callbackUrl }, setSearchParams] = useQueryStates( + searchParamsParsers, + { history: "push" }, + ); const router = useRouter(); + const pathname = usePathname(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { data: workspace, refetch } = useQuery( @@ -102,7 +101,11 @@ export function Client() { const createMonitorMutation = useMutation( trpc.monitor.new.mutationOptions({ onSuccess: async (data) => { - await setSearchParams({ step: "2" }); + if (intent === "both") { + await setSearchParams({ step: "3" }); + } else { + await setSearchParams({ step: "next" }); + } if (data.active) { triggerCheckMutation.mutate({ id: data.id }); } @@ -113,58 +116,156 @@ export function Client() { }, }), ); - const createPageMutation = useMutation( - trpc.page.create.mutationOptions({ - onSuccess: async () => { - await setSearchParams({ step: "next" }); - refetch(); - queryClient.invalidateQueries({ - queryKey: trpc.page.list.queryKey(), - }); - }, - }), + const createPageMutation = useMutation(trpc.page.create.mutationOptions({})); + const updateComponentsMutation = useMutation( + trpc.pageComponent.updateOrder.mutationOptions({}), ); const createFeedbackMutation = useMutation( trpc.feedback.submit.mutationOptions({}), ); + const totalSteps = getTotalSteps(intent); + const currentStepNumber = getCurrentStepNumber(step, intent); + + const nudgeCards = useMemo(() => { + const cards = [ + { + id: "upgrade", + title: "Upgrade your plan", + description: + "Unlock more monitors, status pages, and advanced features.", + href: "/settings/billing", + }, + { + id: "invite", + title: "Invite your team", + description: + "Collaborate with your team by inviting them to your workspace.", + href: "/settings/general", + }, + ]; + + if (intent === "monitoring") { + cards.push({ + id: "status-page", + title: "Create a status page", + description: + "Keep your users informed with a public page showing your system health.", + href: "/status-pages", + }); + } + + if (intent === "status-page") { + cards.push({ + id: "monitor", + title: "Create a monitor", + description: + "Start tracking uptime and performance of your websites and APIs.", + href: "/monitors", + }); + } + + cards.push({ + id: "notifier", + title: "Create a notifier", + description: "Get notified when your website or API is down.", + href: "/notifications", + }); + + return cards; + }, [intent]); + useEffect(() => { if (!callbackUrl) return; - // Validate and normalize the callbackUrl to prevent XSS via javascript: or other dangerous schemes try { const url = new URL(callbackUrl, window.location.origin); if (url.pathname === "/" || url.pathname === "") return; if (url.origin !== window.location.origin) return; if (url.protocol !== "http:" && url.protocol !== "https:") return; - // Navigate using the parsed URL to avoid raw-input parsing discrepancies router.push(`${url.pathname}${url.search}${url.hash}`); } catch { // Malformed URLs are not safe to navigate to } }, [callbackUrl, router]); + const showMonitorForm = + (step === "2" && intent === "monitoring") || + (step === "2" && intent === "both"); + + const showStatusPageForm = + (step === "2" && intent === "status-page") || + (step === "3" && intent === "both"); + return (
Getting Started - Welcome to OpenStatus. Let's get you set up. + Welcome to openstatus. Let's get you set up.
{step === "1" && ( +
+ + What are you looking for? + + + {intents.map((item) => { + const Icon = item.icon; + return ( + + ); + })} + +
+ )} + {showMonitorForm && (
- Step 1 of{" "} - 2 + Step{" "} + + {currentStepNumber} + {" "} + of{" "} + {totalSteps} @@ -198,12 +299,16 @@ export function Client() {
)} - {step === "2" && ( + {showStatusPageForm && (
- Step 2 of{" "} - 2 + Step{" "} + + {currentStepNumber} + {" "} + of{" "} + {totalSteps} + )} + + + + )} + /> + ))} + + {fields.length < 3 && ( + + )} + + )} ); diff --git a/apps/dashboard/src/components/forms/onboarding/learn-from.tsx b/apps/dashboard/src/components/forms/onboarding/question.tsx similarity index 86% rename from apps/dashboard/src/components/forms/onboarding/learn-from.tsx rename to apps/dashboard/src/components/forms/onboarding/question.tsx index 60e1e93d..b6c5c65e 100644 --- a/apps/dashboard/src/components/forms/onboarding/learn-from.tsx +++ b/apps/dashboard/src/components/forms/onboarding/question.tsx @@ -23,18 +23,26 @@ import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { z } from "zod"; -const wantFrom = [ +const sources = [ { - id: "statuspage", - title: "Status Page", + id: "social-media", + title: "Twitter / LinkedIn / Bsky", }, { - id: "uptime-monitoring", - title: "Uptime Monitoring", + id: "github", + title: "GitHub", }, { - id: "both", - title: "Both", + id: "google", + title: "Google Search", + }, + { + id: "recommendation", + title: "Friend / Colleague", + }, + { + id: "blog", + title: "Blog / Article", }, { id: "other", @@ -43,13 +51,13 @@ const wantFrom = [ ] as const; const schema = z.object({ - from: z.string(), + source: z.string().min(1, "Please select an option"), other: z.string().optional(), }); export type FormValues = z.infer; -export function LearnFromForm({ +export function QuestionForm({ onSubmit, defaultValues, className, @@ -62,11 +70,11 @@ export function LearnFromForm({ const form = useForm({ resolver: zodResolver(schema), defaultValues: defaultValues ?? { - from: "", + source: "", other: "", }, }); - const watchFrom = form.watch("from"); + const watchSource = form.watch("source"); function handleSubmit(values: FormValues) { if (isPending) return; @@ -104,7 +112,7 @@ export function LearnFromForm({ > ( @@ -113,7 +121,7 @@ export function LearnFromForm({ defaultValue={field.value} className="grid grid-cols-1 gap-4 sm:grid-cols-2" > - {wantFrom.map((item) => ( + {sources.map((item) => ( @@ -132,7 +140,7 @@ export function LearnFromForm({ )} /> - {watchFrom === "other" && ( + {watchSource === "other" && (