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.
+
+
+
+
+
+
+
+
+
-
+
+
+
);
};
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 (
+
+
+
+ );
+}
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)',