Blocks

Onboarding Workspace

Application UI

A resumable, API-backed activation section that turns a signup into a configured workspace.

Activation

Workspace activation flow

A responsive Notion- and Linear-inspired setup workspace with revisioned saves, rich DOM Studio selects, source connection, scoped invitations, server-owned readiness, immutable launch proof, and a useful post-launch handoff.

1200px

vue
<script setup>
import { computed, onMounted, ref } from 'vue';
import {
	DomAlert,
	DomAvatar,
	DomBadge,
	DomButton,
	DomCheckbox,
	DomEmailInput,
	DomProgress,
	DomSelect,
	DomSkeleton,
	DomStatusPill,
	DomTextInput,
} from '@getdom/studio/vue';

const loading = ref(true);
const busy = ref(false);
const bootstrap = ref(null);
const session = ref(null);
const activeStep = ref('connect');
const errorMessage = ref('');
const successMessage = ref('');
const fieldErrors = ref({});
const workspaceOpened = ref(false);
const workspaceForm = ref({ name: '', slug: '', teamSize: '', region: '' });
const personalizationForm = ref({ role: '', goals: [] });
const selectedSourceId = ref('linear');
const invitationForm = ref({ email: '', role: 'member' });
const termsAccepted = ref(false);

const activeStepRecord = computed(getActiveStepRecord);
const activeStepIndex = computed(getActiveStepIndex);
const isLaunched = computed(getIsLaunched);
const selectedSource = computed(getSelectedSource);
const canLaunch = computed(getCanLaunch);

/**
 * Resolves the currently selected server-owned setup step.
 *
 * @returns {Record<string, unknown>|null} Active step record.
 */
function getActiveStepRecord() {
	return session.value?.readiness?.steps?.find((step) => step.id === activeStep.value) || null;
}

/**
 * Resolves the active step's zero-based position.
 *
 * @returns {number} Active step index.
 */
function getActiveStepIndex() {
	return Math.max(0, session.value?.readiness?.steps?.findIndex((step) => step.id === activeStep.value) ?? 0);
}

/**
 * Resolves whether the server has activated this workspace.
 *
 * @returns {boolean} Whether launch proof exists.
 */
function getIsLaunched() {
	return session.value?.status === 'active' && Boolean(session.value?.launch);
}

/**
 * Resolves the selected connection source option.
 *
 * @returns {Record<string, unknown>|null} Selected source option.
 */
function getSelectedSource() {
	return bootstrap.value?.options?.sources?.find((source) => source.value === selectedSourceId.value) || null;
}

/**
 * Resolves whether readiness and acknowledgement allow activation.
 *
 * @returns {boolean} Whether the launch action is available.
 */
function getCanLaunch() {
	return Boolean(session.value?.readiness?.readyToLaunch && termsAccepted.value && !busy.value);
}

/**
 * Loads the complete setup contract and resumes the first incomplete step.
 *
 * @returns {Promise<void>}
 */
async function loadBootstrap() {
	loading.value = true;
	clearFeedback();
	try {
		const payload = await requestJson('/api/block-demos/onboarding/bootstrap');
		bootstrap.value = payload;
		applySession(payload.session, { preserveStep: false });
	} catch (error) {
		errorMessage.value = error.message || 'The onboarding workspace could not be loaded.';
	} finally {
		loading.value = false;
	}
}

/**
 * Applies server state and synchronizes editable form values.
 *
 * @param {Record<string, unknown>} nextSession Updated session payload.
 * @param {{ preserveStep?: boolean }} options Application options.
 * @returns {void}
 */
function applySession(nextSession, options = {}) {
	session.value = nextSession;
	workspaceForm.value = { ...nextSession.workspace };
	personalizationForm.value = {
		role: nextSession.personalization?.role || '',
		goals: [...(nextSession.personalization?.goals || [])],
	};
	if (nextSession.connection?.sourceId) selectedSourceId.value = nextSession.connection.sourceId;
	if (!options.preserveStep) activeStep.value = nextSession.nextStep || 'workspace';
}

/**
 * Selects a setup step without resetting in-progress fields.
 *
 * @param {string} stepId Step identifier.
 * @returns {void}
 */
function selectStep(stepId) {
	activeStep.value = stepId;
	workspaceOpened.value = false;
	clearFeedback();
}

/**
 * Saves workspace identity through the onboarding API.
 *
 * @returns {Promise<void>}
 */
