Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions apps/web/src/lab/entries/onboarding-checklist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { V2OnboardingChecklist, V2OnboardingChecklistStep } from "@maple/domain/http/v2"
import { Effect, Schema } from "effect"
import type { Html, HtmlBuilder } from "foldkit/html"
import * as OnboardingChecklist from "../../shell/onboarding-checklist"
import * as OnboardingChecklistView from "../../shell/onboarding-checklist-view"
import { defineLabWithFlags } from "../define"

// `/lab/onboarding-checklist`: the top bar's credits pill with its one-time pointer, then the panel in
// each state, over fixtures. Nothing is claimed.

const HOUR = 3_600_000

const step = (
id: V2OnboardingChecklistStep["id"],
title: string,
completed: boolean,
optional = false,
): V2OnboardingChecklistStep => ({
object: "onboarding_checklist_step",
id,
title,
completed,
href: "/lab/onboarding-checklist",
optional,
})

const checklistOf = (
status: V2OnboardingChecklist["status"],
done: ReadonlyArray<boolean>,
nowMs: number,
): V2OnboardingChecklist => {
const steps = [
step("send_telemetry", "Send your first telemetry", done[0] ?? false),
step("connect_github", "Connect GitHub", done[1] ?? false),
step("create_alert_rule", "Create an alert with a destination", done[2] ?? false),
step("connect_mcp_agent", "Connect an MCP agent", done[3] ?? false),
step("invite_teammate", "Invite a teammate", done[4] ?? false, true),
]
const required = steps.filter((entry) => !entry.optional)
return {
object: "onboarding_checklist",
status,
reward_amount_usd: 30,
deadline_at: new Date(nowMs + 17 * HOUR + 42 * 60_000).toISOString(),
claimed_at: status === "claimed" ? new Date(nowMs).toISOString() : null,
completed_count: required.filter((entry) => entry.completed).length,
total_count: required.length,
steps,
}
}

export const Model = Schema.Struct({ nowMs: Schema.Number })
export type Model = typeof Model.Type

/** The pill's own messages, dropped: the lab only draws its states. */
type Message = OnboardingChecklist.Message

const modelOf = (
nowMs: number,
checklist: V2OnboardingChecklist,
isAdmin = true,
): OnboardingChecklist.Model => ({
...OnboardingChecklist.init(),
checklist,
isAdmin,
isSeen: false,
nowMs,
})

const frame = (h: HtmlBuilder<Message>, label: string, children: ReadonlyArray<Html>): Html =>
h.section(
[h.Class("flex flex-col gap-2")],
[
h.span([h.Class("text-xs text-muted-foreground")], [label]),
h.div(
[
h.Class(
"w-[22rem] overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-popup",
),
],
children,
),
],
)

const panelOf = (h: HtmlBuilder<Message>, model: OnboardingChecklist.Model): ReadonlyArray<Html> =>
model.checklist === null ? [] : OnboardingChecklistView.checklistPanel(h, model, model.checklist)

export const view = (model: Model, h: HtmlBuilder<Message>): Html => {
const inProgress = modelOf(
model.nowMs,
checklistOf("in_progress", [true, true, false, false, false], model.nowMs),
)
const claimable = modelOf(
model.nowMs,
checklistOf("claimable", [true, true, true, true, false], model.nowMs),
)
const claimed = {
...modelOf(model.nowMs, checklistOf("claimed", [true, true, true, true, true], model.nowMs)),
isJustClaimed: true,
}
return h.div(
[h.Class("flex min-h-dvh flex-col gap-10 bg-background p-10 text-foreground")],
[
h.div(
[h.Class("flex justify-end pb-24")],
[
h.submodel({
slotId: "lab-onboarding-checklist-pill",
model: inProgress,
view: OnboardingChecklistView.view,
toParentMessage: (message) => message,
}),
],
),
h.div(
[h.Class("flex flex-wrap items-start gap-8")],
[
frame(h, "In progress", panelOf(h, inProgress)),
frame(h, "Every step done", panelOf(h, claimable)),
frame(h, "Claimed", panelOf(h, claimed)),
],
),
],
)
}

