diff --git a/apps/web/src/components/AuthForms.tsx b/apps/web/src/components/AuthForms.tsx index 9502a66..419504d 100644 --- a/apps/web/src/components/AuthForms.tsx +++ b/apps/web/src/components/AuthForms.tsx @@ -129,13 +129,19 @@ export function SsoLoginButton({ export function AuthPageShell({ children, subtitle = "Turn mailboxes into issue boards.", + cardClassName, }: { children?: ReactNode; subtitle?: string; + cardClassName?: string; }) { return (
-
+
ServiceBeard -

{subtitle}

+ {subtitle ?

{subtitle}

: null} {children}
diff --git a/apps/web/src/components/OnboardingPopover.module.css b/apps/web/src/components/OnboardingPopover.module.css new file mode 100644 index 0000000..42735ce --- /dev/null +++ b/apps/web/src/components/OnboardingPopover.module.css @@ -0,0 +1,48 @@ +.anchor { + position: relative; + display: inline-flex; +} + +.popover { + position: absolute; + top: calc(100% + var(--space-3)); + right: 0; + width: min(20rem, calc(100vw - var(--space-8))); + padding: var(--space-4); + background: var(--color-surface); + border: var(--border-width) solid var(--color-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + z-index: 50; +} + +.popover::before { + content: ""; + position: absolute; + top: calc(-1 * var(--space-2)); + right: var(--space-6); + width: var(--space-3); + height: var(--space-3); + background: var(--color-surface); + border-top: var(--border-width) solid var(--color-primary); + border-left: var(--border-width) solid var(--color-primary); + transform: rotate(45deg); +} + +.title { + margin: 0 0 var(--space-2); + font-size: var(--text-sm); + font-weight: 700; +} + +.body { + margin: 0 0 var(--space-4); + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: 1.5; +} + +.actions { + display: flex; + justify-content: flex-end; +} diff --git a/apps/web/src/components/OnboardingPopover.tsx b/apps/web/src/components/OnboardingPopover.tsx new file mode 100644 index 0000000..317e519 --- /dev/null +++ b/apps/web/src/components/OnboardingPopover.tsx @@ -0,0 +1,51 @@ +import { Button } from "./Button"; +import styles from "./OnboardingPopover.module.css"; + +interface OnboardingPopoverProps { + open: boolean; + title: string; + children: React.ReactNode; + onDismiss: () => void; +} + +export function OnboardingPopover({ + open, + title, + children, + onDismiss, +}: OnboardingPopoverProps) { + if (!open) return null; + + return ( +
+

+ {title} +

+

{children}

+
+ +
+
+ ); +} + +export function OnboardingAnchor({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/hooks/useAuthEntry.ts b/apps/web/src/hooks/useAuthEntry.ts index a1894d6..832e1c1 100644 --- a/apps/web/src/hooks/useAuthEntry.ts +++ b/apps/web/src/hooks/useAuthEntry.ts @@ -7,13 +7,21 @@ import { getLastUsedSignInMethod, setLastUsedSignInMethod, } from "../lib/lastSignInMethod"; +import { shouldShowWelcome } from "../lib/onboarding"; import { authenticateWithPasskey, passkeyErrorMessage, registerPasskey, } from "../lib/passkey"; +import { appQueries, queryClient, queryKeys } from "../lib/queryClient"; import { normalizeRedirectPath } from "../lib/redirect"; +type AuthUser = { id: string; email: string; name: string | null }; + +function syncAuthAfterLogin(user: AuthUser) { + queryClient.setQueryData(queryKeys.auth.me, { user }); +} + export function useAuthEntry(search: { redirect?: string; error?: string }) { const navigate = useNavigate(); const { data: authConfig, isPending: configLoading } = useQuery({ @@ -32,12 +40,20 @@ export function useAuthEntry(search: { redirect?: string; error?: string }) { const providers = authConfig?.providers ?? []; const configLoaded = !configLoading; - const redirectAfterAuth = () => { + const redirectAfterAuth = async (user: AuthUser) => { const path = normalizeRedirectPath(search.redirect); if (path) { window.location.href = path; return; } + const [{ teams }, { invites }] = await Promise.all([ + queryClient.fetchQuery(appQueries.teams()), + queryClient.fetchQuery(appQueries.pendingInvites()), + ]); + if (shouldShowWelcome(user.id, teams.length, invites.length)) { + navigate({ to: "/welcome" }); + return; + } navigate({ to: "/" }); }; @@ -99,13 +115,14 @@ export function useAuthEntry(search: { redirect?: string; error?: string }) { ...credentials, mode: "login", }); - if ("requiresVerification" in result && result.requiresVerification) { + if ("requiresVerification" in result) { setInfo(result.message); setPendingVerificationEmail(credentials.email); setLoading(false); return; } - redirectAfterAuth(); + syncAuthAfterLogin(result.user); + await redirectAfterAuth(result.user); } catch (err) { if (err instanceof ApiError && err.code === "email_not_verified") { setPendingVerificationEmail(credentials.email); @@ -129,13 +146,14 @@ export function useAuthEntry(search: { redirect?: string; error?: string }) { ...credentials, mode: "signup", }); - if ("requiresVerification" in result && result.requiresVerification) { + if ("requiresVerification" in result) { setInfo(result.message); setPendingVerificationEmail(credentials.email); setLoading(false); return; } - redirectAfterAuth(); + syncAuthAfterLogin(result.user); + await redirectAfterAuth(result.user); } catch (err) { setError(err instanceof Error ? err.message : "Sign-up failed"); setLoading(false); @@ -147,8 +165,9 @@ export function useAuthEntry(search: { redirect?: string; error?: string }) { setLoading(true); setError(""); try { - await authenticateWithPasskey(type); - redirectAfterAuth(); + const result = await authenticateWithPasskey(type); + syncAuthAfterLogin(result.user); + await redirectAfterAuth(result.user); } catch (err) { setError( passkeyErrorMessage( @@ -172,13 +191,14 @@ export function useAuthEntry(search: { redirect?: string; error?: string }) { setPendingVerificationEmail(""); try { const result = await registerPasskey(type, input); - if ("requiresVerification" in result && result.requiresVerification) { + if ("requiresVerification" in result) { setInfo(result.message); setPendingVerificationEmail(input.email); setLoading(false); return; } - redirectAfterAuth(); + syncAuthAfterLogin(result.user); + await redirectAfterAuth(result.user); } catch (err) { setError( passkeyErrorMessage( diff --git a/apps/web/src/lib/onboarding.ts b/apps/web/src/lib/onboarding.ts new file mode 100644 index 0000000..819fd25 --- /dev/null +++ b/apps/web/src/lib/onboarding.ts @@ -0,0 +1,49 @@ +type OnboardingPhase = "welcome" | "project-hint" | "done"; + +const STORAGE_PREFIX = "servicebeard:onboarding:"; + +function storageKey(userId: string) { + return `${STORAGE_PREFIX}${userId}`; +} + +function getOnboardingPhase(userId: string): OnboardingPhase | null { + try { + const value = localStorage.getItem(storageKey(userId)); + if (value === "welcome" || value === "project-hint" || value === "done") { + return value; + } + return null; + } catch { + return null; + } +} + +function setOnboardingPhase(userId: string, phase: OnboardingPhase) { + try { + localStorage.setItem(storageKey(userId), phase); + } catch { + // Ignore quota / private browsing errors. + } +} + +export function shouldShowWelcome( + userId: string, + teamCount: number, + pendingInviteCount: number, +): boolean { + if (teamCount > 0 || pendingInviteCount > 0) return false; + const phase = getOnboardingPhase(userId); + return phase !== "done" && phase !== "project-hint"; +} + +export function markWelcomeComplete(userId: string) { + setOnboardingPhase(userId, "project-hint"); +} + +export function isProjectHintPending(userId: string): boolean { + return getOnboardingPhase(userId) === "project-hint"; +} + +export function markOnboardingDone(userId: string) { + setOnboardingPhase(userId, "done"); +} diff --git a/apps/web/src/lib/queryClient.ts b/apps/web/src/lib/queryClient.ts index 5ea5a2e..0e7ad17 100644 --- a/apps/web/src/lib/queryClient.ts +++ b/apps/web/src/lib/queryClient.ts @@ -50,6 +50,24 @@ type RefreshAppRoutesOptions = { teamId?: string; }; +export function prependCreatedTeamToCache(team: { + id: string; + name: string; + slug: string; +}) { + queryClient.setQueryData<{ + teams: Array<{ id: string; name: string; slug: string; role: string }>; + }>(queryKeys.teams.all, (existing) => { + if (existing?.teams.some((entry) => entry.id === team.id)) { + return existing; + } + + return { + teams: [{ ...team, role: "owner" }, ...(existing?.teams ?? [])], + }; + }); +} + export async function refreshAppRoutes( router: { invalidate: () => Promise }, options: RefreshAppRoutesOptions = {}, diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 91bf97e..69c1ca9 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -20,7 +20,10 @@ import { import { api } from "../lib/api"; import { iconMd } from "../lib/icons"; import type { AppUser, TeamSummary } from "../lib/loaderTypes"; -import { refreshAppRoutes } from "../lib/queryClient"; +import { + prependCreatedTeamToCache, + refreshAppRoutes, +} from "../lib/queryClient"; import styles from "../styles/pages.module.css"; type DashboardPageProps = { @@ -49,7 +52,8 @@ export function DashboardPage({ slug: slugifyName(name), }), onSuccess: async (team) => { - await refreshAppRoutes(router, { pendingInvites: true }); + prependCreatedTeamToCache(team); + await refreshAppRoutes(router, { pendingInvites: true, teamId: team.id }); setShowCreate(false); setName(""); navigate({ to: "/teams/$teamId/projects", params: { teamId: team.id } }); diff --git a/apps/web/src/pages/ProjectsPage.tsx b/apps/web/src/pages/ProjectsPage.tsx index b018a77..c871b3b 100644 --- a/apps/web/src/pages/ProjectsPage.tsx +++ b/apps/web/src/pages/ProjectsPage.tsx @@ -20,6 +20,10 @@ import { Card } from "../components/Card"; import { CreateProjectWizard } from "../components/CreateProjectWizard"; import { EmptyIcon } from "../components/EmptyIcon"; import { Layout } from "../components/Layout"; +import { + OnboardingAnchor, + OnboardingPopover, +} from "../components/OnboardingPopover"; import { ProviderProjectLabel } from "../components/ProviderProjectLabel"; import { TableRowActionLink } from "../components/TableRowAction"; import { api } from "../lib/api"; @@ -29,6 +33,7 @@ import { } from "../lib/entitlements"; import { clearFieldError, handleMutationError } from "../lib/formErrors"; import type { ProjectsLoaderData } from "../lib/loaderTypes"; +import { isProjectHintPending, markOnboardingDone } from "../lib/onboarding"; import { defaultProjectSettingsForm, formToCreateInput, @@ -49,6 +54,7 @@ export function ProjectsPage() { wizardStep?: string; githubInstallationId?: string; githubAppError?: string; + onboarding?: string; }; const navigate = useNavigate(); const router = useRouter(); @@ -66,6 +72,7 @@ export function ProjectsPage() { const [error, setError] = useState(""); const [fieldErrors, setFieldErrors] = useState>({}); const [limitDialogOpen, setLimitDialogOpen] = useState(false); + const [showProjectHint, setShowProjectHint] = useState(false); const projectsPageContext = { teamId, projectsCount: projects.length, @@ -90,7 +97,23 @@ export function ProjectsPage() { setShowCreate(true); }; + const dismissProjectHint = () => { + markOnboardingDone(user.id); + setShowProjectHint(false); + if (search.onboarding) { + navigate({ + to: "/teams/$teamId/projects", + params: { teamId }, + search: {}, + replace: true, + }); + } + }; + const tryOpenCreate = () => { + if (showProjectHint) { + dismissProjectHint(); + } if (atProjectLimit) { if (LimitReachedDialog && entitlements) { setLimitDialogOpen(true); @@ -106,6 +129,12 @@ export function ProjectsPage() { openCreate(); }; + useEffect(() => { + if (search.onboarding === "project" || isProjectHintPending(user.id)) { + setShowProjectHint(true); + } + }, [search.onboarding, user.id]); + useEffect(() => { const installationId = search.githubInstallationId; const shouldOpen = @@ -214,9 +243,21 @@ export function ProjectsPage() {

All projects

{sectionDescription}

- + + + + A project links your support inbox to an issue board. Click here to + connect a mailbox and start syncing email. + + {error && ( diff --git a/apps/web/src/pages/WelcomePage.tsx b/apps/web/src/pages/WelcomePage.tsx new file mode 100644 index 0000000..01b2a55 --- /dev/null +++ b/apps/web/src/pages/WelcomePage.tsx @@ -0,0 +1,242 @@ +import { slugifyName } from "@servicebeard/shared"; +import { useMutation } from "@tanstack/react-query"; +import { useLoaderData, useNavigate } from "@tanstack/react-router"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import { useState } from "react"; +import { AuthPageShell } from "../components/AuthForms"; +import { Button } from "../components/Button"; +import { Input } from "../components/Input"; +import { api } from "../lib/api"; +import { iconMd } from "../lib/icons"; +import type { AppUser } from "../lib/loaderTypes"; +import { NAV_ICONS } from "../lib/navigation"; +import { markWelcomeComplete } from "../lib/onboarding"; +import { + appQueries, + prependCreatedTeamToCache, + queryClient, +} from "../lib/queryClient"; +import styles from "../styles/pages.module.css"; + +const STEPS = [ + { + id: "welcome", + title: "Welcome", + description: "Welcome to ServiceBeard", + }, + { + id: "how-it-works", + title: "How it works", + description: "Teams and projects", + }, + { + id: "team", + title: "Your team", + description: "Create your team", + }, +] as const; + +function WelcomeSlide({ userName }: { userName: string | null }) { + const greeting = userName ? `Welcome, ${userName}.` : "Welcome."; + + return ( +
+

+ {greeting} We'll walk you through a quick setup to get ServiceBeard + configured — starting with your first team. +

+
+ ); +} + +function HowItWorksSlide() { + const TeamsIcon = NAV_ICONS.teams; + const ProjectsIcon = NAV_ICONS.projects; + + return ( +
+
    +
  1. + + + +

    + Teams manage one or more projects. +

    +
  2. +
  3. + + + +

    + Projects connect a mailbox to your issue board. +

    +
  4. +
+
+ ); +} + +type CreateTeamSlideProps = { + name: string; + onNameChange: (value: string) => void; + error: string; +}; + +function CreateTeamSlide({ name, onNameChange, error }: CreateTeamSlideProps) { + return ( +
+

+ Pick a name for your team that will hold your projects. You can invite + other members later from the Members page. +

+ onNameChange(e.target.value)} + autoFocus + /> + {error ? ( +
+ {error} +
+ ) : null} +
+ ); +} + +export function WelcomePage() { + const { user } = useLoaderData({ from: "/welcome" }) as { user: AppUser }; + const [stepIndex, setStepIndex] = useState(0); + const [teamName, setTeamName] = useState(""); + const [error, setError] = useState(""); + const navigate = useNavigate(); + const step = STEPS[stepIndex]!; + const isLast = stepIndex === STEPS.length - 1; + const canContinue = step.id !== "team" ? true : teamName.trim().length > 0; + + const createTeam = useMutation({ + mutationFn: () => + api.createTeam({ + name: teamName.trim(), + slug: slugifyName(teamName), + }), + onSuccess: async (team) => { + markWelcomeComplete(user.id); + prependCreatedTeamToCache(team); + await Promise.all([ + queryClient.fetchQuery(appQueries.teams()), + queryClient.fetchQuery(appQueries.team(team.id)), + queryClient.fetchQuery(appQueries.projects(team.id)), + ]); + navigate({ + to: "/teams/$teamId/projects", + params: { teamId: team.id }, + search: { onboarding: "project" }, + }); + }, + onError: (err) => { + setError(err instanceof Error ? err.message : "Could not create team"); + }, + }); + + const goBack = () => { + if (stepIndex > 0) setStepIndex((index) => index - 1); + }; + + const handleContinue = () => { + if (!canContinue || createTeam.isPending) return; + if (!isLast) { + setStepIndex((index) => index + 1); + return; + } + setError(""); + createTeam.mutate(); + }; + + return ( + +
{ + e.preventDefault(); + handleContinue(); + }} + > + + +
+

{step.description}

+ {step.id === "welcome" && } + {step.id === "how-it-works" && } + {step.id === "team" && ( + + )} +
+ +
0 ? styles.formActionsSpread : "", + ] + .filter(Boolean) + .join(" ")} + > + {stepIndex > 0 ? ( + + ) : null} + + +
+
+
+ ); +} diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index d8ab17a..f9ec050 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -30,6 +30,7 @@ import { teamRedirectRoute, teamSettingsRoute, verifyEmailRoute, + welcomeRoute, } from "./routes/index"; import { rootRoute } from "./routes/root.tsx"; @@ -49,6 +50,7 @@ const routeTree = rootRoute.addChildren([ docsLinearRoute, docsSelfHostRoute, githubAppInstallCompleteRoute, + welcomeRoute, dashboardRoute, adminOverviewRoute, adminHealthRoute, diff --git a/apps/web/src/routes/index.ts b/apps/web/src/routes/index.ts index 2d440a0..e097df6 100644 --- a/apps/web/src/routes/index.ts +++ b/apps/web/src/routes/index.ts @@ -8,6 +8,7 @@ import { PROJECT_SECTION_LABELS, type ProjectSection, } from "../lib/navigation"; +import { shouldShowWelcome } from "../lib/onboarding"; import { appQueries, queryClient } from "../lib/queryClient"; import { isPlatformAdminTeamAccess } from "../lib/teamAccess"; import { AcceptInvitePage } from "../pages/AcceptInvitePage"; @@ -35,6 +36,7 @@ import { SignupPage } from "../pages/SignupPage"; import { TeamPage } from "../pages/TeamPage"; import { TeamSettingsPage } from "../pages/TeamSettingsPage"; import { VerifyEmailPage } from "../pages/VerifyEmailPage"; +import { WelcomePage } from "../pages/WelcomePage"; import { rootRoute } from "./root.tsx"; async function requireUser() { @@ -212,6 +214,24 @@ export const githubAppInstallCompleteRoute = createRoute({ head: routeHead("GitHub App"), }); +export const welcomeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/welcome", + component: WelcomePage, + head: routeHead("Welcome"), + loader: async () => { + const user = await requireUser(); + const [{ teams }, { invites: pendingInvites }] = await Promise.all([ + queryClient.ensureQueryData(appQueries.teams()), + queryClient.ensureQueryData(appQueries.pendingInvites()), + ]); + if (!shouldShowWelcome(user.id, teams.length, pendingInvites.length)) { + throw redirect({ to: "/" }); + } + return { user }; + }, +}); + export const dashboardRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", @@ -245,6 +265,9 @@ export const dashboardRoute = createRoute({ queryClient.ensureQueryData(appQueries.teams()), queryClient.ensureQueryData(appQueries.pendingInvites()), ]); + if (shouldShowWelcome(user.id, teams.length, pendingInvites.length)) { + throw redirect({ to: "/welcome" }); + } return { landing: false as const, user, teams, pendingInvites }; }, }); diff --git a/apps/web/src/styles/pages.module.css b/apps/web/src/styles/pages.module.css index 44d4837..4e05249 100644 --- a/apps/web/src/styles/pages.module.css +++ b/apps/web/src/styles/pages.module.css @@ -2029,3 +2029,155 @@ grid-template-columns: 1fr; } } + +/* ── Welcome onboarding wizard ── */ + +.welcomeCard { + max-width: 32rem; + text-align: left; +} + +.welcomeCard .loginLogo, +.welcomeCard .title, +.welcomeCard .subtitle { + text-align: center; +} + +.welcomeCard .subtitle { + margin-bottom: var(--space-6); +} + +.welcomeCard:not(:has(.subtitle)) .title { + margin-bottom: var(--space-6); +} + +.welcomeWizard { + width: 100%; + min-width: 0; + gap: var(--space-5); +} + +.welcomeProgress { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.welcomeProgressMeta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-3); + font-size: var(--text-xs); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-muted); +} + +.welcomeProgressTitle { + font-size: var(--text-sm); + font-weight: 700; + letter-spacing: normal; + text-transform: none; + color: var(--color-text); +} + +.welcomeProgressTrack { + display: flex; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.welcomeProgressSegment { + flex: 1; + height: 0.25rem; + border-radius: var(--radius-full); + background: var(--color-border); +} + +.welcomeProgressSegmentActive { + background: var(--color-primary); +} + +.welcomePanel { + display: flex; + flex-direction: column; + gap: var(--space-4); + padding: var(--space-5); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg-subtle); +} + +.welcomePanelTitle { + margin: 0; + font-size: var(--text-lg); + font-weight: 700; + letter-spacing: -0.02em; +} + +.welcomeSlide { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.welcomeIconWrap { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.75rem; + height: 2.75rem; + border-radius: var(--radius-full); + background: var(--color-primary-subtle); + color: var(--color-primary-deep); +} + +.formActionsSpread { + justify-content: space-between; +} + +.welcomeLead { + margin: 0; + font-size: var(--text-base); + line-height: 1.6; +} + +.welcomeHint { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: 1.5; +} + +.welcomeStepsList { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.welcomeStepsList li { + display: flex; + align-items: center; + gap: var(--space-3); + font-size: var(--text-sm); + line-height: 1.5; +} + +.welcomeStepIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-muted); +} + +.welcomeStepText { + margin: 0; +}