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 (
-
+

Service
Beard
-
{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}
-