export const lab = defineLabWithFlags("Onboarding checklist", {
Model,
Flags: Model,
flags: Effect.sync(() => ({ nowMs: Date.now() })),
init: (flags) => ({ model: { nowMs: flags.nowMs } }),
update: (model) => ({ model }),
view,
})
114 changes: 93 additions & 21 deletions apps/web/src/lab/entries/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { CatalogPlan, CatalogPlanItem, CatalogPlanItemPrice } from "@maple/domai
import { Effect, Schema } from "effect"
import { Command, Submodel } from "foldkit"
import type { Html, HtmlBuilder } from "foldkit/html"
import * as CreateOrg from "../../page/onboarding/create-org"
import * as CreateOrgView from "../../page/onboarding/create-org-view"
import * as PricingCardsView from "../../page/onboarding/pricing-cards-view"
import * as QuickStartTeamView from "../../page/onboarding/quick-start-team-view"
import * as QuickStartView from "../../page/onboarding/quick-start-view"
Expand All @@ -18,7 +20,8 @@ import { DEFAULT_QUICK_START_STATE, STEP_IDS } from "../../page/onboarding/quick
import { ready, TeamMessage, updateTeam } from "../../page/onboarding/quick-start-team"

// `/lab/onboarding`: the real quick-start screen with a sample catalog; checkout, rename and invites are
// simulated and nothing is saved. `?step=stack` opens a later step, Restart starts over.
// simulated and nothing is saved. `?step=stack` opens a later step (`org` is the create-org screen,
// `region` the region step), Restart starts over.

const meteredItem = (
featureId: string,
Expand Down Expand Up @@ -51,11 +54,19 @@ export const PLANS = [
}),
]

const LAB_STEPS = ["org", "region", ...STEP_IDS] as const
type LabStep = (typeof LAB_STEPS)[number]

export const Model = Schema.Struct({
quickStart: QuickStart.Model,
/** The create-org screen, shown instead of the quick start until its form is sent. */
createOrg: Schema.NullOr(CreateOrg.Model),
needsRegion: Schema.Boolean,
/** Picking a sample plan stands in for checkout. */
hasPlan: Schema.Boolean,
isComplete: Schema.Boolean,
/** The name sent from the create-org screen, shown by the steps after it. */
orgName: Schema.String,
/** Bumped by Restart, so every step remounts. */
run: Schema.Number,
})
Expand All @@ -65,19 +76,40 @@ export const Message = defineMessageUnion({
GotStepMessage: { message: QuickStart.Message },
ClickedRestart: {},
SimulatedTeamOperation: { message: TeamMessage },
GotCreateOrgMessage: { message: CreateOrg.Message },
})
export type Message = typeof Message.Type

const Step = Schema.Literals(STEP_IDS)
const Step = Schema.Literals(LAB_STEPS)

const SAMPLE_ORG: CreateOrg.Model = {
_tag: "OrgRequired",
name: "Acme",
suggestedFrom: "acme.com",
region: "us",
isCreating: false,
errorMessage: null,
invitations: [],
joiningId: null,
openingId: null,
createdOrgId: null,
invitationsError: null,
}

const quickStartStep = (step: LabStep): (typeof STEP_IDS)[number] =>
step === "org" || step === "region" ? "plan" : step

