diff --git a/studio/src/components/layout/onboarding-layout.tsx b/studio/src/components/layout/onboarding-layout.tsx index 59932b0147..4d7742232c 100644 --- a/studio/src/components/layout/onboarding-layout.tsx +++ b/studio/src/components/layout/onboarding-layout.tsx @@ -1,11 +1,24 @@ -export interface OnboardingLayoutProps { - children?: React.ReactNode; -} +import { Logo } from '../logo'; +import { Card, CardContent } from '../ui/card'; +import { Stepper } from '../onboarding/stepper'; +import { ONBOARDING_STEPS } from '../onboarding/onboarding-steps'; +import { useOnboarding } from '@/hooks/use-onboarding'; + +export const OnboardingLayout = ({ children, title }: { children?: React.ReactNode; title?: string }) => { + const { currentStep } = useOnboarding(); -export const OnboardingLayout = ({ children }: OnboardingLayoutProps) => { return ( -
-
{children}
+
+
+ + {title &&

{title}

} + +
+
+ + {children} + +
); }; diff --git a/studio/src/components/onboarding/onboarding-container.tsx b/studio/src/components/onboarding/onboarding-container.tsx new file mode 100644 index 0000000000..acdf36cc10 --- /dev/null +++ b/studio/src/components/onboarding/onboarding-container.tsx @@ -0,0 +1,3 @@ +export const OnboardingContainer = ({ children }: { children: React.ReactNode }) => { + return
{children}
; +}; diff --git a/studio/src/components/onboarding/onboarding-navigation.tsx b/studio/src/components/onboarding/onboarding-navigation.tsx new file mode 100644 index 0000000000..0f0ead164b --- /dev/null +++ b/studio/src/components/onboarding/onboarding-navigation.tsx @@ -0,0 +1,71 @@ +import { ArrowLeftIcon, ArrowRightIcon, InfoCircledIcon } from '@radix-ui/react-icons'; +import { Link } from '../ui/link'; +import { Button } from '../ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; + +export const OnboardingNavigation = ({ + backHref, + forward, + forwardLabel = 'Next', + onSkip, +}: { + backHref?: string; + forward: { href: string } | { onClick: () => void; isLoading?: boolean; disabled?: boolean }; + forwardLabel?: string; + onSkip: () => void; +}) => { + return ( +
+
+ + + + + + You can always get back to this wizard from the application. Safe to skip. + +
+
+ {backHref ? ( + + ) : ( + + )} + {'href' in forward ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/studio/src/components/onboarding/onboarding-provider.tsx b/studio/src/components/onboarding/onboarding-provider.tsx index ac1c98c528..f7b677f552 100644 --- a/studio/src/components/onboarding/onboarding-provider.tsx +++ b/studio/src/components/onboarding/onboarding-provider.tsx @@ -14,6 +14,8 @@ import { useSessionStorage } from '@/hooks/use-session-storage'; type Onboarding = { finishedAt?: Date; federatedGraphsCount: number; + slack: boolean; + email: boolean; }; export interface OnboardingState { diff --git a/studio/src/components/onboarding/onboarding-steps.ts b/studio/src/components/onboarding/onboarding-steps.ts new file mode 100644 index 0000000000..f8ab26d64c --- /dev/null +++ b/studio/src/components/onboarding/onboarding-steps.ts @@ -0,0 +1,10 @@ +export interface StepperStep { + number: number; + label: string; +} + +export const ONBOARDING_STEPS: StepperStep[] = [ + { number: 1, label: 'Get started with WunderGraph' }, + { number: 2, label: 'Create your first graph' }, + { number: 3, label: 'Run your services' }, +]; diff --git a/studio/src/components/onboarding/step-1.tsx b/studio/src/components/onboarding/step-1.tsx index d7a5765738..63a532edc2 100644 --- a/studio/src/components/onboarding/step-1.tsx +++ b/studio/src/components/onboarding/step-1.tsx @@ -1,17 +1,50 @@ import { useEffect } from 'react'; -import { Link } from '../ui/link'; -import { Button } from '../ui/button'; import { useOnboarding } from '@/hooks/use-onboarding'; +import { OnboardingContainer } from './onboarding-container'; +import { OnboardingNavigation } from './onboarding-navigation'; import { useMutation } from '@connectrpc/connect-query'; import { createOnboarding } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; import { useRouter } from 'next/router'; import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { useToast } from '../ui/use-toast'; +import { SubmitHandler, useZodForm } from '@/hooks/use-form'; +import { Controller } from 'react-hook-form'; +import { z } from 'zod'; +import { Form } from '../ui/form'; +import { Checkbox } from '../ui/checkbox'; +import { TrafficAnimation } from './traffic-animation'; + +const onboardingSchema = z.object({ + channels: z.object({ + slack: z.boolean(), + email: z.boolean(), + }), +}); + +type OnboardingFormValues = z.infer; + +const WhyListItem = ({ title, text }: { title: string; text: string }) => ( +
  • + +
    + {title} + {text} +
    +
  • +); export const Step1 = () => { const router = useRouter(); const { toast } = useToast(); - const { setStep, setSkipped, setOnboarding } = useOnboarding(); + const { setStep, setSkipped, setOnboarding, onboarding } = useOnboarding(); + + const form = useZodForm({ + mode: 'onChange', + schema: onboardingSchema, + defaultValues: { + channels: onboarding, + }, + }); const { mutate, isPending } = useMutation(createOnboarding, { onSuccess: (d) => { @@ -23,9 +56,12 @@ export const Step1 = () => { return; } + const formValues = form.getValues(); setOnboarding({ federatedGraphsCount: d.federatedGraphsCount, finishedAt: d.finishedAt ? new Date(d.finishedAt) : undefined, + slack: formValues.channels.slack, + email: formValues.channels.email, }); router.push('/onboarding/2'); }, @@ -37,36 +73,96 @@ export const Step1 = () => { }, }); + const onSubmit: SubmitHandler = (data) => { + mutate(data.channels); + }; + useEffect(() => { setStep(1); }, [setStep]); return ( -
    -

    Step 1

    -
    - -
    - - + +
    +
    +

    + In ~3 minutes you will have a federated GraphQL graph + running locally and serving live traffic into Cosmo Cloud platform. +

    +
    + + + +
    +

    What you will do

    +
      + + + +
    + +
    + +
    +

    If you get stuck, how can we reach you?

    +
    + ( + + )} + /> + ( + + )} + /> +
    +
    +
    +
    -
    + + + ); }; diff --git a/studio/src/components/onboarding/step-2.tsx b/studio/src/components/onboarding/step-2.tsx index a8e559873f..c65af338b3 100644 --- a/studio/src/components/onboarding/step-2.tsx +++ b/studio/src/components/onboarding/step-2.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; -import { Link } from '../ui/link'; -import { Button } from '../ui/button'; import { useOnboarding } from '@/hooks/use-onboarding'; +import { OnboardingContainer } from './onboarding-container'; +import { OnboardingNavigation } from './onboarding-navigation'; export const Step2 = () => { const { setStep, setSkipped } = useOnboarding(); @@ -11,21 +11,9 @@ export const Step2 = () => { }, [setStep]); return ( -
    +

    Step 2

    -
    - -
    - - -
    -
    -
    + + ); }; diff --git a/studio/src/components/onboarding/step-3.tsx b/studio/src/components/onboarding/step-3.tsx index 0aff769f79..3aa6678a99 100644 --- a/studio/src/components/onboarding/step-3.tsx +++ b/studio/src/components/onboarding/step-3.tsx @@ -1,18 +1,22 @@ import { useEffect } from 'react'; -import { useRouter } from 'next/router'; -import { useMutation } from '@connectrpc/connect-query'; import { useOnboarding } from '@/hooks/use-onboarding'; +import { OnboardingContainer } from './onboarding-container'; +import { OnboardingNavigation } from './onboarding-navigation'; +import { useMutation } from '@connectrpc/connect-query'; import { finishOnboarding } from '@wundergraph/cosmo-connect/dist/platform/v1/platform-PlatformService_connectquery'; -import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; import { useToast } from '../ui/use-toast'; -import { Link } from '../ui/link'; -import { Button } from '../ui/button'; +import { useRouter } from 'next/router'; +import { EnumStatusCode } from '@wundergraph/cosmo-connect/dist/common/common_pb'; export const Step3 = () => { const router = useRouter(); const { toast } = useToast(); const { setStep, setSkipped, setOnboarding } = useOnboarding(); + useEffect(() => { + setStep(3); + }, [setStep]); + const { mutate, isPending } = useMutation(finishOnboarding, { onSuccess: (d) => { if (d.response?.code !== EnumStatusCode.OK) { @@ -27,6 +31,8 @@ export const Step3 = () => { ...prev, finishedAt: new Date(d.finishedAt), federatedGraphsCount: d.federatedGraphsCount, + slack: Boolean(prev?.slack), + email: Boolean(prev?.email), })); setStep(undefined); @@ -40,32 +46,15 @@ export const Step3 = () => { }, }); - useEffect(() => { - setStep(3); - }, [setStep]); - return ( -
    +

    Step 3

    -
    - -
    - - -
    -
    -
    + mutate({}), isLoading: isPending }} + forwardLabel="Finish" + /> + ); }; diff --git a/studio/src/components/onboarding/stepper.tsx b/studio/src/components/onboarding/stepper.tsx new file mode 100644 index 0000000000..cfddd29c3c --- /dev/null +++ b/studio/src/components/onboarding/stepper.tsx @@ -0,0 +1,63 @@ +import { cn } from '@/lib/utils'; +import { CheckIcon } from '@radix-ui/react-icons'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { useRouter } from 'next/router'; +import type { StepperStep } from './onboarding-steps'; + +export function Stepper({ + steps, + currentStep, + className, +}: { + steps: StepperStep[]; + currentStep: number; + className?: string; +}) { + const router = useRouter(); + + return ( + +
    + {steps.map((step, index) => { + const isCompleted = index < currentStep; + const isCurrent = index === currentStep; + const canNavigate = isCompleted && !isCurrent; + + return ( +
    + {index > 0 && ( +
    +
    +
    + )} + + + + + {step.label} + +
    + ); + })} +
    + + ); +} diff --git a/studio/src/components/onboarding/traffic-animation.tsx b/studio/src/components/onboarding/traffic-animation.tsx new file mode 100644 index 0000000000..b8ab5ac094 --- /dev/null +++ b/studio/src/components/onboarding/traffic-animation.tsx @@ -0,0 +1,261 @@ +import { motion } from 'framer-motion'; +import { Logo } from '../logo'; + +// --- SVG layout --- +const VB_W = 700; +const VB_H = 220; +const CARD_R = 8; +const MONO_FONT = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'; +const EASE_OUT: [number, number, number, number] = [0.25, 0.46, 0.45, 0.94]; +const ease = (duration: number, delay = 0) => ({ duration, delay, ease: EASE_OUT }); + +// Node boxes +const CLIENT = { x: 30, y: 90, w: 110, h: 40 }; +const ROUTER = { x: 280, y: 80, w: 140, h: 60 }; +const PRODUCTS = { x: 560, y: 30, w: 110, h: 40 }; +const REVIEWS = { x: 560, y: 150, w: 110, h: 40 }; + +// Edge endpoints (right/left midpoints of the boxes) +const CLIENT_R: [number, number] = [CLIENT.x + CLIENT.w, CLIENT.y + CLIENT.h / 2]; +const ROUTER_L: [number, number] = [ROUTER.x, ROUTER.y + ROUTER.h / 2]; +const ROUTER_R: [number, number] = [ROUTER.x + ROUTER.w, ROUTER.y + ROUTER.h / 2]; +const PRODUCTS_L: [number, number] = [PRODUCTS.x, PRODUCTS.y + PRODUCTS.h / 2]; +const REVIEWS_L: [number, number] = [REVIEWS.x, REVIEWS.y + REVIEWS.h / 2]; + +const edgePath = (a: [number, number], b: [number, number]) => `M ${a[0]} ${a[1]} L ${b[0]} ${b[1]}`; + +const EDGE_CLIENT_ROUTER = edgePath(CLIENT_R, ROUTER_L); +const EDGE_ROUTER_PRODUCTS = edgePath(ROUTER_R, PRODUCTS_L); +const EDGE_ROUTER_REVIEWS = edgePath(ROUTER_R, REVIEWS_L); + +// --- Timing (all relative to mount, seconds) --- +const T_NODES = 0; +const T_LABELS = 0.6; +const T_EDGES = 1.0; +const T_REQ = 1.3; +const T_FANOUT = 1.8; +const T_RESOLVE = 2.4; +const T_FANIN = 2.7; +const T_MERGED = 3.3; +const T_SETTLE = 3.8; + +const REQ_DUR = 0.5; +const FANOUT_DUR = 0.6; +const PULSE_DUR = 0.6; + +// --- Node --- + +interface NodeProps { + x: number; + y: number; + w: number; + h: number; + label: string; + delay: number; + clipId: string; + showLogo?: boolean; + large?: boolean; +} + +function Node({ x, y, w, h, label, delay, clipId, showLogo, large }: NodeProps) { + const cx = x + w / 2; + const cy = y + h / 2; + const fontSize = large ? 12 : 11; + const textX = x + (showLogo ? 32 : 14); + const textY = y + h / 2 + 1; + + return ( + + + + + + + + {/* Outer box — zoom from center */} + + + {/* Label (+ optional logo) clipped for wipe-in */} + + {showLogo && ( + +
    + +
    +
    + )} + + {label} + +
    +
    + ); +} + +// --- Edge --- + +function Edge({ d }: { d: string }) { + return ( + + ); +} + +// --- Packet --- + +interface PacketProps { + from: [number, number]; + to: [number, number]; + delay: number; + duration: number; +} + +function Packet({ from, to, delay, duration }: PacketProps) { + return ( + + ); +} + +// --- Resolve pulse --- + +function ResolvePulse({ cx, cy, delay }: { cx: number; cy: number; delay: number }) { + return ( + + ); +} + +// --- Root --- + +export function TrafficAnimation() { + return ( +
    + + + + + + + + + + + + + + {/* Edges (behind nodes) */} + + + + + {/* Nodes */} + + + + + + {/* Request: client → router */} + + + {/* Fan-out: router → products + router → reviews */} + + + + {/* Resolve pulses at subgraphs */} + + + + {/* Fan-in: products → router + reviews → router */} + + + + {/* Merged: router → client */} + + +
    + ); +} diff --git a/studio/src/hooks/use-onboarding-navigation.ts b/studio/src/hooks/use-onboarding-navigation.ts index b907617c98..65534a4b62 100644 --- a/studio/src/hooks/use-onboarding-navigation.ts +++ b/studio/src/hooks/use-onboarding-navigation.ts @@ -29,6 +29,8 @@ export const useOnboardingNavigation = () => { setOnboarding({ finishedAt: data.finishedAt ? new Date(data.finishedAt) : undefined, federatedGraphsCount: data.federatedGraphsCount, + slack: data.slack, + email: data.email, }); }, [initialLoadSuccess, data, setOnboarding], diff --git a/studio/src/pages/onboarding/[step].tsx b/studio/src/pages/onboarding/[step].tsx index 362d096401..8beec4d58c 100644 --- a/studio/src/pages/onboarding/[step].tsx +++ b/studio/src/pages/onboarding/[step].tsx @@ -1,27 +1,51 @@ import { OnboardingLayout } from '@/components/layout/onboarding-layout'; +import { ONBOARDING_STEPS } from '@/components/onboarding/onboarding-steps'; import { Step1 } from '@/components/onboarding/step-1'; import { Step2 } from '@/components/onboarding/step-2'; import { Step3 } from '@/components/onboarding/step-3'; import { NextPageWithLayout } from '@/lib/page'; import { useRouter } from 'next/router'; +const normalizeOnboardingStep = (step: string | string[] | undefined) => { + const value = Array.isArray(step) ? step[0] : step; + const parsedStep = Number.parseInt(value ?? '', 10); + + if (!Number.isInteger(parsedStep)) { + return 1; + } + + return Math.min(Math.max(parsedStep, 1), ONBOARDING_STEPS.length); +}; + const OnboardingStep: NextPageWithLayout = () => { const router = useRouter(); - const { step } = router.query; + const stepNumber = normalizeOnboardingStep(router.query.step); + const title = ONBOARDING_STEPS[stepNumber - 1]?.label; - switch (step) { - case '0': - case '1': - return ; - case '2': - return ; - case '3': - return ; + switch (stepNumber) { + case 1: + return ( + + + + ); + case 2: + return ( + + + + ); + case 3: + return ( + + + + ); default: return null; } }; -OnboardingStep.getLayout = (page) => {page}; +OnboardingStep.getLayout = (page) => page; export default OnboardingStep; diff --git a/studio/tailwind.config.js b/studio/tailwind.config.js index 6dd94d02ae..c151a91bf7 100644 --- a/studio/tailwind.config.js +++ b/studio/tailwind.config.js @@ -92,6 +92,9 @@ export default { 950: 'hsl(var(--gray-950))', }, }, + spacing: { + 160: '40rem', + }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)',