diff --git a/apps/web/__tests__/unit/checkout-return.test.ts b/apps/web/__tests__/unit/checkout-return.test.ts new file mode 100644 index 00000000000..126ab0c8121 --- /dev/null +++ b/apps/web/__tests__/unit/checkout-return.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { resolveCheckoutReturnUrls } from "@/lib/checkout-return"; + +describe("resolveCheckoutReturnUrls", () => { + it("returns null without a return path", () => { + expect(resolveCheckoutReturnUrls("https://cap.so", undefined)).toBeNull(); + expect(resolveCheckoutReturnUrls("https://cap.so", "")).toBeNull(); + expect(resolveCheckoutReturnUrls("https://cap.so", 42)).toBeNull(); + }); + + it("comes back to the same page after checkout", () => { + expect( + resolveCheckoutReturnUrls( + "https://cap.so/", + "/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc", + ), + ).toEqual({ + successUrl: + "https://cap.so/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc&upgrade=true&session_id={CHECKOUT_SESSION_ID}", + cancelUrl: + "https://cap.so/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc", + }); + expect( + resolveCheckoutReturnUrls("https://cap.so", "/onboarding/upload#drop"), + ).toEqual({ + successUrl: + "https://cap.so/onboarding/upload?upgrade=true&session_id={CHECKOUT_SESSION_ID}#drop", + cancelUrl: "https://cap.so/onboarding/upload#drop", + }); + }); + + it("never leaves the app", () => { + expect( + resolveCheckoutReturnUrls("https://cap.so", "https://evil.example/x"), + ).toEqual({ + successUrl: + "https://cap.so/dashboard?upgrade=true&session_id={CHECKOUT_SESSION_ID}", + cancelUrl: "https://cap.so/dashboard", + }); + expect( + resolveCheckoutReturnUrls("https://cap.so", "//evil.example/x"), + ).toEqual({ + successUrl: + "https://cap.so/dashboard?upgrade=true&session_id={CHECKOUT_SESSION_ID}", + cancelUrl: "https://cap.so/dashboard", + }); + }); +}); diff --git a/apps/web/__tests__/unit/onboarding-flow.test.ts b/apps/web/__tests__/unit/onboarding-flow.test.ts new file mode 100644 index 00000000000..a3250e1a104 --- /dev/null +++ b/apps/web/__tests__/unit/onboarding-flow.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from "vitest"; +import { + maskEmail, + needsOnboarding, + onboardingContinuePath, + onboardingHrefForIntent, + onboardingIntentFromNextPath, + onboardingProgressIndex, + onboardingThemeFromCookie, + resolveOnboardingStep, + resolveOnboardingTheme, + splitFullName, +} from "@/app/(org)/onboarding/onboarding-flow"; + +describe("needsOnboarding", () => { + it("requires a name", () => { + expect(needsOnboarding({ name: null, onboardingSteps: null })).toBe(true); + expect(needsOnboarding({ name: " ", onboardingSteps: null })).toBe(true); + }); + + it("keeps new accounts in onboarding until they pick a start", () => { + expect( + needsOnboarding({ name: "Ada", onboardingSteps: { getStarted: false } }), + ).toBe(true); + expect( + needsOnboarding({ name: "Ada", onboardingSteps: { getStarted: true } }), + ).toBe(false); + }); + + it("leaves existing named accounts alone", () => { + expect(needsOnboarding({ name: "Ada", onboardingSteps: null })).toBe(false); + expect(needsOnboarding({ name: "Ada", onboardingSteps: {} })).toBe(false); + }); +}); + +describe("resolveOnboardingStep", () => { + it("sends unnamed users to the name step", () => { + expect(resolveOnboardingStep("start", { name: null })).toEqual({ + kind: "redirect", + step: "welcome", + }); + expect(resolveOnboardingStep("welcome", { name: null })).toEqual({ + kind: "render", + step: "welcome", + }); + }); + + it("moves named users past the name step", () => { + expect(resolveOnboardingStep("welcome", { name: "Ada" })).toEqual({ + kind: "redirect", + step: "start", + }); + expect(resolveOnboardingStep(undefined, { name: "Ada" })).toEqual({ + kind: "redirect", + step: "start", + }); + }); + + it("renders the start and path steps", () => { + for (const step of ["start", "loom", "record", "upload"] as const) { + expect(resolveOnboardingStep(step, { name: "Ada" })).toEqual({ + kind: "render", + step, + }); + } + }); + + it("redirects the old step urls", () => { + expect( + resolveOnboardingStep("organization-setup", { name: "Ada" }), + ).toEqual({ kind: "redirect", step: "start" }); + expect(resolveOnboardingStep("custom-domain", { name: "Ada" })).toEqual({ + kind: "redirect", + step: "start", + }); + expect(resolveOnboardingStep("invite-team", { name: "Ada" })).toEqual({ + kind: "redirect", + step: "start", + }); + expect(resolveOnboardingStep("download", { name: "Ada" })).toEqual({ + kind: "redirect", + step: "record", + }); + expect(resolveOnboardingStep("nope", { name: "Ada" })).toEqual({ + kind: "redirect", + step: "start", + }); + }); +}); + +describe("onboardingIntentFromNextPath", () => { + it("reads a Loom import link", () => { + expect( + onboardingIntentFromNextPath( + "/dashboard/import/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc1234567", + ), + ).toEqual({ + kind: "loom", + loomUrl: "https://www.loom.com/share/abc1234567", + bulk: false, + }); + expect( + onboardingIntentFromNextPath("/dashboard/import/loom?mode=csv"), + ).toEqual({ kind: "loom", loomUrl: undefined, bulk: true }); + }); + + it("reads upload and record destinations", () => { + expect(onboardingIntentFromNextPath("/dashboard/import/file")).toEqual({ + kind: "upload", + }); + expect(onboardingIntentFromNextPath("/dashboard/import")).toEqual({ + kind: "upload", + }); + expect(onboardingIntentFromNextPath("/dashboard/caps/record")).toEqual({ + kind: "record", + }); + }); + + it("keeps other destinations to continue to afterwards", () => { + expect( + onboardingIntentFromNextPath("/dashboard/settings/billing?tab=plan"), + ).toEqual({ + kind: "elsewhere", + path: "/dashboard/settings/billing?tab=plan", + }); + }); + + it("ignores the dashboard home, onboarding and other origins", () => { + expect(onboardingIntentFromNextPath(undefined)).toBeNull(); + expect(onboardingIntentFromNextPath("/dashboard")).toBeNull(); + expect(onboardingIntentFromNextPath("/dashboard/caps")).toBeNull(); + expect(onboardingIntentFromNextPath("/onboarding/start")).toBeNull(); + expect(onboardingIntentFromNextPath("/invite/abc123")).toBeNull(); + expect(onboardingIntentFromNextPath("https://evil.example/x")).toBeNull(); + expect(onboardingIntentFromNextPath("//evil.example/x")).toBeNull(); + }); +}); + +describe("onboarding hrefs", () => { + it("routes each intent to its step", () => { + expect( + onboardingHrefForIntent({ + kind: "loom", + loomUrl: "https://www.loom.com/share/abc1234567", + bulk: true, + }), + ).toBe( + "/onboarding/loom?url=https%3A%2F%2Fwww.loom.com%2Fshare%2Fabc1234567&bulk=1", + ); + expect(onboardingHrefForIntent({ kind: "upload" })).toBe( + "/onboarding/upload", + ); + expect(onboardingHrefForIntent({ kind: "record" })).toBe( + "/onboarding/record", + ); + expect( + onboardingHrefForIntent({ kind: "elsewhere", path: "/dashboard/spaces" }), + ).toBe("/onboarding/start"); + expect(onboardingHrefForIntent(null)).toBe("/onboarding/start"); + }); + + it("continues to the remembered destination", () => { + expect( + onboardingContinuePath({ kind: "elsewhere", path: "/dashboard/spaces" }), + ).toBe("/dashboard/spaces"); + expect(onboardingContinuePath(null)).toBe("/dashboard/caps"); + }); + + it("maps steps onto three progress marks", () => { + expect(onboardingProgressIndex("welcome")).toBe(0); + expect(onboardingProgressIndex("start")).toBe(1); + expect(onboardingProgressIndex("loom")).toBe(2); + expect(onboardingProgressIndex("upload")).toBe(2); + }); +}); + +describe("splitFullName", () => { + it("splits the first word from the rest", () => { + expect(splitFullName(" Ada Lovelace King ")).toEqual({ + firstName: "Ada", + lastName: "Lovelace King", + }); + expect(splitFullName("Cher")).toEqual({ firstName: "Cher", lastName: "" }); + }); +}); + +describe("maskEmail", () => { + it("hides most of the mailbox name", () => { + expect(maskEmail("richie@cap.so")).toBe("ri••••@cap.so"); + expect(maskEmail("jo@cap.so")).toBe("j•••@cap.so"); + expect(maskEmail("nadia.1791348130278@example.com")).toBe( + "na•••••@example.com", + ); + expect(maskEmail("not-an-email")).toBe("not-an-email"); + }); +}); + +describe("onboarding theme", () => { + it("follows the device unless the app theme was chosen", () => { + expect(onboardingThemeFromCookie(undefined)).toBe("system"); + expect(onboardingThemeFromCookie("purple")).toBe("system"); + expect(onboardingThemeFromCookie("dark")).toBe("dark"); + expect(onboardingThemeFromCookie("light")).toBe("light"); + }); + + it("resolves the device preference", () => { + expect(resolveOnboardingTheme("system", true)).toBe("dark"); + expect(resolveOnboardingTheme("system", false)).toBe("light"); + expect(resolveOnboardingTheme("light", true)).toBe("light"); + expect(resolveOnboardingTheme("dark", false)).toBe("dark"); + }); +}); diff --git a/apps/web/__tests__/unit/onboarding-organization-authorization.test.ts b/apps/web/__tests__/unit/onboarding-organization-authorization.test.ts index d1029fc48c0..b6f27d0cbee 100644 --- a/apps/web/__tests__/unit/onboarding-organization-authorization.test.ts +++ b/apps/web/__tests__/unit/onboarding-organization-authorization.test.ts @@ -108,19 +108,23 @@ function makeFixture( const database = { select: (columns?: Record) => ({ from: (table: MySqlTable) => ({ - where: async (condition: SQL | undefined) => - rowsFor(table) - .filter((row) => matchesRow(condition, row)) - .map((row) => - columns - ? Object.fromEntries( - Object.entries(columns).map(([key, column]) => [ - key, - row[column.name], - ]), - ) - : { ...row }, - ), + where: (condition: SQL | undefined) => { + const result = Promise.resolve( + rowsFor(table) + .filter((row) => matchesRow(condition, row)) + .map((row) => + columns + ? Object.fromEntries( + Object.entries(columns).map(([key, column]) => [ + key, + row[column.name], + ]), + ) + : { ...row }, + ), + ); + return Object.assign(result, { for: () => result }); + }, }), }), update: (table: MySqlTable) => ({ @@ -422,7 +426,92 @@ describe("onboarding organization authorization", () => { expect(fixture.user.onboardingSteps).toMatchObject({ organizationSetup: true, download: true, + getStarted: true, }); }, ); }); + +describe("onboarding get started", () => { + it("finishes onboarding and personalizes an owner's default organization", async () => { + const fixture = makeFixture({ + user: { name: "Jane", onboardingSteps: { getStarted: false } }, + organization: { name: "My Organization", ownerId: USER_ID }, + }); + + await fixture.run((service) => service.getStarted({ path: "loom" })); + + expect(fixture.organization.name).toBe("Jane's Organization"); + expect(fixture.organizations).toHaveLength(1); + expect(fixture.user).toMatchObject({ + activeOrganizationId: ORGANIZATION_ID, + defaultOrgId: DEFAULT_ORGANIZATION_ID, + onboardingSteps: { + welcome: true, + organizationSetup: true, + customDomain: true, + inviteTeam: true, + download: true, + getStarted: true, + }, + }); + expect(fixture.user.onboarding_completed_at).toBeInstanceOf(Date); + }); + + it("leaves a team's organization untouched for members", async () => { + const fixture = makeFixture({ + user: { onboardingSteps: { getStarted: false } }, + organization: { name: "My Organization" }, + }); + + await fixture.run((service) => service.getStarted({ path: "record" })); + + expect(fixture.organization.name).toBe("My Organization"); + expect(fixture.organizations).toHaveLength(1); + expect(fixture.memberships).toEqual([]); + expect(fixture.user).toMatchObject({ + activeOrganizationId: ORGANIZATION_ID, + onboardingSteps: { getStarted: true }, + }); + }); + + it.each([ + { scenario: "no organization", organizationExists: false }, + { scenario: "a deleted organization", organizationExists: true }, + ])("creates an organization when the user has $scenario", async (input) => { + const fixture = makeFixture({ + organizationExists: input.organizationExists, + organization: { ownerId: USER_ID, tombstoneAt: new Date() }, + user: { + name: "Jane", + activeOrganizationId: input.organizationExists + ? ORGANIZATION_ID + : Organisation.OrganisationId.make(""), + defaultOrgId: null, + onboardingSteps: { getStarted: false }, + }, + }); + + await fixture.run((service) => service.getStarted({ path: "upload" })); + + const created = fixture.organizations.find( + (organization) => organization.id !== ORGANIZATION_ID, + ); + expect(created).toMatchObject({ + name: "Jane's Organization", + ownerId: USER_ID, + }); + expect(fixture.memberships).toEqual([ + expect.objectContaining({ + organizationId: created?.id, + userId: USER_ID, + role: "owner", + }), + ]); + expect(fixture.user).toMatchObject({ + activeOrganizationId: created?.id, + defaultOrgId: created?.id, + onboardingSteps: { getStarted: true }, + }); + }); +}); diff --git a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog.tsx b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog.tsx index d25b622b011..f105ac357a6 100644 --- a/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog.tsx +++ b/apps/web/app/(org)/dashboard/caps/components/web-recorder-dialog/web-recorder-dialog.tsx @@ -56,8 +56,12 @@ const waitForNextFrame = () => window.requestAnimationFrame(() => resolve()); }); -export const WebRecorderDialog = () => { - const [open, setOpen] = useState(false); +export const WebRecorderDialog = ({ + defaultOpen = false, +}: { + defaultOpen?: boolean; +} = {}) => { + const [open, setOpen] = useState(defaultOpen); const [settingsOpen, setSettingsOpen] = useState(false); const [howItWorksOpen, setHowItWorksOpen] = useState(false); const [recordingMode, setRecordingMode] = diff --git a/apps/web/app/(org)/dashboard/caps/record/RecordVideoPage.tsx b/apps/web/app/(org)/dashboard/caps/record/RecordVideoPage.tsx index 9f8537e51f3..8f0bd95f280 100644 --- a/apps/web/app/(org)/dashboard/caps/record/RecordVideoPage.tsx +++ b/apps/web/app/(org)/dashboard/caps/record/RecordVideoPage.tsx @@ -11,7 +11,11 @@ import { CHROME_EXTENSION_BUTTON_CLASS } from "@/lib/chrome-extension"; import { FREE_PLAN_MAX_RECORDING_MS } from "../components/web-recorder-dialog/web-recorder-constants"; import { WebRecorderDialog } from "../components/web-recorder-dialog/web-recorder-dialog"; -export const RecordVideoPage = () => { +export const RecordVideoPage = ({ + openBrowserRecorder = false, +}: { + openBrowserRecorder?: boolean; +}) => { const checkingRef = useRef | null>(null); const openDesktop = useCallback(() => { @@ -62,7 +66,7 @@ export const RecordVideoPage = () => { Open Cap Desktop

or

- + ; +export default async function RecordVideoRoute({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { recorder } = await searchParams; + return ; } diff --git a/apps/web/app/(org)/dashboard/import/import-media.ts b/apps/web/app/(org)/dashboard/import/import-media.ts index 3a2a4ab7dce..d80b0e4ec6d 100644 --- a/apps/web/app/(org)/dashboard/import/import-media.ts +++ b/apps/web/app/(org)/dashboard/import/import-media.ts @@ -20,11 +20,13 @@ export async function importMediaFile({ folderId, orgId, setUploadStatus, + showSuccessToast = true, }: { file: File; folderId?: Folder.FolderId; orgId: Organisation.OrganisationId; setUploadStatus: (state: UploadStatus | undefined) => void; + showSuccessToast?: boolean; }) { const imageContentType = getSupportedImageContentType(file); @@ -44,6 +46,7 @@ export async function importMediaFile({ folderId, orgId, setUploadStatus, + showSuccessToast, ); } @@ -116,6 +119,7 @@ async function uploadVideoForServerProcessing( folderId: Folder.FolderId | undefined, orgId: Organisation.OrganisationId, setUploadStatus: (state: UploadStatus | undefined) => void, + showSuccessToast: boolean, ) { try { setUploadStatus({ status: "parsing" }); @@ -265,9 +269,11 @@ async function uploadVideoForServerProcessing( } setUploadStatus(undefined); - toast.success( - "Video uploaded! Processing will continue in the background.", - ); + if (showSuccessToast) { + toast.success( + "Video uploaded! Processing will continue in the background.", + ); + } return true; } catch (err) { console.error("Video upload failed", err); diff --git a/apps/web/app/(org)/dashboard/layout.tsx b/apps/web/app/(org)/dashboard/layout.tsx index bd9ceb66594..156a6cedd94 100644 --- a/apps/web/app/(org)/dashboard/layout.tsx +++ b/apps/web/app/(org)/dashboard/layout.tsx @@ -9,6 +9,7 @@ import { AuthContextProvider } from "@/app/Layout/AuthContext"; import { resolveCurrentUser } from "@/app/Layout/current-user"; import { runPromise } from "@/lib/server"; import { getShareableLinkUsage } from "@/lib/shareable-link-quota"; +import { needsOnboarding } from "../onboarding/onboarding-flow"; import DashboardInner from "./_components/DashboardInner"; import { DashboardPasteImport } from "./_components/DashboardPasteImport"; import MobileTab from "./_components/MobileTab"; @@ -52,9 +53,7 @@ export default async function DashboardLayout({ const pendingInviteId = await getPendingInviteIdForUser(user.email); if (pendingInviteId) redirect(`/invite/${pendingInviteId}`); - if (!user.name || user.name.length === 0) { - redirect("/onboarding/welcome"); - } + if (needsOnboarding(user)) redirect("/onboarding"); let organizationSelect: Organization[] = []; let userCapsCount: number | null = 0; diff --git a/apps/web/app/(org)/invite/[inviteId]/InviteAccept.tsx b/apps/web/app/(org)/invite/[inviteId]/InviteAccept.tsx index 3768f4f1644..188f4884562 100644 --- a/apps/web/app/(org)/invite/[inviteId]/InviteAccept.tsx +++ b/apps/web/app/(org)/invite/[inviteId]/InviteAccept.tsx @@ -1,121 +1,257 @@ "use client"; -import type { userSelectProps } from "@cap/database/auth/session"; -import { Button, Logo } from "@cap/ui"; -import { LogOut } from "lucide-react"; +import { ArrowRight, LoaderCircle } from "lucide-react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { signOut } from "next-auth/react"; -import { useState } from "react"; +import { type CSSProperties, useState } from "react"; import { toast } from "sonner"; +import { resetUser, trackEvent } from "@/app/utils/analytics"; +import { ThemeToggle } from "../../onboarding/components/PaperRoot"; +import { CapWordmark, Explainer } from "../../onboarding/components/paper"; +import { + StepLede, + StepPage, + StepTitle, +} from "../../onboarding/components/StepChrome"; +import { JoinScene } from "../../onboarding/components/scenes"; +import { clearOnboardingNextPath } from "../../onboarding-next"; type InviteAcceptProps = { inviteId: string; organizationName: string; inviterName: string; - user: typeof userSelectProps | null; + maskedEmail: string; + invitedInitial: string; + signedInEmail: string | null; + emailMatches: boolean; + needsOnboarding: boolean; }; export function InviteAccept({ inviteId, organizationName, inviterName, - user, + maskedEmail, + invitedInitial, + signedInEmail, + emailMatches, + needsOnboarding, }: InviteAcceptProps) { const router = useRouter(); - const [isLoading, setIsLoading] = useState(false); + const [pending, setPending] = useState< + "accept" | "decline" | "switch" | null + >(null); + const invitePath = `/invite/${inviteId}`; + const signedIn = signedInEmail !== null; + const inviterFirstName = inviterName.trim().split(/\s+/)[0] ?? inviterName; - const handleAccept = async () => { - setIsLoading(true); + const respond = async (action: "accept" | "decline") => { + setPending(action); try { - if (!user) { - router.push(`/login?next=/invite/${inviteId}`); - return; - } - - const response = await fetch("/api/invite/accept", { + const response = await fetch(`/api/invite/${action}`, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ inviteId }), }); - if (response.ok) { - toast.success("Invite accepted successfully"); - router.push("/dashboard"); - } else { - const error = await response.text(); - toast.error(`Failed to accept invite: ${error}`); - } - } catch (error) { - console.error("Error accepting invite:", error); - toast.error("An error occurred while accepting the invite"); - } finally { - setIsLoading(false); - } - }; - - const handleDecline = async () => { - setIsLoading(true); - try { - if (!user) { - router.push(`/login?next=/invite/${inviteId}`); + if (!response.ok) { + const message = + response.status === 403 + ? "This invite was sent to a different email address." + : response.status === 404 + ? "This invite is no longer available. Ask your teammate to send a new one." + : "Something went wrong. Please try again."; + toast.error(message); + setPending(null); return; } - const response = await fetch("/api/invite/decline", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ inviteId }), - }); - - if (response.ok) { - toast.success("Invite declined"); - router.push("/"); + clearOnboardingNextPath(); + if (action === "accept") { + trackEvent("invite_accepted"); + router.push(needsOnboarding ? "/onboarding" : "/dashboard/caps"); } else { - const error = await response.text(); - toast.error(`Failed to decline invite: ${error}`); + trackEvent("invite_declined"); + router.push("/dashboard"); } - } catch (error) { - console.error("Error declining invite:", error); - toast.error("An error occurred while declining the invite"); - } finally { - setIsLoading(false); + router.refresh(); + } catch { + toast.error("Something went wrong. Please try again."); + setPending(null); } }; + const switchAccount = () => { + setPending("switch"); + resetUser(); + signOut({ callbackUrl: `/login?next=${encodeURIComponent(invitePath)}` }); + }; + return ( -
-
- -

- You're invited to join {organizationName} on Cap -

-

- {inviterName} invited you to join their organization on Cap. -

-
- - + <> +
+ +
+ {signedIn && ( + + {signedInEmail} + + )} +
-
- {!user && ( - - )} -
+ +
+ +
+
+
+ + {inviterFirstName.charAt(0).toUpperCase()} + +

+ + {inviterName} + {" "} + invited you +

+
+ + Join {organizationName} on + Cap + + + Watch your team's videos, record your own and share them in one + place. It only takes a minute to get set up. + + +
+ {!signedIn ? ( + <> + trackEvent("invite_join_clicked")} + > + Join {organizationName} + + +

+ Use {maskedEmail}, the address this invite was sent to. + Already on Cap?{" "} + + Log in + +

+ + ) : emailMatches ? ( + <> +
+ + +
+

+ You're signed in as {signedInEmail}. +

+ + ) : ( + <> +
+ This invite was sent to{" "} + + {maskedEmail} + + , but you're signed in as{" "} + + {signedInEmail} + + . Switch to that account to join. +
+
+ + + Go to my library + +
+ + )} +
+
+ + + } + steps={[ + { + title: "Press Join", + body: `Use ${maskedEmail}, the address this invite was sent to.`, + }, + { + title: "Say hello", + body: "Add your name so your teammates know it's you.", + }, + { + title: "Watch, record and share together", + body: `Everything ${organizationName} records lands in one shared library.`, + }, + ]} + /> +
+
+
+ ); } diff --git a/apps/web/app/(org)/invite/[inviteId]/page.tsx b/apps/web/app/(org)/invite/[inviteId]/page.tsx index 81d36d1e39c..c5b3186bd73 100644 --- a/apps/web/app/(org)/invite/[inviteId]/page.tsx +++ b/apps/web/app/(org)/invite/[inviteId]/page.tsx @@ -1,8 +1,6 @@ +import "../../onboarding/onboarding.css"; import { db } from "@cap/database"; -import { - getCurrentUser, - type userSelectProps, -} from "@cap/database/auth/session"; +import { getCurrentUser } from "@cap/database/auth/session"; import { organizationInvites, organizations, @@ -10,8 +8,16 @@ import { } from "@cap/database/schema"; import { eq } from "drizzle-orm"; import type { Metadata } from "next"; +import { cookies } from "next/headers"; import { notFound } from "next/navigation"; -import { InviteAccept } from ".//InviteAccept"; +import { PaperRoot } from "../../onboarding/components/PaperRoot"; +import { + maskEmail, + needsOnboarding, + onboardingThemeFromCookie, + THEME_COOKIE, +} from "../../onboarding/onboarding-flow"; +import { InviteAccept } from "./InviteAccept"; type Props = { params: Promise<{ inviteId: string }>; @@ -64,12 +70,25 @@ export default async function InvitePage(props: Props) { return notFound(); } + const invitedEmail = inviteDetails.invite.invitedEmail; + const theme = onboardingThemeFromCookie( + (await cookies()).get(THEME_COOKIE)?.value, + ); + return ( - + + + ); } diff --git a/apps/web/app/(org)/onboarding-next.ts b/apps/web/app/(org)/onboarding-next.ts index e4f26f66930..582ef6a1237 100644 --- a/apps/web/app/(org)/onboarding-next.ts +++ b/apps/web/app/(org)/onboarding-next.ts @@ -1,7 +1,7 @@ import Cookies from "js-cookie"; +import { ONBOARDING_NEXT_COOKIE as COOKIE_NAME } from "./onboarding/onboarding-flow"; import { getSafeNextPath } from "./safe-next"; -const COOKIE_NAME = "cap_onboarding_next"; const EXPIRES_DAYS = 1 / 24; export const rememberOnboardingNextPath = (path: string) => { diff --git a/apps/web/app/(org)/onboarding/[...steps]/layout.tsx b/apps/web/app/(org)/onboarding/[...steps]/layout.tsx deleted file mode 100644 index 19007be208f..00000000000 --- a/apps/web/app/(org)/onboarding/[...steps]/layout.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { getCurrentUser } from "@cap/database/auth/session"; -import { redirect } from "next/navigation"; - -export default async function OnboardingStepLayout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ steps: string[] }>; -}) { - const user = await getCurrentUser(); - - if (!user) { - redirect("/login"); - } - - const steps = user.onboardingSteps || {}; - const currentStep = (await params).steps?.[0] ?? "welcome"; - - const ordered = [ - "welcome", - "organization-setup", - "custom-domain", - "invite-team", - "download", - ] as const; - const isComplete = (s: (typeof ordered)[number]) => - s === "welcome" - ? Boolean(steps.welcome && user.name) - : s === "organization-setup" - ? Boolean(steps.organizationSetup) - : s === "custom-domain" - ? Boolean(steps.customDomain) - : s === "invite-team" - ? Boolean(steps.inviteTeam) - : Boolean(steps.download); - - const firstIncomplete = ordered.find((s) => !isComplete(s)) ?? "download"; - - if (currentStep !== firstIncomplete) { - redirect(`/onboarding/${firstIncomplete}`); - } - - return children; -} diff --git a/apps/web/app/(org)/onboarding/[...steps]/page.tsx b/apps/web/app/(org)/onboarding/[...steps]/page.tsx index a9ffe7d3c66..62981286184 100644 --- a/apps/web/app/(org)/onboarding/[...steps]/page.tsx +++ b/apps/web/app/(org)/onboarding/[...steps]/page.tsx @@ -1,38 +1,150 @@ +import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; -import { CustomDomainPage } from "../components/CustomDomainPage"; -import { DownloadPage } from "../components/DownloadPage"; -import { InviteTeamPage } from "../components/InviteTeamPage"; -import { OrganizationSetupPage } from "../components/OrganizationSetupPage"; -import { WelcomePage } from "../components/WelcomePage"; +import { organizationMembers, organizations } from "@cap/database/schema"; +import { userIsPro } from "@cap/utils"; +import { and, eq, isNull } from "drizzle-orm"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { + canManageOrganizationSettings, + getEffectiveOrganizationRole, +} from "@/lib/permissions/roles"; +import { LoomStep } from "../components/LoomStep"; +import { RecordStep } from "../components/RecordStep"; +import { StartStep } from "../components/StartStep"; +import { UploadStep } from "../components/UploadStep"; +import { WelcomeStep } from "../components/WelcomeStep"; +import { + ONBOARDING_NEXT_COOKIE, + onboardingContinuePath, + onboardingHrefForIntent, + onboardingIntentFromNextPath, + onboardingStepHref, + resolveOnboardingStep, +} from "../onboarding-flow"; + +type SearchParams = Record; + +const firstParam = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + +type CurrentUser = NonNullable>>; + +async function getOnboardingOrganization( + user: Pick, +) { + const organizationId = user.activeOrganizationId || user.defaultOrgId; + if (!organizationId) return null; + + const [row] = await db() + .select({ + id: organizations.id, + name: organizations.name, + ownerId: organizations.ownerId, + memberRole: organizationMembers.role, + }) + .from(organizations) + .leftJoin( + organizationMembers, + and( + eq(organizationMembers.organizationId, organizations.id), + eq(organizationMembers.userId, user.id), + ), + ) + .where( + and( + eq(organizations.id, organizationId), + isNull(organizations.tombstoneAt), + ), + ) + .limit(1); + + if (!row) return null; + const isOwner = row.ownerId === user.id; + if (!isOwner && !row.memberRole) return null; + + return { + id: row.id, + name: row.name, + isOwner, + canManage: canManageOrganizationSettings( + getEffectiveOrganizationRole({ + userId: user.id, + ownerId: row.ownerId, + memberRole: row.memberRole, + }), + ), + }; +} export default async function OnboardingStepPage({ params, + searchParams, }: { - params: Promise<{ - steps: - | "welcome" - | "organization-setup" - | "custom-domain" - | "invite-team" - | "download"; - }>; + params: Promise<{ steps?: string[] }>; + searchParams: Promise; }) { - const step = (await params).steps[0]; + const user = await getCurrentUser(); + if (!user) redirect("/login"); + + const resolution = resolveOnboardingStep((await params).steps?.[0], user); + if (resolution.kind === "redirect") { + redirect(onboardingStepHref(resolution.step)); + } + + const query = await searchParams; + const intent = onboardingIntentFromNextPath( + (await cookies()).get(ONBOARDING_NEXT_COOKIE)?.value, + ); + const organization = await getOnboardingOrganization(user); + const teamName = + organization && !organization.isOwner ? organization.name : null; + const completed = + user.onboardingSteps?.getStarted !== false && organization !== null; + const isPro = userIsPro(user); - switch (step) { + switch (resolution.step) { case "welcome": - return ; - case "organization-setup": { - const user = await getCurrentUser(); - return ; - } - case "custom-domain": - return ; - case "invite-team": - return ; - case "download": - return ; - default: - return null; + return ( + + ); + case "start": + return ( + + ); + case "loom": + return ( + + ); + case "record": + return ( + + ); + case "upload": + return ( + + ); } } diff --git a/apps/web/app/(org)/onboarding/components/Base.tsx b/apps/web/app/(org)/onboarding/components/Base.tsx deleted file mode 100644 index f6c611626ef..00000000000 --- a/apps/web/app/(org)/onboarding/components/Base.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import { LogoBadge } from "@cap/ui"; -import { faArrowLeft } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import clsx from "clsx"; -import { useRouter } from "next/navigation"; - -export const Base = ({ - children, - title, - description, - descriptionClassName, - hideBackButton = true, -}: { - children: React.ReactNode; - title: string; - description: string | React.ReactNode; - descriptionClassName?: string; - hideBackButton?: boolean; -}) => { - const router = useRouter(); - return ( -
- {!hideBackButton && ( -
router.back()} - className="absolute overflow-hidden flex top-5 rounded-full left-5 z-20 hover:bg-gray-1 gap-2 items-center py-1.5 px-3 text-gray-12 bg-transparent border border-gray-4 transition-colors duration-300 cursor-pointer" - > - -

Back

-
- )} - - - -
-

{title}

- {typeof description === "string" ? ( -

- {description} -

- ) : ( - description - )} -
- {children} -
- ); -}; diff --git a/apps/web/app/(org)/onboarding/components/Bottom.tsx b/apps/web/app/(org)/onboarding/components/Bottom.tsx deleted file mode 100644 index 7cc81911b0e..00000000000 --- a/apps/web/app/(org)/onboarding/components/Bottom.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { Button } from "@cap/ui"; -import { useRouter } from "next/navigation"; -import { signOut } from "next-auth/react"; -import { startTransition } from "react"; -import { toast } from "sonner"; -import { resetUser } from "@/app/utils/analytics"; -import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import { consumeOnboardingNextPath } from "../../onboarding-next"; - -export const Bottom = () => { - const router = useRouter(); - - const rpc = useRpcClient(); - - const skipToDashboard = useEffectMutation({ - mutationFn: () => - rpc.UserCompleteOnboardingStep({ - step: "skipToDashboard", - data: undefined, - }), - onSuccess: () => { - startTransition(() => { - router.push(consumeOnboardingNextPath("/dashboard/caps")); - router.refresh(); - }); - }, - onError: () => { - toast.error("An error occurred, please try again"); - }, - }); - - return ( -
- - -
- ); -}; - -export default Bottom; diff --git a/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx b/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx deleted file mode 100644 index 45dec1e3260..00000000000 --- a/apps/web/app/(org)/onboarding/components/CustomDomainPage.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { Button } from "@cap/ui"; -import { useRouter } from "next/navigation"; -import { startTransition, useState } from "react"; -import { toast } from "sonner"; -import { UpgradeModal } from "@/components/UpgradeModal"; -import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import { Base } from "./Base"; - -export function CustomDomainPage() { - const router = useRouter(); - const rpc = useRpcClient(); - const [showUpgradeModal, setShowUpgradeModal] = useState(false); - - const customDomainMutation = useEffectMutation({ - mutationFn: (_redirect: boolean) => - rpc.UserCompleteOnboardingStep({ - step: "customDomain", - data: undefined, - }), - onSuccess: (_, redirect) => { - startTransition(() => { - if (redirect) { - router.push("/onboarding/invite-team"); - router.refresh(); - } - }); - }, - onError: () => { - toast.error("An error occurred, please try again"); - }, - }); - - const handleSubmit = async (redirect = true) => - await customDomainMutation.mutateAsync(redirect); - - return ( - -

- Pro users can setup a custom domain to access their shareable Cap - links i.e{" "} - - cap.yourdomain.com - -

-
- } - descriptionClassName="max-w-[400px]" - > - -
- - { - await handleSubmit(); - }} - onboarding={true} - open={showUpgradeModal} - onOpenChange={setShowUpgradeModal} - /> - - ); -} diff --git a/apps/web/app/(org)/onboarding/components/DownloadPage.tsx b/apps/web/app/(org)/onboarding/components/DownloadPage.tsx deleted file mode 100644 index 606d1a3d4f0..00000000000 --- a/apps/web/app/(org)/onboarding/components/DownloadPage.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { Button, LogoBadge } from "@cap/ui"; -import { useDetectPlatform } from "hooks/useDetectPlatform"; -import { Clapperboard, Zap } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { - getDownloadButtonText, - getDownloadUrl, - getPlatformIcon, -} from "@/utils/platform"; -import { consumeOnboardingNextPath } from "../../onboarding-next"; - -const recordingModes = [ - { - name: "Instant Mode", - icon: , - description: - "Hit record, stop, share link. Your video is live in seconds with automatically generated captions, a title, summary, chapters, and more. Perfect for quick feedback, bug reports, or when you just need to show something fast.", - }, - { - name: "Studio Mode", - icon: ( - - ), - description: - "Professional recordings with local editing, custom backgrounds, and export options. When you need pixel-perfect demos, tutorials, or presentations that represent your brand.", - }, -]; - -export function DownloadPage() { - const { platform, isIntel } = useDetectPlatform(); - const loading = platform === null; - const router = useRouter(); - - return ( -
-
-
- -
-

Download Cap

-

- Start recording beautiful screen recordings today -

-
-
-
- {recordingModes.map((recordingMode) => ( -
- {recordingMode.icon} -

- {recordingMode.name} -

-

- {recordingMode.description} -

-
- ))} -
-
-
- - -
-
- ); -} diff --git a/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx b/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx deleted file mode 100644 index d1cc9d28118..00000000000 --- a/apps/web/app/(org)/onboarding/components/InviteTeamPage.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"use client"; - -import { Button, Switch } from "@cap/ui"; -import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import NumberFlow from "@number-flow/react"; -import { useMutation } from "@tanstack/react-query"; -import clsx from "clsx"; -import { useCurrency } from "hooks/useCurrency"; -import { useRouter } from "next/navigation"; -import { type MouseEvent, startTransition, useId, useState } from "react"; -import { toast } from "sonner"; -import { useStripeContext } from "@/app/Layout/StripeContext"; -import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import { homepageCopy } from "../../../../data/homepage-copy"; -import { Base } from "./Base"; - -export function InviteTeamPage() { - const billingCycleId = useId(); - const stripeCtx = useStripeContext(); - const { currency, symbol } = useCurrency(); - const [users, setUsers] = useState(1); - const [isAnnually, setIsAnnually] = useState(true); - const router = useRouter(); - const rpc = useRpcClient(); - - const CAP_PRO_ANNUAL_PRICE_PER_USER = homepageCopy.pricing.pro.pricing.annual; - const CAP_PRO_MONTHLY_PRICE_PER_USER = - homepageCopy.pricing.pro.pricing.monthly; - - const currentTotalPrice = - users * - (isAnnually - ? CAP_PRO_ANNUAL_PRICE_PER_USER - : CAP_PRO_MONTHLY_PRICE_PER_USER); - const billingCycleText = isAnnually - ? "per user, billed annually" - : "per user, billed monthly"; - - const incrementUsers = () => setUsers((n) => n + 1); - const decrementUsers = () => setUsers((n) => (n > 1 ? n - 1 : 1)); - - const inviteTeamMutation = useEffectMutation({ - mutationFn: (_redirect: boolean) => - rpc.UserCompleteOnboardingStep({ - step: "inviteTeam", - data: undefined, - }), - onSuccess: (_, redirect: boolean) => { - startTransition(() => { - if (redirect) { - router.push("/onboarding/download"); - router.refresh(); - } - }); - }, - onError: () => { - toast.error("An error occurred, please try again"); - }, - }); - - const handleSubmit = async ( - e: MouseEvent, - redirect = true, - ) => { - e.preventDefault(); - await inviteTeamMutation.mutateAsync(redirect); - }; - - const planCheckout = async (e: MouseEvent) => { - e.preventDefault(); - try { - const planId = stripeCtx.plans[isAnnually ? "yearly" : "monthly"]; - - const response = await fetch(`/api/settings/billing/subscribe`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - priceId: planId, - quantity: users, - isOnBoarding: true, - }), - }); - if (!response.ok) { - toast.error("Unable to start checkout. Please try again."); - return; - } - const data = await response.json(); - if (data.subscription === true) { - toast.success("You are already on the Cap Pro plan"); - return; - } - - await handleSubmit(e, false); - - if (data.url) { - window.location.href = data.url; - } - } catch (error) { - console.error("Plan checkout error:", error); - toast.error("Something went wrong. Please try again."); - } - }; - - const planCheckoutMutation = useMutation({ - mutationFn: (e: MouseEvent) => planCheckout(e), - onError: (error) => { - console.error("Plan checkout error:", error); - toast.error("Something went wrong. Please try again."); - }, - }); - - return ( - -
- - {symbol} - - - - {" "} - {billingCycleText} - - {isAnnually ? ( -

- or,{" "} - {" "} - {users === 1 ? ( - "per user, " - ) : ( - <> - for{" "} - {" "} - users,{" "} - - )} - billed monthly -

- ) : ( -

- or,{" "} - {" "} - {users === 1 ? ( - "per user, " - ) : ( - <> - for{" "} - {" "} - users,{" "} - - )} - billed annually -

- )} -
- -
-
-
-

Per user

-
- - - - - -
-
-
- - {homepageCopy.pricing.pro.labels.monthly} - - - - {homepageCopy.pricing.pro.labels.annually} - -
-
- -
-
- - - ); -} diff --git a/apps/web/app/(org)/onboarding/components/LoomStep.tsx b/apps/web/app/(org)/onboarding/components/LoomStep.tsx new file mode 100644 index 00000000000..e7cbacf6e67 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/LoomStep.tsx @@ -0,0 +1,411 @@ +"use client"; + +import type { Organisation } from "@cap/web-domain"; +import clsx from "clsx"; +import { ArrowRight, LoaderCircle } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + type CSSProperties, + type FormEvent, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import { importFromLoom } from "@/actions/loom"; +import { trackEvent } from "@/app/utils/analytics"; +import { UpgradeModal } from "@/components/UpgradeModal"; +import { isLoomShareUrl } from "@/lib/loom-import-href"; +import { onboardingStepHref } from "../onboarding-flow"; +import { Explainer, LOOM_MARK_PATH, LoomMark } from "./paper"; +import { BackLink, ProNote, StepLede, StepPage, StepTitle } from "./StepChrome"; +import { DoneDoodle, LoomScene } from "./scenes"; +import { useGetStarted } from "./use-get-started"; +import { useUpgradeConfirmation } from "./use-upgrade-confirmation"; + +const CsvGlyph = () => ( + + + +); + +const LoomToCapBadge = () => ( +
+ + + + + + + +
+); + +export const LoomStep = ({ + organizationId, + initialUrl, + bulk, + canBulkImport, + isPro, + justUpgraded, + completed, +}: { + organizationId: Organisation.OrganisationId | null; + initialUrl: string; + bulk: boolean; + canBulkImport: boolean; + isPro: boolean; + justUpgraded: boolean; + completed: boolean; +}) => { + const router = useRouter(); + const inputId = useId(); + const messageId = useId(); + const inputRef = useRef(null); + const { complete } = useGetStarted(completed, "loom"); + const [url, setUrl] = useState(initialUrl); + const [touched, setTouched] = useState(Boolean(initialUrl)); + const [status, setStatus] = useState<"idle" | "importing" | "done">("idle"); + const [upgradeOpen, setUpgradeOpen] = useState(false); + const [leaving, setLeaving] = useState(false); + const upgrade = useUpgradeConfirmation({ justUpgraded, isPro }); + + const trimmed = url.trim(); + const valid = isLoomShareUrl(trimmed); + const showError = touched && trimmed.length > 0 && !valid; + + useEffect(() => { + if (organizationId) return; + complete("loom") + .then(() => router.refresh()) + .catch(() => undefined); + }, [organizationId, complete, router]); + + useEffect(() => { + if (!initialUrl) inputRef.current?.focus(); + }, [initialUrl]); + + const leaveTo = async (href: string) => { + setLeaving(true); + try { + await complete("loom"); + router.push(href); + } catch { + setLeaving(false); + toast.error("Something went wrong. Please try again."); + } + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setTouched(true); + if (!valid) { + inputRef.current?.focus(); + return; + } + if (!isPro) { + trackEvent("onboarding_loom_upgrade_prompted"); + setUpgradeOpen(true); + return; + } + if (!organizationId) { + toast.info("Your library is still being set up. Try again in a second."); + router.refresh(); + return; + } + + setStatus("importing"); + try { + await complete("loom").catch(() => undefined); + const result = await importFromLoom({ + loomUrl: trimmed, + orgId: organizationId, + }); + if (!result?.success) { + toast.error(result?.error || "We couldn't import that video."); + setStatus("idle"); + return; + } + trackEvent("onboarding_loom_imported"); + setStatus("done"); + } catch { + toast.error("Something went wrong. Please try again."); + setStatus("idle"); + } + }; + + const importing = status === "importing"; + + return ( + + +
+
+ + Bring your Looms to Cap + + Paste a Loom share link and we'll copy the video into your Cap + library, or bring your whole library over with a CSV. Your Looms + stay exactly where they are. + + + {status === "done" ? ( +
+ +

+ Your Loom is on its way +

+

+ We're copying it into My Caps now. It usually takes a minute or + two, and it keeps going if you leave this page. +

+
+ + +
+
+ ) : ( +
+ +
+ + setUrl(event.target.value)} + onBlur={() => setTouched(true)} + onPaste={() => setTouched(true)} + aria-invalid={showError} + aria-describedby={messageId} + /> + {valid && ( + + )} +
+

+ {showError + ? "That doesn't look like a Loom share link. It should start with loom.com/share/." + : valid + ? "Looks good. Press import and we'll take it from here." + : "On any Loom video, press Share, then Copy link."} +

+
+ +
+ {upgrade.waiting ? ( + + + + Finishing your upgrade. This only takes a moment. + + + ) : upgrade.timedOut ? ( + + Your upgrade is taking a little longer than usual. Refresh + this page in a moment and your import will be ready to go. + + ) : ( + !isPro && ( + + Moving from Loom is part of Cap Pro, so you can bring over + as many videos as you like.{" "} + + Or record something new for free + + + ) + )} +
+ )} + + {status !== "done" && ( +
+ +
+

+ Moving lots of videos? +

+

+ Add your Loom links to a spreadsheet and import up to 500 at a + time. We'll give you a template, and each video can go to the + right person and space. +

+ {canBulkImport ? ( + + ) : ( +

+ Ask an admin on your team to run a bulk import. +

+ )} +
+
+ )} +
+ + } + steps={[ + { + title: "Copy the link from Loom", + body: "Open any Loom video, press Share, then Copy link.", + }, + { + title: "Paste it here and press Import", + body: "We check the link as soon as you paste it.", + }, + { + title: "Find it in your Cap library", + body: "Same video with a Cap link you can share. Nothing changes on Loom.", + }, + ]} + /> +
+ +
+ ); +}; diff --git a/apps/web/app/(org)/onboarding/components/OnboardingHeader.tsx b/apps/web/app/(org)/onboarding/components/OnboardingHeader.tsx new file mode 100644 index 00000000000..258335b72be --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/OnboardingHeader.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { signOut } from "next-auth/react"; +import { useState } from "react"; +import { resetUser } from "@/app/utils/analytics"; +import { clearOnboardingNextPath } from "../../onboarding-next"; +import { isOnboardingStep, onboardingProgressIndex } from "../onboarding-flow"; +import { ThemeToggle } from "./PaperRoot"; +import { CapWordmark, ProgressMarks } from "./paper"; + +export const OnboardingHeader = ({ email }: { email: string }) => { + const pathname = usePathname(); + const [signingOut, setSigningOut] = useState(false); + const segment = pathname?.split("/")[2] ?? ""; + const step = isOnboardingStep(segment) ? segment : null; + + return ( +
+ +
+ {step && } +
+
+ + {email} + + + +
+
+ ); +}; diff --git a/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx b/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx deleted file mode 100644 index 6b314f06333..00000000000 --- a/apps/web/app/(org)/onboarding/components/OrganizationSetupPage.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; - -import { Button, Input } from "@cap/ui"; -import { faImage } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Effect } from "effect"; -import Image from "next/image"; -import { useRouter } from "next/navigation"; -import { startTransition, useRef, useState } from "react"; -import { toast } from "sonner"; -import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import { Base } from "./Base"; - -export function OrganizationSetupPage({ - firstName, -}: { - firstName: string | null | undefined; -}) { - const [organizationName, setOrganizationName] = useState( - firstName ? `${firstName}'s organization` : "", - ); - const [selectedFile, setSelectedFile] = useState(null); - const fileInputRef = useRef(null); - const router = useRouter(); - const rpc = useRpcClient(); - - const handleFileChange = () => { - const file = fileInputRef.current?.files?.[0]; - if (file) { - setSelectedFile(file); - } - }; - - const orgSetupMutation = useEffectMutation({ - mutationFn: (data: { organizationName: string; icon?: File }) => - Effect.gen(function* () { - let organizationIcon: - | { - data: Uint8Array; - contentType: string; - fileName: string; - } - | undefined; - - if (data.icon) { - const icon = data.icon; - const arrayBuffer = yield* Effect.promise(() => icon.arrayBuffer()); - organizationIcon = { - data: new Uint8Array(arrayBuffer), - contentType: icon.type, - fileName: icon.name, - }; - } - - yield* rpc.UserCompleteOnboardingStep({ - step: "organizationSetup", - data: { - organizationName: data.organizationName, - organizationIcon, - }, - }); - }), - onSuccess: () => { - startTransition(() => { - router.push("/onboarding/custom-domain"); - router.refresh(); - }); - }, - onError: (error) => { - console.error(error); - toast.error("An error occurred, please try again"); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - orgSetupMutation.mutate({ - organizationName, - icon: selectedFile || undefined, - }); - }; - - return ( - -
-
- setOrganizationName(e.target.value)} - placeholder="Organization Name" - name="organizationName" - required - /> -
-

- Organization Logo -

-
-
- {selectedFile ? ( - Selected File - ) : ( - - )} -
- -
- -

- Recommended size: 120x120 -

-
-
-
-
-
- - - - ); -} diff --git a/apps/web/app/(org)/onboarding/components/PaperRoot.tsx b/apps/web/app/(org)/onboarding/components/PaperRoot.tsx new file mode 100644 index 00000000000..e8217d66458 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/PaperRoot.tsx @@ -0,0 +1,139 @@ +"use client"; + +import clsx from "clsx"; +import Cookies from "js-cookie"; +import { + type CSSProperties, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useState, +} from "react"; +import { trackEvent } from "@/app/utils/analytics"; +import { + type OnboardingThemePreference, + resolveOnboardingTheme, + THEME_COOKIE, +} from "../onboarding-flow"; +import { BoilFilter } from "./paper"; + +const DARK_QUERY = "(prefers-color-scheme: dark)"; + +const ThemeToggleContext = createContext<(() => void) | null>(null); + +export const useToggleOnboardingTheme = () => { + const toggle = useContext(ThemeToggleContext); + if (!toggle) throw new Error("useToggleOnboardingTheme needs a PaperRoot"); + return toggle; +}; + +export const PaperRoot = ({ + children, + className, + initialTheme, +}: { + children: ReactNode; + className?: string; + initialTheme: OnboardingThemePreference; +}) => { + const filterId = `ob-boil-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + const [preference, setPreference] = useState(initialTheme); + + useEffect(() => { + const media = window.matchMedia(DARK_QUERY); + const previous = document.body.className; + const apply = () => { + document.body.className = resolveOnboardingTheme( + preference, + media.matches, + ); + }; + apply(); + media.addEventListener("change", apply); + return () => { + media.removeEventListener("change", apply); + document.body.className = previous; + }; + }, [preference]); + + const toggle = useCallback(() => { + const current = resolveOnboardingTheme( + preference, + window.matchMedia(DARK_QUERY).matches, + ); + const next = current === "dark" ? "light" : "dark"; + setPreference(next); + Cookies.set(THEME_COOKIE, next, { expires: 365 }); + trackEvent("onboarding_theme_toggled", { theme: next }); + }, [preference]); + + return ( + +
+ + {children} +
+
+ ); +}; + +export const ThemeToggle = () => { + const toggle = useToggleOnboardingTheme(); + + return ( + + ); +}; diff --git a/apps/web/app/(org)/onboarding/components/RecordStep.tsx b/apps/web/app/(org)/onboarding/components/RecordStep.tsx new file mode 100644 index 00000000000..af1d8beff91 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/RecordStep.tsx @@ -0,0 +1,328 @@ +"use client"; + +import clsx from "clsx"; +import { useCapChromeExtension } from "hooks/useCapChromeExtension"; +import { useDetectPlatform } from "hooks/useDetectPlatform"; +import { ArrowRight, Check, Copy, Download, Puzzle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type CSSProperties, type ReactNode, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; +import { CAP_CHROME_EXTENSION_URL } from "@/lib/chrome-extension"; +import { getDownloadUrl } from "@/utils/platform"; +import { Explainer } from "./paper"; +import { BackLink, StepLede, StepPage, StepTitle } from "./StepChrome"; +import { RecordScene } from "./scenes"; +import { useGetStarted } from "./use-get-started"; + +const isMobileDevice = () => { + if (typeof navigator === "undefined") return false; + const ua = navigator.userAgent; + return ( + /iPhone|iPad|iPod|Android/i.test(ua) || + (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1) + ); +}; + +const platformName = (platform: string | null) => + platform === "windows" ? "Windows" : platform === "linux" ? "Linux" : "Mac"; + +const OptionCard = ({ + icon, + title, + tag, + children, + action, + featured = false, + delay, +}: { + icon: ReactNode; + title: string; + tag?: string; + children: ReactNode; + action: ReactNode; + featured?: boolean; + delay: number; +}) => ( +
  • + + {icon} + +
    +

    {title}

    + {tag && ( + + {tag} + + )} +
    +
    + {children} +
    +
    {action}
    +
  • +); + +const BrowserGlyph = () => ( + +); + +const AppGlyph = () => ( + +); + +export const RecordStep = ({ + isPro, + email, + completed, +}: { + isPro: boolean; + email: string; + completed: boolean; +}) => { + const router = useRouter(); + const { complete } = useGetStarted(completed, "record"); + const { platform, isIntel } = useDetectPlatform(); + const { isChromeBrowser, isInstalled, openRecorder } = + useCapChromeExtension(); + const [mobile, setMobile] = useState(false); + const [downloaded, setDownloaded] = useState(false); + const [copied, setCopied] = useState(false); + const [leaving, setLeaving] = useState(false); + + useEffect(() => { + setMobile(isMobileDevice()); + router.prefetch("/dashboard/caps/record"); + }, [router]); + + const leaveTo = async (href: string) => { + setLeaving(true); + try { + await complete("record"); + router.push(href); + } catch { + setLeaving(false); + toast.error("Something went wrong. Please try again."); + } + }; + + const copyDownloadLink = async () => { + try { + await navigator.clipboard.writeText(`${window.location.origin}/download`); + setCopied(true); + trackEvent("onboarding_download_link_copied"); + } catch { + toast.error( + "Couldn't copy the link. Visit cap.so/download on your computer.", + ); + } + }; + + const showExtension = !mobile && (isChromeBrowser || isInstalled); + const os = platformName(platform); + + return ( + + +
    +
    + Record your first Cap + + Show it instead of typing it out. Pick how you'd like to record and + you'll have a link to share as soon as you stop. + + +
      + } + title="Record in your browser" + tag="Fastest" + action={ + + } + > + {mobile + ? "Nothing to install. Record yourself with your camera, right here." + : "Nothing to install. Share your screen, turn on your camera and talk it through."} + {!isPro && " Free recordings can be up to 5 minutes."} + + + } + title="Get the Cap app" + action={ + mobile ? ( + + ) : downloaded ? ( +
      +
        + {[ + "Open the file you just downloaded and install Cap", + `Sign in with ${email}`, + "Press record, and your link is ready when you stop", + ].map((line, index) => ( +
      1. + + {index + 1} + + {line} +
      2. + ))} +
      + +
      + ) : ( + { + setDownloaded(true); + trackEvent("onboarding_record_selected", { + via: "desktop", + platform, + }); + complete("record").catch(() => undefined); + }} + > + + Download for {os} + + ) + } + > + {mobile + ? "Studio-quality recordings and editing for Mac and Windows. Grab it on your computer." + : `Studio-quality recordings, local editing and 4K exports for ${os}.`} +
      + + {showExtension && ( + + } + title="Use the Chrome extension" + action={ + isInstalled ? ( + + ) : ( + { + trackEvent("onboarding_record_selected", { + via: "extension_install", + }); + complete("record").catch(() => undefined); + }} + > + Add to Chrome + + ) + } + > + Record any tab from your toolbar without leaving what you're + doing. + + )} +
    +
    + + } + steps={[ + { + title: "Choose what to record", + body: "Your whole screen, a window or a tab, plus your camera and mic.", + }, + { + title: "Hit record and talk it through", + body: "Pause or start over whenever you like.", + }, + { + title: "Stop, and your link is ready", + body: "It uploads while you record, so there's nothing to wait for.", + }, + ]} + /> +
    +
    + ); +}; diff --git a/apps/web/app/(org)/onboarding/components/SharePreview.tsx b/apps/web/app/(org)/onboarding/components/SharePreview.tsx new file mode 100644 index 00000000000..6e600cc4787 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/SharePreview.tsx @@ -0,0 +1,202 @@ +import clsx from "clsx"; +import { Lock } from "lucide-react"; + +const COLUMNS = [ + { + title: "Now", + x: 16, + cards: [ + { y: 50, tag: "var(--ob-green)", line: 52 }, + { y: 84, tag: "var(--ob-green)", line: 38 }, + ], + }, + { + title: "Next", + x: 117, + cards: [ + { y: 50, tag: "var(--ob-accent)", line: 46 }, + { y: 84, tag: "var(--ob-accent)", line: 56 }, + { y: 118, tag: "var(--ob-accent)", line: 30 }, + ], + }, + { + title: "Later", + x: 218, + cards: [{ y: 50, tag: "var(--ob-camera)", line: 42 }], + }, +] as const; + +const CARD_WIDTH = 86; +const CARD_HEIGHT = 26; + +const Recording = () => ( + +); + +const Webcam = () => ( + + + +); + +export const SharePreview = ({ name }: { name: string }) => { + const trimmed = name.trim(); + const initial = trimmed.charAt(0).toUpperCase(); + + return ( +
    +
    +
    +
    +
    +
    +

    + Q3 roadmap walkthrough +

    + + Copy link + +
    +
    + + + {initial || "?"} + + +
    +

    + {trimmed || "Your name"} +

    +

    + just now +

    +
    +
    +
    + + +
    +
    +
    +
    +
    +
    + This is what people see when you send them a Cap. +
    +
    + ); +}; diff --git a/apps/web/app/(org)/onboarding/components/StartStep.tsx b/apps/web/app/(org)/onboarding/components/StartStep.tsx new file mode 100644 index 00000000000..a0d300efd62 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/StartStep.tsx @@ -0,0 +1,161 @@ +"use client"; + +import type { User } from "@cap/web-domain"; +import { ArrowRight } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { type CSSProperties, type ReactNode, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; +import { onboardingStepHref } from "../onboarding-flow"; +import { StepLede, StepPage, StepTitle } from "./StepChrome"; +import { LoomToCapDoodle, RecordDoodle, UploadDoodle } from "./scenes"; +import { useGetStarted } from "./use-get-started"; + +type Choice = { + path: "loom" | "record" | "upload"; + title: string; + body: string; + cue: string; + doodle: ReactNode; +}; + +const CHOICES: readonly Choice[] = [ + { + path: "loom", + title: "Bring my videos from Loom", + body: "Paste a link, or bring your whole library over with a CSV. Nothing changes on Loom.", + cue: "One link or a whole CSV", + doodle: , + }, + { + path: "record", + title: "Record a video", + body: "Capture your screen, camera and mic. Your link is ready the moment you stop.", + cue: "No download needed", + doodle: , + }, + { + path: "upload", + title: "Upload a video", + body: "Turn a recording you already have into a link you can share anywhere.", + cue: "MP4, MOV or WebM", + doodle: , + }, +]; + +export const StartStep = ({ + firstName, + organizationName, + continuePath, + completed, +}: { + firstName: string; + organizationName: string | null; + continuePath: string; + completed: boolean; +}) => { + const router = useRouter(); + const { complete } = useGetStarted(completed); + const [leaving, setLeaving] = useState(null); + + useEffect(() => { + for (const choice of CHOICES) + router.prefetch(onboardingStepHref(choice.path)); + }, [router]); + + const choose = (path: Choice["path"]) => { + trackEvent("onboarding_start_selected", { path }); + setLeaving(path); + router.push(onboardingStepHref(path)); + }; + + const lookAround = async () => { + trackEvent("onboarding_start_selected", { path: "explore" }); + setLeaving("explore"); + try { + await complete("explore"); + router.push(continuePath); + router.refresh(); + } catch { + setLeaving(null); + toast.error("Something went wrong. Please try again."); + } + }; + + return ( + +
    + + {organizationName + ? `Welcome to ${organizationName}${firstName ? `, ${firstName}` : ""}` + : firstName + ? `Hi ${firstName}, let's make your first Cap` + : "Let's make your first Cap"} + + + {organizationName + ? "Pick where you'd like to start. Everything you make lands in your team's library." + : "Pick where you'd like to start. You can do the others any time from your library."} + +
    + +
      + {CHOICES.map((choice, index) => ( +
    • + +
    • + ))} +
    + +
    + +
    +
    + ); +}; diff --git a/apps/web/app/(org)/onboarding/components/StepChrome.tsx b/apps/web/app/(org)/onboarding/components/StepChrome.tsx new file mode 100644 index 00000000000..4ac95c0322e --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/StepChrome.tsx @@ -0,0 +1,112 @@ +import clsx from "clsx"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import type { CSSProperties, ReactNode } from "react"; + +export const StepPage = ({ + children, + width = "wide", + className, +}: { + children: ReactNode; + width?: "narrow" | "wide"; + className?: string; +}) => ( +
    + {children} +
    +); + +export const BackLink = ({ + href = "/onboarding/start", + children = "Other ways to start", +}: { + href?: string; + children?: ReactNode; +}) => ( + + + {children} + +); + +export const StepTitle = ({ + children, + className, + delay = 0, +}: { + children: ReactNode; + className?: string; + delay?: number; +}) => ( +

    + {children} +

    +); + +export const StepLede = ({ + children, + className, + delay = 0.06, +}: { + children: ReactNode; + className?: string; + delay?: number; +}) => ( +

    + {children} +

    +); + +export const ProNote = ({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) => ( +
    + + + Pro + +
    {children}
    +
    +); diff --git a/apps/web/app/(org)/onboarding/components/Stepper.tsx b/apps/web/app/(org)/onboarding/components/Stepper.tsx deleted file mode 100644 index 70b28743f5d..00000000000 --- a/apps/web/app/(org)/onboarding/components/Stepper.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"use client"; - -import { faCheck } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import clsx from "clsx"; -import { usePathname } from "next/navigation"; -import { Fragment, useMemo } from "react"; - -export default function Stepper({ - completedSteps, -}: { - completedSteps: { - welcome?: boolean; - organizationSetup?: boolean; - customDomain?: boolean; - inviteTeam?: boolean; - download?: boolean; - }; -}) { - const currentPath = usePathname(); - const currentStep = useMemo(() => { - if (currentPath === "/onboarding/welcome") return "Welcome"; - if (currentPath === "/onboarding/organization-setup") - return "Organization Setup"; - if (currentPath === "/onboarding/custom-domain") return "Custom Domain"; - if (currentPath === "/onboarding/invite-team") return "Invite your team"; - if (currentPath === "/onboarding/download") return "Download"; - }, [currentPath]); - - const steps = [ - { - id: "1", - name: "Welcome", - completed: completedSteps.welcome || false, - }, - { - id: "2", - name: "Organization Setup", - completed: completedSteps.organizationSetup || false, - }, - { - id: "3", - name: "Custom Domain", - completed: completedSteps.customDomain || false, - }, - { - id: "4", - name: "Invite your team", - completed: completedSteps.inviteTeam || false, - }, - { - id: "5", - name: "Download", - completed: completedSteps.download || false, - }, - ]; - - return ( - <> - {/* Mobile Stepper - shows on devices up to 1024px */} -
    - -
    - - {/* Desktop Stepper - shows on devices 1024px and up */} -
    - {steps.map((step, idx) => { - return ( - -
    - {idx !== 0 && ( -
    - )} -
    -
    - {step.completed ? ( - - ) : ( -

    {step.id}

    - )} -
    -

    - {step.name} -

    -
    -
    - - ); - })} -
    - - ); -} - -const MobileStepper = ({ - currentStep, - steps, -}: { - currentStep: string | undefined; - steps: Array<{ - id: string; - name: string; - completed: boolean; - }>; -}) => { - const activeStep = steps.find((step) => step.name === currentStep); - - if (!activeStep) return null; - - return ( -
    -
    -
    -

    {activeStep.id}

    -
    -

    {activeStep.name}

    -
    -
    -

    - Step {activeStep.id}/5 -

    -
    -
    - ); -}; diff --git a/apps/web/app/(org)/onboarding/components/UploadStep.tsx b/apps/web/app/(org)/onboarding/components/UploadStep.tsx new file mode 100644 index 00000000000..042bba57979 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/UploadStep.tsx @@ -0,0 +1,330 @@ +"use client"; + +import type { Organisation } from "@cap/web-domain"; +import { ArrowRight, LoaderCircle } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + type ChangeEvent, + type CSSProperties, + type DragEvent, + useEffect, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import type { UploadStatus } from "@/app/(org)/dashboard/caps/UploadingContext"; +import { importMediaFile } from "@/app/(org)/dashboard/import/import-media"; +import { isSupportedVideoFile } from "@/app/(org)/dashboard/import/media-file-types"; +import { trackEvent } from "@/app/utils/analytics"; +import { UpgradeModal } from "@/components/UpgradeModal"; +import { onboardingStepHref } from "../onboarding-flow"; +import { Explainer, SquiggleProgress } from "./paper"; +import { BackLink, ProNote, StepLede, StepPage, StepTitle } from "./StepChrome"; +import { DoneDoodle, UploadDoodle, UploadScene } from "./scenes"; +import { useGetStarted } from "./use-get-started"; +import { useUpgradeConfirmation } from "./use-upgrade-confirmation"; + +const VIDEO_ACCEPT = "video/*,.mp4,.mov,.m4v,.webm,.mkv,.avi"; + +const statusLabel = (status: UploadStatus | undefined) => { + switch (status?.status) { + case "parsing": + return "Reading your video…"; + case "creating": + return "Getting your link ready…"; + case "uploadingVideo": + case "uploadingThumbnail": + case "converting": + return "Uploading…"; + case "serverProcessing": + return "Almost there…"; + default: + return "Starting…"; + } +}; + +const statusProgress = (status: UploadStatus | undefined) => { + if (!status) return null; + if (status.status === "serverProcessing") return 1; + if ("progress" in status) + return Math.min(1, Math.max(0, status.progress / 100)); + return null; +}; + +export const UploadStep = ({ + organizationId, + isPro, + justUpgraded, + completed, +}: { + organizationId: Organisation.OrganisationId | null; + isPro: boolean; + justUpgraded: boolean; + completed: boolean; +}) => { + const router = useRouter(); + const inputRef = useRef(null); + const capIdRef = useRef(null); + const { complete } = useGetStarted(completed, "upload"); + const upgrade = useUpgradeConfirmation({ justUpgraded, isPro }); + const [phase, setPhase] = useState<"idle" | "working" | "done">("idle"); + const [status, setStatus] = useState(); + const [fileName, setFileName] = useState(""); + const [capId, setCapId] = useState(null); + const [over, setOver] = useState(false); + const [upgradeOpen, setUpgradeOpen] = useState(false); + const [leaving, setLeaving] = useState(false); + + useEffect(() => { + if (organizationId) return; + complete("upload") + .then(() => router.refresh()) + .catch(() => undefined); + }, [organizationId, complete, router]); + + const promptUpgrade = () => { + trackEvent("onboarding_upload_upgrade_prompted"); + setUpgradeOpen(true); + }; + + const upload = async (file: File) => { + if (!isPro) { + promptUpgrade(); + return; + } + if (!isSupportedVideoFile(file)) { + toast.error("Please choose a video file, like an MP4, MOV or WebM."); + return; + } + if (!organizationId) { + toast.info("Your library is still being set up. Try again in a second."); + router.refresh(); + return; + } + + capIdRef.current = null; + setFileName(file.name); + setStatus(undefined); + setPhase("working"); + await complete("upload").catch(() => undefined); + + const ok = await importMediaFile({ + file, + orgId: organizationId, + showSuccessToast: false, + setUploadStatus: (next) => { + setStatus(next); + if (next && "capId" in next) capIdRef.current = next.capId; + }, + }); + + if (ok && capIdRef.current) { + trackEvent("onboarding_video_uploaded"); + setCapId(capIdRef.current); + setPhase("done"); + } else { + setPhase("idle"); + } + }; + + const onFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) upload(file); + }; + + const onDrop = (event: DragEvent) => { + event.preventDefault(); + setOver(false); + const file = event.dataTransfer.files[0]; + if (file) upload(file); + }; + + const leaveTo = async (href: string) => { + setLeaving(true); + try { + await complete("upload"); + router.push(href); + } catch { + setLeaving(false); + toast.error("Something went wrong. Please try again."); + } + }; + + return ( + + +
    +
    + Upload a video + + Drop in a recording you already have and get a link you can share + straight away. No attachments, no waiting for exports. + + + + + {phase === "done" && capId ? ( +
    + +

    + Your video is ready to share +

    +

    + {fileName} +

    +

    + We're polishing it in the background. Your link already works. +

    +
    + + +
    +
    + ) : phase === "working" ? ( +
    +
    + +
    +

    + {statusLabel(status)} +

    +

    + {fileName} +

    +
    +
    + +

    + Keep this tab open until the upload finishes. +

    +
    + ) : ( +
    + + {upgrade.waiting ? ( + + + + Finishing your upgrade. This only takes a moment. + + + ) : upgrade.timedOut ? ( + + Your upgrade is taking a little longer than usual. Refresh + this page in a moment to upload. + + ) : ( + !isPro && ( + + Uploading videos is part of Cap Pro.{" "} + + Or record one for free + + + ) + )} +
    + )} +
    + + } + steps={[ + { + title: "Drop in a video file", + body: "Drag it onto the page or choose it from your computer.", + }, + { + title: "We upload it and get it ready", + body: "Big files are fine. Keep the tab open while it uploads.", + }, + { + title: "Share your link", + body: "Send it anywhere. People can watch without signing up.", + }, + ]} + /> +
    + +
    + ); +}; diff --git a/apps/web/app/(org)/onboarding/components/WelcomePage.tsx b/apps/web/app/(org)/onboarding/components/WelcomePage.tsx deleted file mode 100644 index 5c3168c67b8..00000000000 --- a/apps/web/app/(org)/onboarding/components/WelcomePage.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import { Button, Input } from "@cap/ui"; -import { useRouter } from "next/navigation"; -import { startTransition, useState } from "react"; -import { toast } from "sonner"; -import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; -import { Base } from "./Base"; - -export function WelcomePage() { - const [firstName, setFirstName] = useState(""); - const [lastName, setLastName] = useState(""); - const router = useRouter(); - const rpc = useRpcClient(); - - const welcomeMutation = useEffectMutation({ - mutationFn: (data: { firstName: string; lastName?: string }) => - rpc.UserCompleteOnboardingStep({ - step: "welcome", - data, - }), - onSuccess: () => { - startTransition(() => { - router.push("/onboarding/organization-setup"); - router.refresh(); - }); - }, - onError: () => { - toast.error("An error occurred, please try again"); - }, - }); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - welcomeMutation.mutate({ firstName, lastName }); - }; - - return ( - -
    -
    - setFirstName(e.target.value)} - type="text" - placeholder="First name" - name="firstName" - required - /> - setLastName(e.target.value)} - type="text" - placeholder="Last name (optional)" - name="lastName" - /> -
    -
    - - - - ); -} diff --git a/apps/web/app/(org)/onboarding/components/WelcomeStep.tsx b/apps/web/app/(org)/onboarding/components/WelcomeStep.tsx new file mode 100644 index 00000000000..25b46201890 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/WelcomeStep.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { + type CSSProperties, + type FormEvent, + startTransition, + useEffect, + useId, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; +import { splitFullName } from "../onboarding-flow"; +import { SharePreview } from "./SharePreview"; +import { StepLede, StepPage, StepTitle } from "./StepChrome"; + +const WavingHand = () => ( + + + 👋 + + + +); + +export const WelcomeStep = ({ + organizationName, + nextHref, +}: { + organizationName: string | null; + nextHref: string; +}) => { + const router = useRouter(); + const rpc = useRpcClient(); + const inputId = useId(); + const inputRef = useRef(null); + const [name, setName] = useState(""); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + const ready = name.trim().length > 0; + + const welcome = useEffectMutation({ + mutationFn: (data: { firstName: string; lastName?: string }) => + rpc.UserCompleteOnboardingStep({ step: "welcome", data }), + onSuccess: () => { + trackEvent("onboarding_name_saved"); + startTransition(() => { + router.push(nextHref); + router.refresh(); + }); + }, + onError: () => { + toast.error("We couldn't save your name. Please try again."); + }, + }); + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!ready || welcome.isPending || welcome.isSuccess) return; + const { firstName, lastName } = splitFullName(name); + welcome.mutate({ firstName, lastName: lastName || undefined }); + }; + + const busy = welcome.isPending || welcome.isSuccess; + + return ( + +
    +
    + + Hey, welcome to{" "} + + {organizationName ?? "Cap"} + {"\u00a0"} + + + + + {organizationName + ? "You're one step away from your team's videos. What should we call you?" + : "Let's get your first video out the door. First, what should we call you?"} + +
    + + setName(event.target.value)} + placeholder="Ada Lovelace" + autoComplete="name" + autoCapitalize="words" + enterKeyHint="go" + maxLength={120} + disabled={busy} + required + ref={inputRef} + /> + +

    + You can change this any time in settings. +

    +
    +
    + +
    +
    + ); +}; diff --git a/apps/web/app/(org)/onboarding/components/paper.tsx b/apps/web/app/(org)/onboarding/components/paper.tsx new file mode 100644 index 00000000000..1a9981433bf --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/paper.tsx @@ -0,0 +1,283 @@ +import clsx from "clsx"; +import type { CSSProperties, ReactNode } from "react"; +import { ONBOARDING_PROGRESS_TOTAL } from "../onboarding-flow"; + +export const BoilFilter = ({ id }: { id: string }) => ( + +); + +export const CapWordmark = ({ className }: { className?: string }) => ( + + + + + + +); + +export const LoomMark = ({ + className, + size = 18, +}: { + className?: string; + size?: number; +}) => ( + +); + +export const LOOM_MARK_PATH = + "M15 7.222h-4.094l3.546-2.047-.779-1.35-3.545 2.048 2.046-3.546-1.349-.779L8.78 5.093V1H7.22v4.094L5.174 1.548l-1.348.779 2.046 3.545-3.545-2.046-.779 1.348 3.546 2.047H1v1.557h4.093l-3.545 2.047.779 1.35 3.545-2.047-2.047 3.545 1.35.779 2.046-3.546V15h1.557v-4.094l2.047 3.546 1.349-.779-2.047-3.546 3.545 2.047.779-1.349-3.545-2.046h4.093L15 7.222zm-7 2.896a2.126 2.126 0 110-4.252 2.126 2.126 0 010 4.252z"; + +export const SceneCursor = ({ name }: { name: string }) => ( + + + +); + +export const SceneRipple = ({ + x, + y, + at, + r = 12, +}: { + x: number; + y: number; + at: number; + r?: number; +}) => ( + +); + +const SPARK_SHAPE = (x: number, y: number, s: number) => + `M ${x} ${y - 2 * s} L ${x} ${y - s * 0.6} M ${x} ${y + s * 0.6} L ${x} ${y + 2 * s} M ${x - 2 * s} ${y} L ${x - s * 0.6} ${y} M ${x + s * 0.6} ${y} L ${x + 2 * s} ${y}`; + +export const LoopSparks = ({ + points, + at, +}: { + points: readonly (readonly [number, number, number])[]; + at: number; +}) => ( + <> + {points.map(([x, y, s], index) => ( + + ))} + +); + +const PROGRESS_MARKS = Array.from( + { length: ONBOARDING_PROGRESS_TOTAL }, + (_, mark) => mark, +); + +export const ProgressMarks = ({ index }: { index: number }) => ( +
    + + Step {index + 1} of {ONBOARDING_PROGRESS_TOTAL} + + {PROGRESS_MARKS.map((mark) => ( + + ))} +
    +); + +const SQUIGGLE = + "M 6 14 q 6 -7 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0"; + +export const SquiggleProgress = ({ progress }: { progress: number | null }) => ( +
    + + + {Math.round((progress ?? 0) * 100)}% + +
    +); + +const STEP_RING = + "M 13 2.2 C 19.5 2 24 6.6 23.8 13 C 23.6 19.6 18.8 23.9 12.6 23.8 C 6.3 23.6 2.1 19.2 2.2 12.8 C 2.3 7.2 6.4 2.6 12.2 2.3"; + +export type ExplainerStep = { title: string; body?: ReactNode }; + +export const Explainer = ({ + scene, + steps, + loopSeconds, + label, + className, + layout = "stacked", +}: { + scene: ReactNode; + steps: readonly [ExplainerStep, ExplainerStep, ExplainerStep]; + loopSeconds: number; + label: string; + className?: string; + layout?: "stacked" | "split"; +}) => ( +
    +
    + {scene} +
    +
    +

    {label}

    +
      + {steps.map((step, index) => ( +
    1. + + + + {index + 1} + + + + + {step.title} + + {step.body && ( + + {step.body} + + )} + +
    2. + ))} +
    +
    +
    +); diff --git a/apps/web/app/(org)/onboarding/components/scenes.tsx b/apps/web/app/(org)/onboarding/components/scenes.tsx new file mode 100644 index 00000000000..b746477510f --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/scenes.tsx @@ -0,0 +1,1186 @@ +import type { CSSProperties } from "react"; +import { LOOM_MARK_PATH, LoopSparks, SceneCursor, SceneRipple } from "./paper"; + +const white = { fill: "var(--ob-surface)" } as const; +const paper2 = { fill: "var(--ob-paper-2)" } as const; + +const PlayTriangle = ({ + x, + y, + size = 1, + color = "var(--ob-ink-faint)", +}: { + x: number; + y: number; + size?: number; + color?: string; +}) => ( + +); + +export const LoomScene = () => ( + +); + +const SOURCE_GLYPHS = [ + { + label: "Screen", + color: "var(--ob-screen)", + glyph: "M -5.5 -4 L 5.5 -4 L 5.5 3 L -5.5 3 Z M -2.5 5.8 L 2.5 5.8", + }, + { + label: "Camera", + color: "var(--ob-camera)", + glyph: + "M -5.5 -2.8 L -2.8 -2.8 L -1.6 -4.8 L 1.6 -4.8 L 2.8 -2.8 L 5.5 -2.8 L 5.5 4.8 L -5.5 4.8 Z M 0 -0.6 m -2 0 a 2 2 0 1 0 4 0 a 2 2 0 1 0 -4 0", + }, + { + label: "Mic", + color: "var(--ob-mic)", + glyph: + "M -2 -5.6 L 2 -5.6 L 2 0.8 Q 2 2.8 0 2.8 Q -2 2.8 -2 0.8 Z M -4.4 0 Q -4.4 4.8 0 4.8 Q 4.4 4.8 4.4 0 M 0 4.8 L 0 6.8", + }, +] as const; + +const MIC_BARS = Array.from({ length: 16 }, (_, index) => ({ + id: `bar-${index}`, + x: 30 + index * 12, + j: index % 7, +})); + +const SCREEN_CARDS = [ + { id: "now-1", x: 32, y: 60, tag: "var(--ob-green)", line: 34 }, + { id: "now-2", x: 32, y: 84, tag: "var(--ob-green)", line: 24 }, + { id: "next-1", x: 92, y: 60, tag: "var(--ob-accent)", line: 30 }, + { id: "next-2", x: 92, y: 84, tag: "var(--ob-accent)", line: 38 }, + { id: "later-1", x: 152, y: 60, tag: "var(--ob-camera)", line: 28 }, +] as const; + +export const RecordScene = () => ( + +); + +const UPLOAD_SQUIGGLE = + "M 172 112 q 6 -7 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0 t 12 0"; + +export const UploadScene = () => ( + +); + +const TEAM = [ + { initial: "A", fill: "var(--ob-team-1)" }, + { initial: "M", fill: "var(--ob-team-2)" }, + { initial: "J", fill: "var(--ob-team-3)" }, +] as const; + +const LIBRARY = Array.from({ length: 6 }, (_, index) => ({ + id: `thumb-${index}`, + x: 214 + (index % 3) * 44, + y: index < 3 ? 110 : 148, + highlight: index === 5, + j: index, +})); + +const truncate = (value: string, max: number) => + value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value; + +export const JoinScene = ({ + organizationName, + initial, +}: { + organizationName: string; + initial: string; +}) => ( + +); + +export const LoomToCapDoodle = () => ( + +); + +export const RecordDoodle = () => ( + +); + +export const UploadDoodle = () => ( + +); + +export const DoneDoodle = () => ( + +); diff --git a/apps/web/app/(org)/onboarding/components/use-get-started.ts b/apps/web/app/(org)/onboarding/components/use-get-started.ts new file mode 100644 index 00000000000..be7e3bcf7d1 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/use-get-started.ts @@ -0,0 +1,56 @@ +"use client"; + +import type { User } from "@cap/web-domain"; +import Cookies from "js-cookie"; +import { useCallback, useEffect, useRef } from "react"; +import { trackEvent } from "@/app/utils/analytics"; +import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime"; +import { clearOnboardingNextPath } from "../../onboarding-next"; +import { THEME_COOKIE } from "../onboarding-flow"; + +const carryThemeToDashboard = () => { + if (Cookies.get(THEME_COOKIE)) return; + if (!window.matchMedia("(prefers-color-scheme: dark)").matches) return; + Cookies.set(THEME_COOKIE, "dark", { expires: 365 }); +}; + +export const useGetStarted = ( + initiallyComplete: boolean, + autoCompletePath?: User.OnboardingStartPath, +) => { + const rpc = useRpcClient(); + const completeRef = useRef(initiallyComplete); + const pendingRef = useRef | null>(null); + + const { mutateAsync, isPending } = useEffectMutation({ + mutationFn: (path: User.OnboardingStartPath) => + rpc.UserCompleteOnboardingStep({ step: "getStarted", data: { path } }), + }); + + const complete = useCallback( + async (path: User.OnboardingStartPath) => { + if (completeRef.current) return; + if (!pendingRef.current) { + pendingRef.current = mutateAsync(path) + .then(() => { + completeRef.current = true; + clearOnboardingNextPath(); + carryThemeToDashboard(); + trackEvent("onboarding_completed", { path }); + }) + .finally(() => { + pendingRef.current = null; + }); + } + await pendingRef.current; + }, + [mutateAsync], + ); + + useEffect(() => { + if (!autoCompletePath) return; + complete(autoCompletePath).catch(() => undefined); + }, [autoCompletePath, complete]); + + return { complete, isCompleting: isPending }; +}; diff --git a/apps/web/app/(org)/onboarding/components/use-upgrade-confirmation.ts b/apps/web/app/(org)/onboarding/components/use-upgrade-confirmation.ts new file mode 100644 index 00000000000..c1848b0b109 --- /dev/null +++ b/apps/web/app/(org)/onboarding/components/use-upgrade-confirmation.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +const POLL_INTERVAL_MS = 2500; +const MAX_POLLS = 12; + +export const useUpgradeConfirmation = ({ + justUpgraded, + isPro, +}: { + justUpgraded: boolean; + isPro: boolean; +}) => { + const router = useRouter(); + const [awaiting] = useState(justUpgraded && !isPro); + const [polls, setPolls] = useState(0); + const pending = awaiting && !isPro; + + useEffect(() => { + if (!pending || polls >= MAX_POLLS) return; + const timer = window.setTimeout(() => { + router.refresh(); + setPolls((count) => count + 1); + }, POLL_INTERVAL_MS); + return () => window.clearTimeout(timer); + }, [pending, polls, router]); + + return { + waiting: pending && polls < MAX_POLLS, + timedOut: pending && polls >= MAX_POLLS, + }; +}; diff --git a/apps/web/app/(org)/onboarding/layout.tsx b/apps/web/app/(org)/onboarding/layout.tsx index 01164bbc2dd..a8d24995ca9 100644 --- a/apps/web/app/(org)/onboarding/layout.tsx +++ b/apps/web/app/(org)/onboarding/layout.tsx @@ -1,6 +1,15 @@ +import "./onboarding.css"; import { getCurrentUser } from "@cap/database/auth/session"; -import Bottom from "./components/Bottom"; -import Stepper from "./components/Stepper"; +import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { OnboardingHeader } from "./components/OnboardingHeader"; +import { PaperRoot } from "./components/PaperRoot"; +import { onboardingThemeFromCookie, THEME_COOKIE } from "./onboarding-flow"; + +export const metadata: Metadata = { + title: "Get started — Cap", +}; export default async function OnboardingLayout({ children, @@ -8,13 +17,15 @@ export default async function OnboardingLayout({ children: React.ReactNode; }) { const user = await getCurrentUser(); - const completedSteps = user?.onboardingSteps || {}; + if (!user) redirect("/login"); + const theme = onboardingThemeFromCookie( + (await cookies()).get(THEME_COOKIE)?.value, + ); return ( -
    - - {children} - -
    + + +
    {children}
    +
    ); } diff --git a/apps/web/app/(org)/onboarding/onboarding-flow.ts b/apps/web/app/(org)/onboarding/onboarding-flow.ts new file mode 100644 index 00000000000..f3120bcb734 --- /dev/null +++ b/apps/web/app/(org)/onboarding/onboarding-flow.ts @@ -0,0 +1,170 @@ +export const ONBOARDING_PATH_STEPS = ["loom", "record", "upload"] as const; +export type OnboardingPathStep = (typeof ONBOARDING_PATH_STEPS)[number]; + +export const ONBOARDING_STEPS = [ + "welcome", + "start", + ...ONBOARDING_PATH_STEPS, +] as const; +export type OnboardingStep = (typeof ONBOARDING_STEPS)[number]; + +export const ONBOARDING_NEXT_COOKIE = "cap_onboarding_next"; +export const THEME_COOKIE = "theme"; + +export type OnboardingThemePreference = "light" | "dark" | "system"; + +export const onboardingThemeFromCookie = ( + value: string | undefined, +): OnboardingThemePreference => + value === "light" || value === "dark" ? value : "system"; + +export const resolveOnboardingTheme = ( + preference: OnboardingThemePreference, + systemPrefersDark: boolean, +) => + preference === "system" ? (systemPrefersDark ? "dark" : "light") : preference; + +const LEGACY_STEP_REDIRECTS: Record = { + "organization-setup": "start", + "custom-domain": "start", + "invite-team": "start", + download: "record", +}; + +type OnboardingUser = { + name: string | null; + onboardingSteps: { getStarted?: boolean } | null; +}; + +export const hasOnboardingName = (user: Pick) => + Boolean(user.name?.trim()); + +export const needsOnboarding = (user: OnboardingUser) => + !hasOnboardingName(user) || user.onboardingSteps?.getStarted === false; + +export const isOnboardingStep = (value: string): value is OnboardingStep => + (ONBOARDING_STEPS as readonly string[]).includes(value); + +export type OnboardingStepResolution = + | { kind: "render"; step: OnboardingStep } + | { kind: "redirect"; step: OnboardingStep }; + +export const resolveOnboardingStep = ( + requested: string | undefined, + user: Pick, +): OnboardingStepResolution => { + if (!hasOnboardingName(user)) { + return requested === "welcome" + ? { kind: "render", step: "welcome" } + : { kind: "redirect", step: "welcome" }; + } + + if (requested === undefined || requested === "welcome") { + return { kind: "redirect", step: "start" }; + } + + const legacy = LEGACY_STEP_REDIRECTS[requested]; + if (legacy) return { kind: "redirect", step: legacy }; + + if (isOnboardingStep(requested)) return { kind: "render", step: requested }; + + return { kind: "redirect", step: "start" }; +}; + +export type OnboardingIntent = + | { kind: "loom"; loomUrl?: string; bulk: boolean } + | { kind: "upload" } + | { kind: "record" } + | { kind: "elsewhere"; path: string }; + +const INTENT_BASE = "http://cap.invalid"; + +export const onboardingIntentFromNextPath = ( + nextPath: string | null | undefined, +): OnboardingIntent | null => { + if (!nextPath) return null; + + let url: URL; + try { + url = new URL(nextPath, INTENT_BASE); + } catch { + return null; + } + if (url.origin !== INTENT_BASE) return null; + + const pathname = url.pathname.replace(/\/+$/, "") || "/"; + + if (pathname === "/dashboard/import/loom") { + return { + kind: "loom", + loomUrl: url.searchParams.get("url")?.trim() || undefined, + bulk: url.searchParams.get("mode") === "csv", + }; + } + if (pathname === "/dashboard/import" || pathname === "/dashboard/import/file") + return { kind: "upload" }; + if (pathname === "/dashboard/caps/record") return { kind: "record" }; + if ( + pathname === "/" || + pathname === "/dashboard" || + pathname === "/dashboard/caps" || + pathname.startsWith("/onboarding") || + pathname.startsWith("/invite/") + ) + return null; + + return { + kind: "elsewhere", + path: `${url.pathname}${url.search}${url.hash}`, + }; +}; + +export const onboardingStepHref = ( + step: OnboardingStep, + params?: Record, +) => { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params ?? {})) { + if (value) query.set(key, value); + } + const search = query.toString(); + return `/onboarding/${step}${search ? `?${search}` : ""}`; +}; + +export const onboardingHrefForIntent = (intent: OnboardingIntent | null) => { + switch (intent?.kind) { + case "loom": + return onboardingStepHref("loom", { + url: intent.loomUrl, + bulk: intent.bulk ? "1" : undefined, + }); + case "upload": + return onboardingStepHref("upload"); + case "record": + return onboardingStepHref("record"); + default: + return onboardingStepHref("start"); + } +}; + +export const onboardingContinuePath = (intent: OnboardingIntent | null) => + intent?.kind === "elsewhere" ? intent.path : "/dashboard/caps"; + +export const onboardingProgressIndex = (step: OnboardingStep) => + step === "welcome" ? 0 : step === "start" ? 1 : 2; + +export const ONBOARDING_PROGRESS_TOTAL = 3; + +export const splitFullName = (fullName: string) => { + const [firstName = "", ...rest] = fullName.trim().split(/\s+/); + return { firstName, lastName: rest.join(" ") }; +}; + +export const maskEmail = (email: string) => { + const at = email.lastIndexOf("@"); + if (at <= 0) return email; + const local = email.slice(0, at); + const visible = local.length > 4 ? 2 : 1; + const hidden = Math.min(5, Math.max(3, local.length - visible)); + return `${local.slice(0, visible)}${"•".repeat(hidden)}${email.slice(at)}`; +}; diff --git a/apps/web/app/(org)/onboarding/onboarding.css b/apps/web/app/(org)/onboarding/onboarding.css new file mode 100644 index 00000000000..13b18e5ceb8 --- /dev/null +++ b/apps/web/app/(org)/onboarding/onboarding.css @@ -0,0 +1,1562 @@ +.cap-ob { + --ob-paper: #fbfaf7; + --ob-paper-2: #f4f2ec; + --ob-surface: #ffffff; + --ob-surface-soft: rgba(255, 255, 255, 0.65); + --ob-card: rgba(255, 255, 255, 0.78); + --ob-dot: rgba(32, 36, 44, 0.075); + --ob-cta-hover: #343944; + --ob-shadow: rgba(32, 36, 44, 0.45); + --ob-ink: #20242c; + --ob-ink-2: #4a4f59; + --ob-ink-soft: #6b6f78; + --ob-ink-faint: #a9acb3; + --ob-track: #e4e1d9; + --ob-track-strong: #d3cfc4; + --ob-accent: #4785ff; + --ob-accent-soft: rgba(71, 133, 255, 0.14); + --ob-red: #e5484d; + --ob-green: #2f9e69; + --ob-loom: #625df5; + --ob-loom-soft: rgba(98, 93, 245, 0.14); + --ob-screen: #3b82f6; + --ob-camera: #8b5cf6; + --ob-mic: #22c55e; + --ob-team-1: color-mix(in srgb, #f59e0b 24%, var(--ob-surface)); + --ob-team-2: color-mix(in srgb, #3b82f6 22%, var(--ob-surface)); + --ob-team-3: color-mix(in srgb, #22c55e 22%, var(--ob-surface)); + position: relative; + min-height: 100dvh; + background-color: var(--ob-paper); + background-image: radial-gradient(var(--ob-dot) 1px, transparent 1.2px); + background-size: 22px 22px; + color: var(--ob-ink); + color-scheme: light; + -webkit-font-smoothing: antialiased; + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +.cap-ob[data-theme="dark"] { + --ob-paper: #161618; + --ob-paper-2: #1f1f22; + --ob-surface: #232327; + --ob-surface-soft: rgba(38, 38, 43, 0.6); + --ob-card: rgba(32, 32, 36, 0.82); + --ob-dot: rgba(238, 236, 230, 0.06); + --ob-cta-hover: #ffffff; + --ob-shadow: rgba(0, 0, 0, 0.7); + --ob-ink: #eeece6; + --ob-ink-2: #cfcdc7; + --ob-ink-soft: #a09fa6; + --ob-ink-faint: #67666e; + --ob-track: #2e2e33; + --ob-track-strong: #45454c; + --ob-accent: #6c9eff; + --ob-accent-soft: rgba(108, 158, 255, 0.18); + --ob-red: #ff6369; + --ob-green: #3dd68c; + --ob-loom: #8d89ff; + --ob-loom-soft: rgba(141, 137, 255, 0.18); + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + .cap-ob[data-theme="system"] { + --ob-paper: #161618; + --ob-paper-2: #1f1f22; + --ob-surface: #232327; + --ob-surface-soft: rgba(38, 38, 43, 0.6); + --ob-card: rgba(32, 32, 36, 0.82); + --ob-dot: rgba(238, 236, 230, 0.06); + --ob-cta-hover: #ffffff; + --ob-shadow: rgba(0, 0, 0, 0.7); + --ob-ink: #eeece6; + --ob-ink-2: #cfcdc7; + --ob-ink-soft: #a09fa6; + --ob-ink-faint: #67666e; + --ob-track: #2e2e33; + --ob-track-strong: #45454c; + --ob-accent: #6c9eff; + --ob-accent-soft: rgba(108, 158, 255, 0.18); + --ob-red: #ff6369; + --ob-green: #3dd68c; + --ob-loom: #8d89ff; + --ob-loom-soft: rgba(141, 137, 255, 0.18); + color-scheme: dark; + } +} + +.ob-when-dark { + display: none; +} +.cap-ob[data-theme="dark"] .ob-when-dark { + display: inline-flex; +} +.cap-ob[data-theme="dark"] .ob-when-light { + display: none; +} +@media (prefers-color-scheme: dark) { + .cap-ob[data-theme="system"] .ob-when-dark { + display: inline-flex; + } + .cap-ob[data-theme="system"] .ob-when-light { + display: none; + } +} +.ob-when-light { + display: inline-flex; +} + +.ob-theme-toggle { + display: inline-flex; + width: 36px; + height: 36px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 1.5px solid var(--ob-track); + border-radius: 999px; + background: var(--ob-surface-soft); + color: var(--ob-ink); + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + transform 0.18s ease; +} +.ob-theme-toggle:active { + transform: scale(0.94); +} +.ob-theme-icon { + animation: ob-theme-in 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +.ob-wave { + display: inline-block; + transform-origin: 70% 80%; + animation: ob-wave 1.9s ease-in-out 0.35s 2 both; +} + +.cap-ob ::selection { + background: var(--ob-accent-soft); +} + +.ob-card { + border: 1.5px solid var(--ob-track); + border-radius: 22px; + background: var(--ob-card); + backdrop-filter: blur(6px); +} + +.ob-scene-frame { + border-radius: 16px; + background: var(--ob-paper-2); + box-shadow: inset 0 0 0 1.5px var(--ob-track); +} + +.ob-cta { + display: inline-flex; + min-height: 48px; + flex-shrink: 0; + align-items: center; + justify-content: center; + gap: 8px; + border: 1.5px solid var(--ob-ink); + border-radius: 999px; + padding: 0 22px; + background: var(--ob-ink); + color: var(--ob-paper); + font-size: 15px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: + transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + background 0.18s ease, + color 0.18s ease, + border-color 0.18s ease, + opacity 0.18s ease; +} +@media (hover: hover) { + .ob-theme-toggle:hover { + border-color: var(--ob-ink); + background: var(--ob-surface); + } + .ob-greeting:hover .ob-wave { + animation: ob-wave 1.9s ease-in-out infinite; + } + .ob-cta:hover:not(:disabled) { + background: var(--ob-cta-hover); + border-color: var(--ob-cta-hover); + } + .ob-cta.is-outline:hover:not(:disabled) { + background: var(--ob-ink); + color: var(--ob-paper); + border-color: var(--ob-ink); + } + .ob-cta.is-quiet:hover:not(:disabled) { + border-color: var(--ob-ink); + background: var(--ob-surface); + } + .ob-cta:hover:not(:disabled) .ob-cta-arrow { + transform: translateX(3px); + } + .ob-link:hover { + color: var(--ob-ink); + text-decoration-color: var(--ob-ink); + } + .ob-choice:hover { + transform: translateY(-3px); + border-color: var(--ob-ink); + background: var(--ob-surface); + box-shadow: 0 18px 36px -26px var(--ob-shadow); + } + .ob-choice:hover .ob-doodle-hover { + animation: ob-bob 1.2s ease-in-out infinite; + } + .ob-choice:hover .ob-choice-arrow { + transform: translateX(4px); + color: var(--ob-ink); + } + .ob-drop:hover:not(:disabled) .ob-drop-outline { + border-color: var(--ob-accent); + } +} +.ob-touch-only { + display: none; +} +@media (hover: none) and (pointer: coarse) { + .ob-touch-only { + display: inline; + } + .ob-pointer-only { + display: none; + } +} +.ob-cta:active:not(:disabled) { + transform: scale(0.98); +} +.ob-cta:disabled { + cursor: not-allowed; + opacity: 0.45; +} +.ob-cta.is-outline { + background: transparent; + color: var(--ob-ink); +} +.ob-cta.is-quiet { + border-color: var(--ob-track-strong); + background: var(--ob-surface-soft); + color: var(--ob-ink); +} +.ob-cta.is-small { + min-height: 40px; + padding: 0 16px; + font-size: 14px; +} +.ob-cta .ob-cta-arrow { + transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.ob-focus:focus-visible, +.ob-cta:focus-visible, +.ob-link:focus-visible { + outline: 2px solid var(--ob-accent); + outline-offset: 3px; +} + +.ob-link { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 6px; + color: var(--ob-ink-2); + font-size: 14px; + font-weight: 500; + text-decoration: underline; + text-decoration-color: var(--ob-track-strong); + text-decoration-thickness: 1.5px; + text-underline-offset: 5px; + transition: + color 0.18s ease, + text-decoration-color 0.18s ease; +} + +.ob-input { + width: 100%; + min-height: 56px; + border: 1.5px solid var(--ob-track-strong); + border-radius: 16px; + padding: 0 18px; + background: var(--ob-surface); + color: var(--ob-ink); + font-size: 17px; + transition: + border-color 0.18s ease, + box-shadow 0.18s ease; +} +.ob-input::placeholder { + color: var(--ob-ink-faint); +} +.ob-input:focus { + outline: none; + border-color: var(--ob-ink); + box-shadow: 0 0 0 4px var(--ob-accent-soft); +} +.ob-input[aria-invalid="true"] { + border-color: var(--ob-red); +} + +.ob-choice { + position: relative; + display: flex; + border: 1.5px solid var(--ob-track); + border-radius: 22px; + background: var(--ob-card); + text-align: left; + transition: + transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), + border-color 0.18s ease, + box-shadow 0.22s ease, + background 0.18s ease; +} +.ob-choice:active { + transform: translateY(-1px) scale(0.995); +} +.ob-choice:focus-visible { + outline: 2px solid var(--ob-accent); + outline-offset: 3px; +} +.ob-choice .ob-choice-arrow { + transition: + transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), + color 0.18s ease; +} + +.ob-drop { + position: relative; + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 24px; + background: var(--ob-surface-soft); + text-align: center; + transition: background 0.18s ease; +} +.ob-drop[data-over="true"] { + background: rgba(71, 133, 255, 0.06); +} +.ob-drop-outline { + position: absolute; + inset: 0; + border: 2px dashed var(--ob-track-strong); + border-radius: 24px; + pointer-events: none; + transition: border-color 0.18s ease; +} +.ob-drop[data-over="true"] .ob-drop-outline { + border-color: var(--ob-accent); +} + +.ob-boil { + filter: var(--ob-boil-filter, none); +} +.ob-ink { + fill: none; + stroke: var(--ob-ink); + stroke-width: 2.2; + stroke-linecap: round; + stroke-linejoin: round; +} +.ob-ink.is-bold { + stroke-width: 3; +} +.ob-ink.is-soft { + stroke: var(--ob-ink-faint); +} +.ob-ink.is-track { + stroke: var(--ob-track-strong); +} +.ob-ink.is-accent { + stroke: var(--ob-accent); +} +.ob-ink.is-red { + stroke: var(--ob-red); +} +.ob-ink.is-green { + stroke: var(--ob-green); +} +.ob-ink.is-loom { + stroke: var(--ob-loom); +} +.ob-label { + fill: var(--ob-ink); + font-family: inherit; +} +.ob-label.is-soft { + fill: var(--ob-ink-soft); +} +.ob-label.is-faint { + fill: var(--ob-ink-faint); +} +.ob-label.is-on-ink { + fill: var(--ob-paper); +} +.ob-label.is-white { + fill: #fff; +} + +.ob-draw { + stroke-dasharray: 1 2; + stroke-dashoffset: 1; + animation: ob-draw 0.9s ease var(--d, 0s) forwards; +} +.ob-bob { + animation: ob-bob 1.9s ease-in-out infinite; +} +.ob-rise { + animation: ob-rise 0.55s cubic-bezier(0.2, 0.8, 0.2, 1) var(--d, 0s) both; +} +.ob-fade { + animation: ob-fade 0.4s ease var(--d, 0s) both; +} +.ob-pulse { + animation: ob-pulse 1.6s ease-in-out infinite; +} +.ob-march { + stroke-dasharray: 2 5; + animation: ob-march 1s linear infinite; +} +.ob-spin { + animation: ob-spin 0.9s linear infinite; + transform-origin: center; + transform-box: fill-box; +} + +.ob-progress-mark { + fill: none; + stroke-width: 3; + stroke-linecap: round; + transition: stroke 0.3s ease; +} +.ob-progress-mark.is-done { + stroke: var(--ob-ink); +} +.ob-progress-mark.is-current { + stroke: var(--ob-accent); + stroke-dasharray: 1 2; + stroke-dashoffset: 1; + animation: ob-draw 0.7s ease 0.15s forwards; +} +.ob-progress-mark.is-next { + stroke: var(--ob-track-strong); + stroke-dasharray: 0.04 0.09; +} + +.ob-squiggle-track { + fill: none; + stroke: var(--ob-track-strong); + stroke-width: 2.5; + stroke-linecap: round; + stroke-dasharray: 0.45 1.15; +} +.ob-squiggle-progress { + fill: none; + stroke: var(--ob-accent); + stroke-width: 3.2; + stroke-linecap: round; + stroke-dasharray: 100; + transition: stroke-dashoffset 0.6s cubic-bezier(0.2, 0.8, 0.2, 1); +} +.ob-squiggle-progress.is-waiting { + stroke: var(--ob-ink-faint); + stroke-dasharray: 2 4.2; + stroke-dashoffset: 0; + animation: ob-march 1.2s linear infinite; +} + +.ob-step { + --ob-step-ring: var(--ob-ink-faint); + --ob-step-num: var(--ob-ink); + --ob-step-text: var(--ob-ink); + animation: ob-step-focus var(--loop) step-end infinite; + animation-delay: calc((var(--i) / 3 - 1) * var(--loop)); +} +.ob-step-ring { + fill: none; + stroke: var(--ob-step-ring); + stroke-width: 2; + stroke-linecap: round; + transition: stroke 0.25s ease; +} +.ob-step-num { + color: var(--ob-step-num); + transition: color 0.25s ease; +} +.ob-step-text { + color: var(--ob-step-text); + transition: color 0.25s ease; +} + +.ob-scene { + display: block; + width: 100%; + height: auto; + overflow: visible; +} +.ob-scene .ob-tf { + transform-box: fill-box; + transform-origin: center; +} +.ob-scene .ob-tf-left { + transform-box: fill-box; + transform-origin: left center; +} +.ob-scene .ob-tf-right { + transform-box: fill-box; + transform-origin: right center; +} +.ob-scene .ob-tf-bottom { + transform-box: fill-box; + transform-origin: center bottom; +} + +.ob-cursor { + opacity: 0; + animation: var(--cursor) var(--loop) cubic-bezier(0.45, 0, 0.2, 1) infinite; +} +.ob-ripple { + opacity: 0; + transform-box: fill-box; + transform-origin: center; + animation: ob-ripple var(--loop) ease-out infinite; + animation-delay: calc((var(--at) - 1) * var(--loop)); +} +.ob-press { + transform-box: fill-box; + transform-origin: center; + animation: ob-press var(--loop) ease infinite; + animation-delay: calc((var(--at) - 1) * var(--loop)); +} +.ob-loop-spark { + opacity: 0; + fill: none; + stroke: var(--ob-accent); + stroke-width: 2.2; + stroke-linecap: round; + transform-box: fill-box; + transform-origin: center; + animation: ob-loop-spark var(--loop) ease infinite; + animation-delay: calc((var(--at) - 1) * var(--loop)); +} + +.ob-pv-progress { + transform: scaleX(0.42); + transform-origin: left center; + animation: ob-pv-progress 12s linear infinite; +} +.ob-pv-cursor { + transform: translate(198px, 104px); + animation: ob-pv-cursor 12s cubic-bezier(0.45, 0, 0.2, 1) infinite; +} +.ob-pv-circle { + stroke-dasharray: 1 2; + stroke-dashoffset: 0; + animation: ob-pv-circle 12s ease infinite; +} +.ob-pv-cam { + animation: ob-pv-cam 4.2s ease-in-out infinite; +} + +.ob-loom-copy-label { + opacity: 0; + animation: ob-loom-copy var(--loop) linear infinite; +} +.ob-loom-copied-label { + animation: ob-loom-copied var(--loop) linear infinite; +} +.ob-loom-placeholder { + opacity: 0; + animation: ob-loom-placeholder var(--loop) linear infinite; +} +.ob-loom-typed-cover { + transform: scaleX(0); + animation: ob-loom-typed var(--loop) linear infinite; +} +.ob-loom-input-focus { + opacity: 0; + animation: ob-loom-focus var(--loop) linear infinite; +} +.ob-loom-flyer { + opacity: 0; + animation: ob-loom-fly var(--loop) cubic-bezier(0.45, 0, 0.25, 1) infinite; +} +.ob-loom-slot-fill { + animation: ob-loom-slot var(--loop) linear infinite; +} +.ob-loom-check { + stroke-dasharray: 1 2; + stroke-dashoffset: 0; + animation: ob-loom-check var(--loop) ease infinite; +} + +.ob-rec-switch { + fill: var(--ob-accent); + animation: var(--switch) var(--loop) step-end infinite; +} +.ob-rec-knob { + transform: translateX(9px); + animation: var(--knob) var(--loop) cubic-bezier(0.2, 0.8, 0.2, 1) infinite; +} +.ob-rec-start { + animation: ob-rec-start var(--loop) linear infinite; +} +.ob-rec-stop { + opacity: 0; + animation: ob-rec-stop var(--loop) linear infinite; +} +.ob-rec-live { + opacity: 0; + animation: ob-rec-live var(--loop) linear infinite; +} +.ob-rec-time { + opacity: 0; + animation: ob-rec-time var(--loop) step-end infinite; + animation-delay: calc((var(--at) - 1) * var(--loop)); +} +.ob-rec-time.is-last { + opacity: 1; + animation-name: ob-rec-time-last; +} +.ob-rec-cam { + animation: ob-rec-cam var(--loop) cubic-bezier(0.2, 0.8, 0.2, 1) infinite; +} +.ob-rec-bar { + transform-box: fill-box; + transform-origin: center; + animation: ob-rec-bar 0.9s ease-in-out infinite; + animation-delay: calc(var(--j) * -0.13s); +} +.ob-rec-link { + animation: ob-rec-link var(--loop) cubic-bezier(0.2, 0.8, 0.2, 1) infinite; +} + +.ob-up-file { + opacity: 0; + animation: ob-up-file var(--loop) cubic-bezier(0.45, 0, 0.25, 1) infinite; +} +.ob-up-zone-hint { + opacity: 0; + animation: ob-up-hint var(--loop) linear infinite; +} +.ob-up-zone-hot { + opacity: 0; + animation: + ob-march-long 1.2s linear infinite, + ob-up-hot var(--loop) linear infinite; +} +.ob-up-progress-group { + opacity: 0; + animation: ob-up-progress-group var(--loop) linear infinite; +} +.ob-up-progress { + stroke-dasharray: 100; + stroke-dashoffset: 0; + animation: ob-up-progress var(--loop) linear infinite; +} +.ob-up-done { + animation: ob-up-done var(--loop) cubic-bezier(0.2, 0.8, 0.2, 1) infinite; +} + +.ob-join-flap-closed { + opacity: 0; + animation: ob-join-flap-closed var(--loop) step-end infinite; +} +.ob-join-flap-open { + animation: ob-join-flap-open var(--loop) step-end infinite; +} +.ob-join-card { + animation: ob-join-card var(--loop) cubic-bezier(0.2, 0.8, 0.2, 1) infinite; +} +.ob-join-you { + animation: ob-join-you var(--loop) cubic-bezier(0.2, 0.8, 0.2, 1) infinite; +} +.ob-join-you-label { + animation: ob-join-you-label var(--loop) linear infinite; +} +.ob-join-thumb { + animation: ob-join-thumb var(--loop) cubic-bezier(0.2, 0.8, 0.2, 1) infinite; + animation-delay: calc((var(--j) * 0.05 - 1) * var(--loop)); +} + +@keyframes ob-theme-in { + from { + opacity: 0; + transform: rotate(-40deg) scale(0.6); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes ob-wave { + 0%, + 60%, + 100% { + transform: rotate(0deg); + } + 10% { + transform: rotate(16deg); + } + 20% { + transform: rotate(-8deg); + } + 30% { + transform: rotate(16deg); + } + 40% { + transform: rotate(-4deg); + } + 50% { + transform: rotate(10deg); + } +} +@keyframes ob-draw { + to { + stroke-dashoffset: 0; + } +} +@keyframes ob-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} +@keyframes ob-rise { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: none; + } +} +@keyframes ob-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} +@keyframes ob-pulse { + 50% { + opacity: 0.35; + } +} +@keyframes ob-march { + to { + stroke-dashoffset: -7; + } +} +@keyframes ob-march-long { + to { + stroke-dashoffset: -16; + } +} +@keyframes ob-spin { + to { + transform: rotate(360deg); + } +} +@keyframes ob-step-focus { + 0% { + --ob-step-ring: var(--ob-accent); + --ob-step-num: var(--ob-accent); + --ob-step-text: var(--ob-ink); + } + 33.333% { + --ob-step-ring: var(--ob-track-strong); + --ob-step-num: var(--ob-ink-soft); + --ob-step-text: var(--ob-ink-soft); + } +} +@keyframes ob-ripple { + 0% { + opacity: 0.45; + transform: scale(0.4); + } + 9%, + 100% { + opacity: 0; + transform: scale(1.9); + } +} +@keyframes ob-press { + 0% { + transform: scale(1); + } + 2% { + transform: scale(0.9); + } + 6%, + 100% { + transform: scale(1); + } +} +@keyframes ob-loop-spark { + 0% { + opacity: 0; + transform: scale(0.2); + } + 3% { + opacity: 1; + transform: scale(1.2); + } + 5% { + transform: scale(1); + } + 8% { + opacity: 1; + } + 10.5%, + 100% { + opacity: 0; + } +} + +@keyframes ob-pv-progress { + from { + transform: scaleX(0.04); + } + to { + transform: scaleX(1); + } +} +@keyframes ob-pv-cursor { + 0% { + opacity: 0; + transform: translate(70px, 150px); + } + 6% { + opacity: 1; + transform: translate(76px, 140px); + } + 24%, + 84% { + opacity: 1; + transform: translate(198px, 104px); + } + 92%, + 100% { + opacity: 0; + transform: translate(240px, 146px); + } +} +@keyframes ob-pv-circle { + 0%, + 26% { + opacity: 1; + stroke-dashoffset: 1; + } + 36%, + 84% { + opacity: 1; + stroke-dashoffset: 0; + } + 92%, + 100% { + opacity: 0; + stroke-dashoffset: 0; + } +} +@keyframes ob-pv-cam { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-1.5px); + } +} +@keyframes ob-loom-cursor { + 0% { + opacity: 0; + transform: translate(96px, 236px); + } + 4% { + opacity: 1; + } + 12%, + 18% { + transform: translate(60px, 156px); + } + 15% { + transform: translate(60px, 156px) scale(0.86); + } + 40%, + 46% { + transform: translate(262px, 71px); + } + 43% { + transform: translate(262px, 71px) scale(0.86); + } + 56%, + 66% { + opacity: 1; + transform: translate(306px, 100px); + } + 61% { + transform: translate(306px, 100px) scale(0.86); + } + 76% { + opacity: 0; + transform: translate(334px, 196px); + } + 100% { + opacity: 0; + transform: translate(96px, 236px); + } +} +@keyframes ob-loom-copy { + 0%, + 15% { + opacity: 1; + } + 16%, + 96% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +@keyframes ob-loom-copied { + 0%, + 15% { + opacity: 0; + } + 16%, + 96% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +@keyframes ob-loom-placeholder { + 0%, + 44% { + opacity: 1; + } + 45%, + 96% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +@keyframes ob-loom-typed { + 0%, + 44% { + transform: scaleX(1); + animation-timing-function: steps(16, end); + } + 54%, + 96% { + transform: scaleX(0); + } + 100% { + transform: scaleX(1); + } +} +@keyframes ob-loom-focus { + 0%, + 42% { + opacity: 0; + } + 43%, + 64% { + opacity: 1; + } + 68%, + 100% { + opacity: 0; + } +} +@keyframes ob-loom-fly { + 0%, + 65% { + opacity: 0; + transform: translate(92px, 97px) scale(0.7); + } + 67% { + opacity: 1; + transform: translate(92px, 97px) scale(1); + } + 77% { + transform: translate(196px, 6px) scale(0.92) rotate(-6deg); + } + 86% { + opacity: 1; + transform: translate(316px, 152px) scale(1); + } + 88%, + 100% { + opacity: 0; + transform: translate(316px, 152px) scale(1); + } +} +@keyframes ob-loom-slot { + 0%, + 85% { + opacity: 0; + } + 86%, + 96% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +@keyframes ob-loom-check { + 0%, + 86% { + stroke-dashoffset: 1; + } + 91%, + 96% { + stroke-dashoffset: 0; + } + 100% { + stroke-dashoffset: 1; + } +} + +@keyframes ob-rec-cursor { + 0% { + opacity: 0; + transform: translate(320px, 236px); + } + 6% { + opacity: 1; + } + 14%, + 20% { + transform: translate(328px, 45px); + } + 17% { + transform: translate(328px, 45px) scale(0.86); + } + 24%, + 27% { + transform: translate(328px, 81px); + } + 25.5% { + transform: translate(328px, 81px) scale(0.86); + } + 30%, + 32% { + transform: translate(328px, 117px); + } + 31% { + transform: translate(328px, 117px) scale(0.86); + } + 36%, + 40% { + transform: translate(293px, 161px); + } + 38% { + transform: translate(293px, 161px) scale(0.86); + } + 46%, + 62% { + opacity: 1; + transform: translate(338px, 206px); + } + 68%, + 72% { + transform: translate(293px, 161px); + } + 70% { + transform: translate(293px, 161px) scale(0.86); + } + 82%, + 88% { + opacity: 1; + transform: translate(170px, 101px); + } + 86% { + transform: translate(170px, 101px) scale(0.86); + } + 94% { + opacity: 0; + transform: translate(196px, 150px); + } + 100% { + opacity: 0; + transform: translate(320px, 236px); + } +} +@keyframes ob-rec-switch-1 { + 0% { + fill: var(--ob-track-strong); + } + 17% { + fill: var(--ob-accent); + } + 97% { + fill: var(--ob-track-strong); + } +} +@keyframes ob-rec-knob-1 { + 0%, + 17% { + transform: translateX(0); + } + 20%, + 96% { + transform: translateX(9px); + } + 99%, + 100% { + transform: translateX(0); + } +} +@keyframes ob-rec-switch-2 { + 0% { + fill: var(--ob-track-strong); + } + 25.5% { + fill: var(--ob-accent); + } + 97% { + fill: var(--ob-track-strong); + } +} +@keyframes ob-rec-knob-2 { + 0%, + 25.5% { + transform: translateX(0); + } + 28.5%, + 96% { + transform: translateX(9px); + } + 99%, + 100% { + transform: translateX(0); + } +} +@keyframes ob-rec-switch-3 { + 0% { + fill: var(--ob-track-strong); + } + 31% { + fill: var(--ob-accent); + } + 97% { + fill: var(--ob-track-strong); + } +} +@keyframes ob-rec-knob-3 { + 0%, + 31% { + transform: translateX(0); + } + 34%, + 96% { + transform: translateX(9px); + } + 99%, + 100% { + transform: translateX(0); + } +} +@keyframes ob-rec-start { + 0%, + 38% { + opacity: 1; + } + 39%, + 70% { + opacity: 0; + } + 71%, + 100% { + opacity: 1; + } +} +@keyframes ob-rec-stop { + 0%, + 38% { + opacity: 0; + } + 39%, + 70% { + opacity: 1; + } + 71%, + 100% { + opacity: 0; + } +} +@keyframes ob-rec-live { + 0%, + 38% { + opacity: 0; + } + 40%, + 70% { + opacity: 1; + } + 72%, + 100% { + opacity: 0; + } +} +@keyframes ob-rec-time { + 0% { + opacity: 1; + } + 8% { + opacity: 0; + } +} +@keyframes ob-rec-time-last { + 0% { + opacity: 1; + } + 15% { + opacity: 0; + } +} +@keyframes ob-rec-cam { + 0%, + 38% { + opacity: 0; + transform: scale(0.4); + } + 44%, + 96% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.4); + } +} +@keyframes ob-rec-bar { + 0%, + 100% { + transform: scaleY(0.35); + } + 50% { + transform: scaleY(1); + } +} +@keyframes ob-rec-link { + 0%, + 71% { + opacity: 0; + transform: translateY(10px) scale(0.92); + } + 76%, + 96% { + opacity: 1; + transform: none; + } + 100% { + opacity: 0; + transform: translateY(10px) scale(0.92); + } +} + +@keyframes ob-up-cursor { + 0% { + opacity: 0; + transform: translate(40px, 236px); + } + 5% { + opacity: 1; + transform: translate(66px, 108px); + } + 8% { + transform: translate(66px, 108px) scale(0.86); + } + 30% { + transform: translate(252px, 120px) scale(0.86); + } + 33% { + opacity: 1; + transform: translate(252px, 120px); + } + 44%, + 100% { + opacity: 0; + transform: translate(300px, 210px); + } +} +@keyframes ob-up-file { + 0%, + 2% { + opacity: 0; + transform: translate(56px, 112px) scale(0.85); + } + 5%, + 8% { + opacity: 1; + transform: translate(56px, 92px); + } + 30% { + opacity: 1; + transform: translate(242px, 104px) rotate(-5deg); + } + 36% { + opacity: 0; + transform: translate(242px, 112px) scale(0.5); + } + 100% { + opacity: 0; + transform: translate(56px, 112px) scale(0.85); + } +} +@keyframes ob-up-hint { + 0%, + 30% { + opacity: 1; + } + 33%, + 97% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +@keyframes ob-up-hot { + 0%, + 17% { + opacity: 0; + } + 20%, + 34% { + opacity: 1; + } + 38%, + 100% { + opacity: 0; + } +} +@keyframes ob-up-progress-group { + 0%, + 34% { + opacity: 0; + } + 36%, + 66% { + opacity: 1; + } + 69%, + 100% { + opacity: 0; + } +} +@keyframes ob-up-progress { + 0%, + 36% { + stroke-dashoffset: 100; + } + 62%, + 100% { + stroke-dashoffset: 0; + } +} +@keyframes ob-up-done { + 0%, + 67% { + opacity: 0; + transform: translateY(10px) scale(0.92); + } + 72%, + 96% { + opacity: 1; + transform: none; + } + 100% { + opacity: 0; + transform: translateY(10px) scale(0.92); + } +} + +@keyframes ob-join-cursor { + 0% { + opacity: 0; + transform: translate(160px, 236px); + } + 8% { + opacity: 0; + } + 16% { + opacity: 1; + } + 24%, + 30% { + transform: translate(112px, 88px); + } + 27% { + transform: translate(112px, 88px) scale(0.86); + } + 40% { + opacity: 0; + transform: translate(200px, 170px); + } + 100% { + opacity: 0; + transform: translate(160px, 236px); + } +} +@keyframes ob-join-flap-closed { + 0% { + opacity: 1; + } + 6% { + opacity: 0; + } + 97% { + opacity: 1; + } +} +@keyframes ob-join-flap-open { + 0% { + opacity: 0; + } + 6% { + opacity: 1; + } + 97% { + opacity: 0; + } +} +@keyframes ob-join-card { + 0%, + 6% { + opacity: 0; + transform: translateY(70px); + } + 9% { + opacity: 1; + } + 18%, + 96% { + opacity: 1; + transform: none; + } + 100% { + opacity: 0; + transform: translateY(70px); + } +} +@keyframes ob-join-you { + 0%, + 36% { + opacity: 0; + transform: translateY(-30px) scale(0.6); + } + 42% { + opacity: 1; + transform: translateY(3px) scale(1.06); + } + 46%, + 96% { + opacity: 1; + transform: none; + } + 100% { + opacity: 0; + transform: translateY(-30px) scale(0.6); + } +} +@keyframes ob-join-you-label { + 0%, + 44% { + opacity: 0; + } + 48%, + 96% { + opacity: 1; + } + 100% { + opacity: 0; + } +} +@keyframes ob-join-thumb { + 0%, + 66% { + opacity: 0; + transform: translateY(8px) scale(0.9); + } + 72%, + 96% { + opacity: 1; + transform: none; + } + 100% { + opacity: 0; + transform: translateY(8px) scale(0.9); + } +} + +@media (prefers-reduced-motion: reduce) { + .ob-boil { + filter: none; + } + .ob-draw, + .ob-rise, + .ob-fade, + .ob-progress-mark.is-current { + animation-duration: 0.01ms; + animation-delay: 0s; + } + .ob-bob, + .ob-pulse, + .ob-march, + .ob-step, + .ob-cursor, + .ob-ripple, + .ob-press, + .ob-loop-spark, + .ob-squiggle-progress.is-waiting, + .ob-doodle-hover, + .ob-choice:hover .ob-doodle-hover, + .ob-wave, + .ob-greeting:hover .ob-wave, + .ob-pv-progress, + .ob-pv-cursor, + .ob-pv-circle, + .ob-pv-cam, + .ob-theme-icon, + .ob-scene * { + animation: none; + } + .cap-ob, + .ob-cta, + .ob-choice, + .ob-choice .ob-choice-arrow, + .ob-cta .ob-cta-arrow { + transition: none; + } +} diff --git a/apps/web/app/(org)/onboarding/page.tsx b/apps/web/app/(org)/onboarding/page.tsx index da18f52f120..84d37bb2fc1 100644 --- a/apps/web/app/(org)/onboarding/page.tsx +++ b/apps/web/app/(org)/onboarding/page.tsx @@ -1,5 +1,19 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import { cookies } from "next/headers"; import { redirect } from "next/navigation"; +import { + hasOnboardingName, + ONBOARDING_NEXT_COOKIE, + onboardingHrefForIntent, + onboardingIntentFromNextPath, + onboardingStepHref, +} from "./onboarding-flow"; -export default function Onboarding() { - return redirect("/onboarding/welcome"); +export default async function Onboarding() { + const user = await getCurrentUser(); + if (!user) redirect("/login"); + if (!hasOnboardingName(user)) redirect(onboardingStepHref("welcome")); + + const nextPath = (await cookies()).get(ONBOARDING_NEXT_COOKIE)?.value; + redirect(onboardingHrefForIntent(onboardingIntentFromNextPath(nextPath))); } diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index 397662e77a2..a75bb863292 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -6,12 +6,14 @@ import { stripe, userIsPro } from "@cap/utils"; import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import type Stripe from "stripe"; +import { resolveCheckoutReturnUrls } from "@/lib/checkout-return"; import { trackServerEvent } from "@/lib/server-analytics"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); let customerId = user?.stripeCustomerId; - const { priceId, quantity, isOnBoarding } = await request.json(); + const { priceId, quantity, isOnBoarding, returnPath } = await request.json(); + const returnUrls = resolveCheckoutReturnUrls(serverEnv().WEB_URL, returnPath); if (!priceId) { console.error("Price ID not found"); @@ -67,12 +69,16 @@ export async function POST(request: NextRequest) { customer: customerId as string, line_items: [{ price: priceId, quantity: quantity }], mode: "subscription", - success_url: isOnBoarding - ? `${serverEnv().WEB_URL}/dashboard/settings/organization?upgrade=true&session_id={CHECKOUT_SESSION_ID}` - : `${serverEnv().WEB_URL}/dashboard/caps?upgrade=true&session_id={CHECKOUT_SESSION_ID}`, - cancel_url: isOnBoarding - ? `${serverEnv().WEB_URL}/onboarding` - : `${serverEnv().WEB_URL}/pricing`, + success_url: + returnUrls?.successUrl ?? + (isOnBoarding + ? `${serverEnv().WEB_URL}/dashboard/settings/organization?upgrade=true&session_id={CHECKOUT_SESSION_ID}` + : `${serverEnv().WEB_URL}/dashboard/caps?upgrade=true&session_id={CHECKOUT_SESSION_ID}`), + cancel_url: + returnUrls?.cancelUrl ?? + (isOnBoarding + ? `${serverEnv().WEB_URL}/onboarding` + : `${serverEnv().WEB_URL}/pricing`), allow_promotion_codes: true, metadata: { platform: "web", diff --git a/apps/web/components/UpgradeModal.tsx b/apps/web/components/UpgradeModal.tsx index ee2bd980190..10a54a6a9a6 100644 --- a/apps/web/components/UpgradeModal.tsx +++ b/apps/web/components/UpgradeModal.tsx @@ -37,6 +37,7 @@ interface UpgradeModalProps { onOpenChange: (open: boolean) => void; onCheckout?: () => Promise; dismissible?: boolean; + returnPath?: string; } const modalVariants = { @@ -151,6 +152,7 @@ const UpgradeModalImpl = ({ onCheckout, onboarding, dismissible = true, + returnPath, }: UpgradeModalProps) => { const stripeCtx = useStripeContext(); const { currency } = useCurrency(); @@ -176,6 +178,7 @@ const UpgradeModalImpl = ({ priceId: planId, quantity: proQuantity, isOnBoarding: onboarding, + returnPath, }), }); const data = await response.json(); diff --git a/apps/web/lib/checkout-return.ts b/apps/web/lib/checkout-return.ts new file mode 100644 index 00000000000..bcd25662c3b --- /dev/null +++ b/apps/web/lib/checkout-return.ts @@ -0,0 +1,22 @@ +import { getSafeNextPath } from "@/app/(org)/safe-next"; + +const CHECKOUT_SESSION_PLACEHOLDER = "{CHECKOUT_SESSION_ID}"; + +export const resolveCheckoutReturnUrls = ( + webUrl: string, + returnPath: unknown, +) => { + if (typeof returnPath !== "string" || returnPath.length === 0) return null; + + const base = webUrl.replace(/\/+$/, ""); + const path = getSafeNextPath(returnPath, base); + const hashIndex = path.indexOf("#"); + const pathWithoutHash = hashIndex === -1 ? path : path.slice(0, hashIndex); + const hash = hashIndex === -1 ? "" : path.slice(hashIndex); + const separator = pathWithoutHash.includes("?") ? "&" : "?"; + + return { + successUrl: `${base}${pathWithoutHash}${separator}upgrade=true&session_id=${CHECKOUT_SESSION_PLACEHOLDER}${hash}`, + cancelUrl: `${base}${path}`, + }; +}; diff --git a/packages/database/auth/drizzle-adapter.ts b/packages/database/auth/drizzle-adapter.ts index 810b6b75e34..1ab571610f5 100644 --- a/packages/database/auth/drizzle-adapter.ts +++ b/packages/database/auth/drizzle-adapter.ts @@ -143,6 +143,7 @@ export function DrizzleAdapter( activeOrganizationId: Organisation.OrganisationId.make(""), marketingOrigin: pendingInvite || ssoIdentity ? "teammate" : "independent", + onboardingSteps: ssoIdentity ? null : { getStarted: false }, }); if (ssoIdentity) { await insertUser.onDuplicateKeyUpdate({ diff --git a/packages/database/schema.ts b/packages/database/schema.ts index 8c19b1fd20f..33087732949 100644 --- a/packages/database/schema.ts +++ b/packages/database/schema.ts @@ -126,6 +126,7 @@ export const users = mysqlTable( customDomain?: boolean; inviteTeam?: boolean; download?: boolean; + getStarted?: boolean; }>(), onboarding_completed_at: timestamp("onboarding_completed_at"), customBucket: nanoIdNullable("customBucket"), diff --git a/packages/web-backend/src/Users/UsersOnboarding.ts b/packages/web-backend/src/Users/UsersOnboarding.ts index 9e0402ab325..6e4341745c6 100644 --- a/packages/web-backend/src/Users/UsersOnboarding.ts +++ b/packages/web-backend/src/Users/UsersOnboarding.ts @@ -1,6 +1,6 @@ import { nanoId } from "@cap/database/helpers"; import * as Db from "@cap/database/schema"; -import { CurrentUser, Organisation } from "@cap/web-domain"; +import { CurrentUser, Organisation, type User } from "@cap/web-domain"; import * as Dz from "drizzle-orm"; import { Effect, Option } from "effect"; @@ -269,6 +269,91 @@ export class UsersOnboarding extends Effect.Service()( .where(Dz.eq(Db.users.id, currentUser.id)), ); }), + getStarted: Effect.fn("Onboarding.getStarted")(function* (data: { + path: User.OnboardingStartPath; + }) { + const currentUser = yield* CurrentUser; + yield* Effect.annotateCurrentSpan("onboarding.path", data.path); + + yield* db.use((db) => + db.transaction(async (tx) => { + const [user] = await tx + .select() + .from(Db.users) + .where(Dz.eq(Db.users.id, currentUser.id)) + .for("update"); + if (!user) return; + + const firstName = user.name?.trim() ?? ""; + const preferredOrganizationId = + user.activeOrganizationId || user.defaultOrgId; + const [existingOrg] = preferredOrganizationId + ? await tx + .select({ + id: Db.organizations.id, + tombstoneAt: Db.organizations.tombstoneAt, + }) + .from(Db.organizations) + .where(Dz.eq(Db.organizations.id, preferredOrganizationId)) + : []; + + let createdOrganizationId: Organisation.OrganisationId | null = + null; + if (!existingOrg || existingOrg.tombstoneAt) { + createdOrganizationId = Organisation.OrganisationId.make( + nanoId(), + ); + await tx.insert(Db.organizations).values({ + id: createdOrganizationId, + ownerId: currentUser.id, + name: firstName + ? `${firstName}'s Organization` + : "My Organization", + }); + await tx.insert(Db.organizationMembers).values({ + id: nanoId(), + organizationId: createdOrganizationId, + userId: currentUser.id, + role: "owner", + }); + } else if (firstName.length > 0) { + await tx + .update(Db.organizations) + .set({ name: `${firstName}'s Organization` }) + .where( + Dz.and( + Dz.eq(Db.organizations.id, existingOrg.id), + Dz.eq(Db.organizations.ownerId, currentUser.id), + Dz.isNull(Db.organizations.tombstoneAt), + Dz.eq(Db.organizations.name, "My Organization"), + ), + ); + } + + await tx + .update(Db.users) + .set({ + ...(createdOrganizationId && { + activeOrganizationId: createdOrganizationId, + defaultOrgId: createdOrganizationId, + }), + onboarding_completed_at: + user.onboarding_completed_at ?? new Date(), + onboardingSteps: { + ...user.onboardingSteps, + ...(firstName.length > 0 && { welcome: true }), + organizationSetup: true, + customDomain: true, + inviteTeam: true, + download: true, + getStarted: true, + }, + }) + .where(Dz.eq(Db.users.id, currentUser.id)); + }), + ); + }), + skipToDashboard: Effect.fn("Onboarding.skipToDashboard")(function* () { const currentUser = yield* CurrentUser; @@ -297,6 +382,7 @@ export class UsersOnboarding extends Effect.Service()( customDomain: true, inviteTeam: true, download: true, + getStarted: true, }, }) .where(Dz.eq(Db.users.id, currentUser.id)); diff --git a/packages/web-backend/src/Users/UsersRpcs.ts b/packages/web-backend/src/Users/UsersRpcs.ts index 1e0291d551e..1424ce44aae 100644 --- a/packages/web-backend/src/Users/UsersRpcs.ts +++ b/packages/web-backend/src/Users/UsersRpcs.ts @@ -35,6 +35,9 @@ export const UsersRpcsLive = User.UserRpcs.toLayer( case "skipToDashboard": yield* onboarding.skipToDashboard(); return { step: "skipToDashboard" as const, data: undefined }; + case "getStarted": + yield* onboarding.getStarted(payload.data); + return { step: "getStarted" as const, data: undefined }; } }).pipe( Effect.catchTag("DatabaseError", () => diff --git a/packages/web-domain/src/User.ts b/packages/web-domain/src/User.ts index d11d3a2e72d..26fa6e78c91 100644 --- a/packages/web-domain/src/User.ts +++ b/packages/web-domain/src/User.ts @@ -16,6 +16,14 @@ export const UserUpdate = Schema.Struct({ }); export type UserUpdate = Schema.Schema.Type; +export const OnboardingStartPath = Schema.Literal( + "loom", + "record", + "upload", + "explore", +); +export type OnboardingStartPath = typeof OnboardingStartPath.Type; + export const OnboardingStepPayload = Schema.Union( Schema.Struct({ step: Schema.Literal("welcome"), @@ -49,6 +57,12 @@ export const OnboardingStepPayload = Schema.Union( step: Schema.Literal("skipToDashboard"), data: Schema.Void, }), + Schema.Struct({ + step: Schema.Literal("getStarted"), + data: Schema.Struct({ + path: OnboardingStartPath, + }), + }), ); export const OnboardingStepResult = Schema.Union( @@ -74,6 +88,10 @@ export const OnboardingStepResult = Schema.Union( step: Schema.Literal("skipToDashboard"), data: Schema.Void, }), + Schema.Struct({ + step: Schema.Literal("getStarted"), + data: Schema.Void, + }), ); export class UserRpcs extends RpcGroup.make(