async function saveWorkspace() {
	if (!session.value || busy.value) return;
	busy.value = true;
	clearFeedback();
	try {
		const payload = await requestJson('/api/block-demos/onboarding/workspace', {
			method: 'PATCH',
			body: { revision: session.value.revision, ...workspaceForm.value },
		});
		applySession(payload.session, { preserveStep: true });
		successMessage.value = payload.message;
		activeStep.value = 'personalize';
	} catch (error) {
		applyRequestError(error, 'Workspace details could not be saved.');
	} finally {
		busy.value = false;
	}
}

/**
 * Toggles one personalization goal while enforcing the three-goal limit.
 *
 * @param {string} goal Goal identifier.
 * @param {boolean} checked Desired selected state.
 * @returns {void}
 */
function toggleGoal(goal, checked) {
	const current = personalizationForm.value.goals;
	if (!checked) {
		personalizationForm.value.goals = current.filter((item) => item !== goal);
		return;
	}
	if (current.includes(goal)) return;
	if (current.length >= 3) {
		errorMessage.value = 'Choose no more than three outcomes so the first workspace stays focused.';
		return;
	}
	personalizationForm.value.goals = [...current, goal];
	clearFeedback({ preserveError: true });
}

/**
 * Saves role and outcome choices through the onboarding API.
 *
 * @returns {Promise<void>}
 */
async function savePersonalization() {
	if (!session.value || busy.value) return;
	busy.value = true;
	clearFeedback();
	try {
		const payload = await requestJson('/api/block-demos/onboarding/personalization', {
			method: 'PATCH',
			body: { revision: session.value.revision, ...personalizationForm.value },
		});
		applySession(payload.session, { preserveStep: true });
		successMessage.value = payload.message;
		activeStep.value = 'connect';
	} catch (error) {
		applyRequestError(error, 'Personalization could not be saved.');
	} finally {
		busy.value = false;
	}
}

/**
 * Connects the selected source and persists import proof.
 *
 * @param {boolean} simulateRestricted Whether to exercise the recoverable Jira permission path.
 * @returns {Promise<void>}
 */
async function connectSource(simulateRestricted = false) {
	if (!session.value || busy.value) return;
	busy.value = true;
	clearFeedback();
	try {
		const payload = await requestJson('/api/block-demos/onboarding/connections', {
			method: 'POST',
			body: {
				revision: session.value.revision,
				sourceId: selectedSourceId.value,
				simulateRestricted,
			},
		});
		applySession(payload.session, { preserveStep: true });
		successMessage.value = payload.message;
		activeStep.value = 'launch';
	} catch (error) {
		applyRequestError(error, 'The source could not be connected.');
	} finally {
		busy.value = false;
	}
}

/**
 * Adds a teammate invitation with a selected DOM Studio role.
 *
 * @returns {Promise<void>}
 */
async function addInvitation() {
	if (!session.value || busy.value) return;
	busy.value = true;
	clearFeedback();
	try {
		const payload = await requestJson('/api/block-demos/onboarding/invitations', {
			method: 'POST',
			body: { revision: session.value.revision, ...invitationForm.value },
		});
		applySession(payload.session, { preserveStep: true });
		invitationForm.value.email = '';
		successMessage.value = payload.message;
	} catch (error) {
		applyRequestError(error, 'The invitation could not be prepared.');
	} finally {
		busy.value = false;
	}
}

/**
 * Removes one pending invitation through the onboarding API.
 *
 * @param {string} invitationId Invitation identifier.
 * @returns {Promise<void>}
 */
async function removeInvitation(invitationId) {
	if (!session.value || busy.value) return;
	busy.value = true;
	clearFeedback();
	try {
		const payload = await requestJson(`/api/block-demos/onboarding/invitations/${encodeURIComponent(invitationId)}`, {
			method: 'DELETE',
			body: { revision: session.value.revision },
		});
		applySession(payload.session, { preserveStep: true });
		successMessage.value = payload.message;
	} catch (error) {
		applyRequestError(error, 'The invitation could not be removed.');
	} finally {
		busy.value = false;
	}
}

/**
 * Activates a ready workspace and stores immutable launch proof.
 *
 * @returns {Promise<void>}
 */