export const init = (step: (typeof STEP_IDS)[number], run = 0): Model => ({
export const init = (step: LabStep, run = 0): Model => ({
quickStart: {
...QuickStart.init("lab-onboarding").model,
state: { ...DEFAULT_QUICK_START_STATE, activeStep: step },
state: { ...DEFAULT_QUICK_START_STATE, activeStep: quickStartStep(step) },
team: ready("Acme", [], true, null),
},
hasPlan: step !== "plan",
createOrg: step === "org" ? SAMPLE_ORG : null,
needsRegion: step === "org" || step === "region",
hasPlan: quickStartStep(step) !== "plan",
isComplete: false,
orgName: SAMPLE_ORG.name,
run,
})

Expand Down Expand Up @@ -138,6 +170,8 @@ const stepMessage = (model: Model, message: QuickStart.Message): Return => {
return message.message._tag === "GotSnippetCopyMessage"
? copyStep(model, message.message.snippet, message.message.message)
: { model: screenStep(model, message) }
case "ClickedRegionContinue":
return { model: { ...model, needsRegion: false } }
case "GotPricingMessage":
return message.message._tag === "ClickedPlan" ? { model: { ...model, hasPlan: true } } : { model }
case "ClickedContinue":
Expand All @@ -151,10 +185,37 @@ const stepMessage = (model: Model, message: QuickStart.Message): Return => {
export const update = (model: Model, message: Message): Return =>
Message.match<Return>(message, {
GotStepMessage: ({ message: child }) => stepMessage(model, child),
ClickedRestart: () => ({ model: init("plan", model.run + 1) }),
ClickedRestart: () => ({ model: init("org", model.run + 1) }),
SimulatedTeamOperation: ({ message: child }) => teamStep(model, child),
GotCreateOrgMessage: ({ message: child }) => createOrgStep(model, child),
})

/** The create-org form's own edits; sending it moves on to the region step, nothing is created. */
const createOrgStep = (model: Model, message: CreateOrg.Message): Return => {
if (model.createOrg === null) return { model }
switch (message._tag) {
case "ChangedName":
case "PickedRegion":
return { model: { ...model, createOrg: CreateOrg.update(model.createOrg, message).model } }
case "SubmittedCreate":
return model.createOrg.name.trim() === ""
? { model }
: {
model: {
...model,
orgName: model.createOrg.name.trim(),
createOrg: null,
quickStart: {
...model.quickStart,
region: { ...model.quickStart.region, selected: model.createOrg.region },
},
},
}
default:
return { model }
}
}

// VIEW

const plansView = Submodel.defineView<null, PricingCards.Message>((_, h) =>
Expand Down Expand Up @@ -188,9 +249,9 @@ const stepView = Submodel.defineView<Model, QuickStart.Message>(
separator(h, "vertical", "h-4"),
linkButton(h, { href: "/lab", size: "xs", variant: "ghost" }, ["Exit preview"]),
]),
needsRegion: false,
needsRegion: model.needsRegion,
hasPlan: model.hasPlan,
orgName: "Acme",
orgName: model.orgName,
teamStep: () => QuickStartTeamView.teamStep(h, model.quickStart.team),
planStep: QuickStartTeamView.planStep(
h,
Expand All @@ -205,25 +266,36 @@ const stepView = Submodel.defineView<Model, QuickStart.Message>(
}) ?? h.empty,
)

const createOrgView = Submodel.defineView<CreateOrg.Model, CreateOrg.Message>((model, h) =>
CreateOrgView.view(h, model, "you@acme.com", []),
)

export const view = (model: Model, h: HtmlBuilder<Message>): Html =>
model.isComplete
? complete(h)
: h.div(
[h.Key(String(model.run))],
[
h.submodel({
slotId: "lab-onboarding-step",
model,
view: stepView,
toParentMessage: (message) => Message.GotStepMessage({ message }),
}),
],
)
: model.createOrg !== null
? h.submodel({
slotId: "lab-onboarding-org",
model: model.createOrg,
view: createOrgView,
toParentMessage: (message) => Message.GotCreateOrgMessage({ message }),
})
: h.div(
[h.Key(String(model.run))],
[
h.submodel({
slotId: "lab-onboarding-step",
model,
view: stepView,
toParentMessage: (message) => Message.GotStepMessage({ message }),
}),
],
)

/** `?step=stack` opens the preview on a later step. */
const initialStep = (search: string): (typeof STEP_IDS)[number] =>
const initialStep = (search: string): LabStep =>
Schema.decodeUnknownOption(Step)(new URLSearchParams(search).get("step")).pipe((step) =>
step._tag === "Some" ? step.value : "plan",
step._tag === "Some" ? step.value : "org",
)

export const lab = defineLabWithFlags("Onboarding", {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
kind: "lab",
session: "none",
},
{
path: "/lab/onboarding-checklist",
title: "Onboarding checklist",
description:
"The top bar's credits pill and its checklist panel in progress, claimable and claimed, over fixtures.",
kind: "lab",
session: "none",
},
{
path: "/lab/loaders",
title: "Chat loaders",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/lib/onboarding-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ export const STACK_PLATFORMS = {
aws: { label: "AWS", description: "CloudWatch", icon: "AwsIcon", fills: ["infrastructure", "metrics"] },
gcp: {
label: "Google Cloud",
description: "Logging, monitoring",
description: "Logs, metrics",
icon: "GoogleCloudIcon",
fills: ["logs", "metrics"],
},
Expand All @@ -196,7 +196,7 @@ export const STACK_PLATFORMS = {
},
prometheus: {
label: "Prometheus",
description: "Scrape an endpoint",
description: "Scrape targets",
icon: "PrometheusIcon",
fills: ["metrics"],
},
Expand Down
Loading
Loading