async function launchWorkspace() {
	if (!session.value || busy.value) return;
	busy.value = true;
	clearFeedback();
	try {
		const payload = await requestJson('/api/block-demos/onboarding/launch', {
			method: 'POST',
			body: { revision: session.value.revision, termsAccepted: termsAccepted.value },
		});
		applySession(payload.session, { preserveStep: true });
		successMessage.value = payload.message;
		workspaceOpened.value = false;
	} catch (error) {
		applyRequestError(error, 'The workspace could not be launched.');
	} finally {
		busy.value = false;
	}
}

/**
 * Shows the activated workspace handoff without navigating away from the block.
 *
 * @returns {void}
 */
function openWorkspace() {
	workspaceOpened.value = true;
	clearFeedback();
}

/**
 * Applies a structured API error and refreshes stale session state when present.
 *
 * @param {Error & { fields?: Array<Record<string, string>>, payload?: Record<string, unknown> }} error Request error.
 * @param {string} fallback Fallback error message.
 * @returns {void}
 */
function applyRequestError(error, fallback) {
	errorMessage.value = error.message || fallback;
	fieldErrors.value = Object.fromEntries((error.fields || []).map((field) => [field.field, field.message]));
	if (error.payload?.session) applySession(error.payload.session, { preserveStep: true });
}

/**
 * Clears transient success, error, and field feedback.
 *
 * @param {{ preserveError?: boolean }} options Clearing options.
 * @returns {void}
 */
function clearFeedback(options = {}) {
	if (!options.preserveError) errorMessage.value = '';
	successMessage.value = '';
	fieldErrors.value = {};
}

/**
 * Resolves semantic status styling for a setup step.
 *
 * @param {Record<string, unknown>} step Step record.
 * @returns {string} DOM Studio status tone.
 */
function stepTone(step) {
	if (session.value?.status === 'active') return 'success';
	if (step.status === 'complete') return 'success';
	if (step.id === activeStep.value) return 'primary';
	return 'neutral';
}

/**
 * Resolves the display label for a selected option.
 *
 * @param {Array<Record<string, unknown>>} options Available options.
 * @param {string} value Selected value.
 * @returns {string} Selected label.
 */
function optionLabel(options, value) {
	return options?.find((option) => option.value === value)?.label || value || 'Not set';
}

/**
 * Formats an ISO timestamp as a concise local time.
 *
 * @param {string} value ISO timestamp.
 * @returns {string} Local time label.
 */
function formatTime(value) {
	if (!value) return 'Not yet';
	return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(new Date(value));
}

/**
 * Sends JSON to the demo API and promotes structured errors into exceptions.
 *
 * @param {string} url API route.
 * @param {{ method?: string, body?: Record<string, unknown> }} options Request options.
 * @returns {Promise<Record<string, unknown>>} Parsed response payload.
 */
async function requestJson(url, options = {}) {
	const response = await fetch(url, {
		method: options.method || 'GET',
		headers: options.body ? { 'Content-Type': 'application/json' } : undefined,
		body: options.body ? JSON.stringify(options.body) : undefined,
	});
	const payload = await response.json();
	if (!response.ok || payload.error) {
		const error = new Error(payload.error?.message || `Request failed with ${response.status}.`);
		error.fields = payload.error?.fields || [];
		error.payload = payload.error || payload;
		throw error;
	}
	return payload;
}

onMounted(loadBootstrap);
</script>

<template>
	<div class="h-dvh min-h-[42rem] overflow-hidden bg-canvas text-canvas-fg">
		<div v-if="loading" class="grid h-full lg:grid-cols-[17rem_minmax(0,1fr)]">
			<aside class="hidden border-r border-border p-6 lg:block">
				<DomSkeleton variant="text" :lines="5" />
			</aside>
			<main class="p-6 sm:p-10">
				<div class="mx-auto max-w-3xl space-y-6">
					<DomSkeleton variant="text" :lines="3" />
					<DomSkeleton height="20rem" />
				</div>
			</main>
		</div>

		<div v-else-if="session && bootstrap" class="grid h-full lg:grid-cols-[17rem_minmax(0,1fr)]">
			<aside class="hidden min-h-0 flex-col border-r border-border bg-secondary/25 lg:flex">
				<div class="flex items-center gap-3 px-6 py-6">
					<DomAvatar :name="bootstrap.product.name" :initials="bootstrap.product.mark" shape="rounded" />
					<div class="min-w-0">
						<p class="truncate text-sm font-semibold">{{ bootstrap.product.name }}</p>
						<p class="text-xs text-muted-fg">Workspace setup</p>
					</div>
				</div>

				<div class="px-6 pb-5">
					<div class="flex items-center justify-between gap-3 text-xs">
						<span class="font-medium">{{ session.readiness.completeCount }} of {{ session.readiness.steps.length }} complete</span>
						<span class="text-muted-fg">{{ session.readiness.percent }}%</span>
					</div>
					<DomProgress class="mt-3" :value="session.readiness.percent" label="Setup progress" size="sm" :show-label="false" />
				</div>

				<nav class="min-h-0 flex-1 overflow-y-auto px-3" aria-label="Setup steps">
					<button
						v-for="(step, index) in session.readiness.steps"
						:key="step.id"
						type="button"
						class="mb-1 flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition hover:bg-secondary"
						:class="step.id === activeStep ? 'bg-canvas shadow-sm ring-1 ring-border' : ''"
						@click="selectStep(step.id)"
					>
						<span class="grid size-7 shrink-0 place-items-center rounded-full border border-border bg-canvas text-xs font-semibold">{{ index + 1 }}</span>
						<span class="min-w-0 flex-1">
							<span class="flex items-center justify-between gap-2">
								<span class="text-sm font-semibold">{{ step.label }}</span>
								<span v-if="step.status === 'complete'" class="text-[10px] font-semibold uppercase tracking-wider text-success">Done</span>
							</span>
							<span class="mt-1 block text-xs leading-5 text-muted-fg">{{ step.description }}</span>
						</span>
					</button>
				</nav>

				<div class="border-t border-border px-5 py-4">
					<div class="flex items-center gap-3">
						<DomAvatar :name="bootstrap.viewer.name" :initials="bootstrap.viewer.initials" size="sm" />
						<div class="min-w-0 flex-1">
							<p class="truncate text-sm font-medium">{{ bootstrap.viewer.name }}</p>
							<p class="truncate text-xs text-muted-fg">{{ bootstrap.viewer.email }}</p>
						</div>
						<DomStatusPill :tone="isLaunched ? 'success' : 'primary'" size="sm">{{ isLaunched ? 'Live' : 'Draft' }}</DomStatusPill>
					</div>
				</div>
			</aside>

			<section class="flex min-h-0 min-w-0 flex-col">
				<header class="border-b border-border bg-canvas/95 px-5 py-4 backdrop-blur lg:hidden">
					<div class="flex items-center justify-between gap-4">
						<div class="flex min-w-0 items-center gap-3">
							<DomAvatar :name="bootstrap.product.name" :initials="bootstrap.product.mark" shape="rounded" size="sm" />
							<div class="min-w-0">
								<p class="truncate text-sm font-semibold">{{ bootstrap.product.name }}</p>
								<p class="text-xs text-muted-fg">Step {{ activeStepIndex + 1 }} of {{ session.readiness.steps.length }}</p>
							</div>
						</div>
						<DomStatusPill :tone="isLaunched ? 'success' : 'primary'" size="sm">{{ isLaunched ? 'Live' : `${session.readiness.percent}%` }}</DomStatusPill>
					</div>
					<DomProgress class="mt-3" :value="session.readiness.percent" label="Setup progress" size="sm" :show-label="false" />
					<nav class="mt-3 flex gap-1 overflow-x-auto" aria-label="Setup steps">
						<button
							v-for="(step, index) in session.readiness.steps"
							:key="step.id"
							type="button"
							class="shrink-0 rounded-full px-3 py-1.5 text-xs font-medium transition"
							:class="step.id === activeStep ? 'bg-primary text-primary-fg' : 'bg-secondary text-secondary-fg'"
							@click="selectStep(step.id)"
						>
							{{ index + 1 }}. {{ step.label }}
						</button>
					</nav>
				</header>

				<main class="min-h-0 flex-1 overflow-y-auto">
					<div v-if="workspaceOpened && isLaunched" class="mx-auto flex min-h-full max-w-4xl flex-col justify-center px-5 py-10 sm:px-8 lg:px-12">
						<div class="max-w-2xl">
							<DomBadge tone="success" variant="soft">Workspace handoff</DomBadge>
							<h2 class="mt-5 text-3xl font-semibold tracking-tight sm:text-4xl">Welcome to {{ session.workspace.name }}</h2>
							<p class="mt-4 max-w-xl text-base leading-7 text-muted-fg">Your imported work, invited team, and personalized starting views are ready. The first useful action is already waiting.</p>
							<div class="mt-8 border-y border-border py-6">
								<p class="text-xs font-semibold uppercase tracking-[0.14em] text-muted-fg">Recommended first project</p>
								<h3 class="mt-2 text-xl font-semibold">Q3 activation roadmap</h3>
								<p class="mt-2 text-sm leading-6 text-muted-fg">A starter project shaped around your selected outcomes and {{ session.connection.importedItems }} imported items.</p>
							</div>
							<div class="mt-6 flex flex-wrap gap-3">
								<DomButton @click="workspaceOpened = false">Review launch receipt</DomButton>
								<DomButton variant="secondary" @click="selectStep('personalize')">Adjust personalization</DomButton>
							</div>
						</div>
					</div>

					<div v-else class="mx-auto w-full max-w-4xl px-5 py-7 sm:px-8 sm:py-10 lg:px-12 lg:py-12">
						<div class="flex flex-col gap-4 border-b border-border pb-6 sm:flex-row sm:items-start sm:justify-between">
							<div class="max-w-2xl">
								<p class="text-xs font-semibold uppercase tracking-[0.16em] text-muted-fg">{{ isLaunched ? 'Setup complete' : 'Workspace activation' }}</p>
								<h1 class="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{{ isLaunched ? `${session.workspace.name} is ready` : activeStepRecord?.label }}</h1>
								<p class="mt-3 text-sm leading-6 text-muted-fg">
									{{ isLaunched ? 'Your setup is stored, teammates are queued, and the workspace has immutable launch proof.' : activeStepRecord?.description }}
								</p>
							</div>
							<DomStatusPill :tone="isLaunched ? 'success' : stepTone(activeStepRecord)" :pulse="busy">
								{{ busy ? 'Saving' : isLaunched ? 'Active' : activeStepRecord?.status === 'complete' ? 'Saved' : 'Needs attention' }}
							</DomStatusPill>
						</div>

						<div class="mt-6 space-y-4">
							<DomAlert v-if="errorMessage" tone="danger" title="Check this step" :description="errorMessage" dismissible @dismiss="errorMessage = ''" />
							<DomAlert v-if="successMessage" tone="success" title="Saved" :description="successMessage" dismissible @dismiss="successMessage = ''" />
						</div>

						<form v-if="!isLaunched && activeStep === 'workspace'" class="mt-8 max-w-2xl" @submit.prevent="saveWorkspace">
							<div class="grid gap-5 sm:grid-cols-2">
								<DomTextInput v-model="workspaceForm.name" label="Workspace name" placeholder="Northstar Product" :errors="fieldErrors.name || []" required />
								<DomTextInput v-model="workspaceForm.slug" label="Workspace URL" description="northstar.app/workspaces/your-url" placeholder="northstar-product" :errors="fieldErrors.slug || []" required />
								<DomSelect v-model="workspaceForm.teamSize" label="Team size" :options="bootstrap.options.teamSizes" :errors="fieldErrors.teamSize || []" width="min-w-[18rem]" />
								<DomSelect v-model="workspaceForm.region" label="Data region" :options="bootstrap.options.regions" :errors="fieldErrors.region || []" width="min-w-[18rem]">
									<template #option="{ option }">
										<div><p class="font-medium">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-75">{{ option.description }}</p></div>
									</template>
								</DomSelect>
							</div>
							<div class="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-5">
								<p class="text-xs leading-5 text-muted-fg">Changes are revisioned and reloadable.</p>
								<DomButton type="submit" :loading="busy">Save and continue</DomButton>
							</div>
						</form>

						<form v-else-if="!isLaunched && activeStep === 'personalize'" class="mt-8 max-w-2xl" @submit.prevent="savePersonalization">
							<DomSelect v-model="personalizationForm.role" label="What best describes your role?" :options="bootstrap.options.roles" :errors="fieldErrors.role || []" searchable width="min-w-[20rem]">
								<template #option="{ option }">
									<div><p class="font-medium">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-75">{{ option.description }}</p></div>
								</template>
							</DomSelect>
							<fieldset class="mt-7">
								<legend class="text-sm font-semibold">What should Northstar help with first?</legend>
								<p class="mt-1 text-xs leading-5 text-muted-fg">Choose up to three outcomes. We use them to shape starter views and the first project.</p>
								<div class="mt-4 divide-y divide-border border-y border-border">
									<div v-for="goal in bootstrap.options.goals" :key="goal.value" class="py-4">
										<DomCheckbox
											:model-value="personalizationForm.goals.includes(goal.value)"
											:label="goal.label"
											:description="goal.description"
											@update:model-value="toggleGoal(goal.value, $event)"
										/>
									</div>
								</div>
							</fieldset>
							<div class="mt-8 flex flex-wrap items-center justify-between gap-3">
								<DomButton type="button" variant="secondary" @click="selectStep('workspace')">Back</DomButton>
								<DomButton type="submit" :loading="busy">Save and continue</DomButton>
							</div>
						</form>

						<section v-else-if="!isLaunched && activeStep === 'connect'" class="mt-8 max-w-2xl">
							<div v-if="session.connection" class="flex flex-col gap-4 border-y border-border py-5 sm:flex-row sm:items-center sm:justify-between">
								<div>
									<div class="flex flex-wrap items-center gap-2"><h2 class="font-semibold">{{ session.connection.label }}</h2><DomStatusPill tone="success" size="sm">Ready</DomStatusPill></div>
									<p class="mt-1 text-sm text-muted-fg">{{ session.connection.accountLabel }} · {{ session.connection.importedItems }} items · connected {{ formatTime(session.connection.connectedAt) }}</p>
								</div>
								<DomButton variant="secondary" size="sm" @click="selectedSourceId = session.connection.sourceId">Reconnect</DomButton>
							</div>

							<div class="mt-6 grid gap-6 sm:grid-cols-[minmax(0,1fr)_14rem]">
								<div>
									<DomSelect v-model="selectedSourceId" label="Import source" :options="bootstrap.options.sources" searchable width="min-w-[20rem]">
										<template #value="{ option, placeholder }">
											<span v-if="option"><span class="font-medium">{{ option.label }}</span><span class="ml-2 text-xs text-muted-fg">{{ option.description }}</span></span>
											<span v-else class="text-muted-fg">{{ placeholder }}</span>
										</template>
										<template #option="{ option }">
											<div><p class="font-medium">{{ option.label }}</p><p class="mt-0.5 text-xs opacity-75">{{ option.description }}</p></div>
										</template>
									</DomSelect>
									<p class="mt-4 text-sm leading-6 text-muted-fg">{{ selectedSource?.description }}</p>
								</div>
								<ol class="space-y-3 border-l border-border pl-5 text-xs leading-5 text-muted-fg">
									<li><span class="font-semibold text-canvas-fg">1. Authorize</span><br />Approve a scoped connection.</li>
									<li><span class="font-semibold text-canvas-fg">2. Inspect</span><br />Review the item count.</li>
									<li><span class="font-semibold text-canvas-fg">3. Continue</span><br />Import runs in the background.</li>
								</ol>
							</div>
							<div class="mt-8 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-5">
								<DomButton variant="secondary" @click="selectStep('personalize')">Back</DomButton>
								<div class="flex flex-wrap gap-3">
									<DomButton v-if="selectedSourceId === 'jira'" variant="secondary" :loading="busy" @click="connectSource(true)">Test restricted access</DomButton>
									<DomButton :loading="busy" @click="connectSource(false)">{{ selectedSourceId === 'empty' ? 'Use clean workspace' : `Connect ${selectedSource?.label || 'source'}` }}</DomButton>
								</div>
							</div>
						</section>

						<section v-else-if="!isLaunched" class="mt-8 max-w-3xl">
							<form class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem_auto] sm:items-end" @submit.prevent="addInvitation">
								<DomEmailInput v-model="invitationForm.email" label="Teammate email" placeholder="name@company.com" :errors="fieldErrors.email || []" />
								<DomSelect v-model="invitationForm.role" label="Role" :options="bootstrap.options.invitationRoles" width="min-w-[15rem]" />
								<DomButton type="submit" :loading="busy">Add invite</DomButton>
							</form>

							<div class="mt-7 border-y border-border">
								<div class="flex items-center gap-3 py-4">
									<DomAvatar :name="bootstrap.viewer.name" :initials="bootstrap.viewer.initials" size="sm" />
									<div class="min-w-0 flex-1"><p class="truncate text-sm font-medium">{{ bootstrap.viewer.email }}</p><p class="text-xs text-muted-fg">Workspace owner</p></div>
									<DomBadge tone="primary" variant="soft">Owner</DomBadge>
								</div>
								<div v-for="invitation in session.invitations" :key="invitation.id" class="flex items-center gap-3 border-t border-border py-4">
									<DomAvatar :name="invitation.email" size="sm" />
									<div class="min-w-0 flex-1"><p class="truncate text-sm font-medium">{{ invitation.email }}</p><p class="text-xs text-muted-fg">Invite prepared · {{ optionLabel(bootstrap.options.invitationRoles, invitation.role) }}</p></div>
									<DomButton variant="ghost" size="sm" :disabled="busy" @click="removeInvitation(invitation.id)">Remove</DomButton>
								</div>
							</div>

							<div class="mt-8 bg-secondary/50 p-5 sm:p-6">
								<div class="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
									<div class="max-w-xl">
										<h2 class="font-semibold">Ready to activate {{ session.workspace.name }}</h2>
										<p class="mt-2 text-sm leading-6 text-muted-fg">Launch creates the workspace, queues {{ session.invitations.length }} invitation{{ session.invitations.length === 1 ? '' : 's' }}, and records a signed setup receipt.</p>
									</div>
									<DomStatusPill :tone="session.readiness.readyToLaunch ? 'success' : 'warning'">{{ session.readiness.completeCount }}/4 ready</DomStatusPill>
								</div>
								<div class="mt-5 border-t border-border pt-5">
									<DomCheckbox v-model="termsAccepted" label="I confirm these workspace settings" description="The data region and workspace URL cannot be changed during activation." />
								</div>
							</div>

							<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
								<DomButton variant="secondary" @click="selectStep('connect')">Back</DomButton>
								<DomButton :disabled="!canLaunch" :loading="busy" @click="launchWorkspace">Launch workspace</DomButton>
							</div>
						</section>

						<section v-else class="mt-8 max-w-3xl">
							<div class="border-y border-success/30 bg-success/5 py-6 sm:px-6">
								<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
									<div><DomStatusPill tone="success">Workspace active</DomStatusPill><h2 class="mt-3 text-xl font-semibold">Launch {{ session.launch.reference }} completed</h2><p class="mt-2 text-sm leading-6 text-muted-fg">{{ session.invitations.length }} teammate invitation{{ session.invitations.length === 1 ? '' : 's' }} queued and {{ session.connection.importedItems }} source items ready.</p></div>
									<DomButton @click="openWorkspace">Open workspace</DomButton>
								</div>
							</div>

							<dl class="mt-6 grid gap-x-8 gap-y-5 sm:grid-cols-2">
								<div><dt class="text-xs font-semibold uppercase tracking-wider text-muted-fg">Workspace</dt><dd class="mt-1 text-sm font-medium">{{ session.workspace.name }}</dd></div>
								<div><dt class="text-xs font-semibold uppercase tracking-wider text-muted-fg">Region</dt><dd class="mt-1 text-sm font-medium">{{ optionLabel(bootstrap.options.regions, session.workspace.region) }}</dd></div>
								<div><dt class="text-xs font-semibold uppercase tracking-wider text-muted-fg">Source</dt><dd class="mt-1 text-sm font-medium">{{ session.connection.label }} · {{ session.connection.importedItems }} items</dd></div>
								<div><dt class="text-xs font-semibold uppercase tracking-wider text-muted-fg">Launched</dt><dd class="mt-1 text-sm font-medium">{{ formatTime(session.launch.launchedAt) }}</dd></div>
							</dl>

							<div class="mt-8 border-t border-border pt-6">
								<p class="text-xs font-semibold uppercase tracking-wider text-muted-fg">Launch proof</p>
								<code class="mt-2 block break-all rounded-lg bg-secondary px-3 py-2 text-xs text-secondary-fg">{{ session.launch.proof }}</code>
							</div>
						</section>
					</div>
				</main>
			</section>
		</div>

		<div v-else class="grid h-full place-items-center p-6">
			<DomAlert class="max-w-lg" tone="danger" title="Onboarding unavailable" :description="errorMessage || 'The onboarding session could not be loaded.'">
				<template #actions><DomButton variant="secondary" @click="loadBootstrap">Try again</DomButton></template>
			</DomAlert>
		</div>
	</div>
</template>