Wizard blocks
Multi-step flows that guide users through setup one validated stage at a time.
Horizontal steps
Three-step workspace setup with a numbered stepper across the top — the step and every answer live in client signals, so nothing touches the wire and going back loses nothing
-
components
-
templates
-
pages
-
<c-lbr.signals id="hsteps" :$step=step :$form=form />
<main class="container mx-auto max-w-3xl px-4 py-10 sm:py-14 flex flex-col gap-8">
<c-horizontal-steps.header />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-7 p-6 sm:p-8">
<c-horizontal-steps.stepper :steps=steps />
<div data-show="$step === 1">
<c-horizontal-steps.step-workspace :regions=regions />
</div>
<div data-show="$step === 2" style="display:none">
<c-horizontal-steps.step-data :sources=sources />
</div>
<div data-show="$step === 3" style="display:none">
<c-horizontal-steps.step-team :roles=roles />
</div>
<c-horizontal-steps.nav />
</c-lb.card.body>
</c-lb.card>
<c-horizontal-steps.trust />
</main>
<c-vars label="" hint="" class="" />
<div class="{{ class }}">
{% if label %}
<label class="label label-text text-sm font-medium mb-1">
{{ label }}
{% if hint %}<span class="text-base-content/40 font-normal">{{ hint }}</span>{% endif %}
</label>
{% endif %}
{{ slot }}
</div>
<div class="flex flex-col items-center gap-3 text-center">
<c-lb.badge style="soft" size="sm" icon="rmx.rocket">Arden setup</c-lb.badge>
<h1 class="text-3xl sm:text-4xl font-semibold tracking-tight text-balance">Set up your Arden workspace</h1>
<p class="max-w-lg text-sm text-base-content/60">
Three steps, about four minutes. Everything you type stays in the browser until
you finish, so you can move back and forth without losing an answer.
</p>
</div>
<div class="flex items-center justify-between gap-4 border-t border-base-300 pt-5">
<c-lb.button
id="hz-back"
type="button"
btnStyle="ghost"
size="sm"
icon="rmx.arrow-left"
disabled
data-attr:disabled="$step === 1"
data-on:click="$step = Math.max($step - 1, 1)"
>Back</c-lb.button>
<div id="hz-count" class="text-xs text-base-content/50 tabular-nums">
Step <span data-text="$step">1</span> of 3
</div>
<div>
<c-lb.button
id="hz-next"
type="button"
variant="primary"
size="sm"
icon.end="rmx.arrow-right"
data-show="$step < 3"
data-on:click="$step = Math.min($step + 1, 3)"
>Continue</c-lb.button>
<c-lb.button
id="hz-finish"
type="button"
variant="primary"
size="sm"
icon="rmx.check"
data-show="$step === 3"
style="display:none"
>Create workspace</c-lb.button>
</div>
</div>
<c-vars :sources="[]" />
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Where does your revenue live?</h2>
<p class="text-sm text-base-content/60 mt-1">
Arden reads customers, invoices and renewals from here. Connect one now, add the
rest whenever you like.
</p>
</div>
<div class="flex flex-col gap-2">
{% for source in sources %}
<label class="flex items-start gap-3 rounded-box border border-base-300 p-4 cursor-pointer
transition-colors hover:border-base-content/30
has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<c-lb.radio
name="hz-source"
variant="primary"
value="{{ source.value }}"
data-bind="form.source"
class="mt-0.5" />
<div>
<div class="font-medium text-sm">{{ source.name }}</div>
<div class="text-xs text-base-content/55 mt-0.5">{{ source.note }}</div>
</div>
</label>
{% endfor %}
</div>
<div class="rounded-box bg-base-200/60 p-4 text-xs text-base-content/60 leading-relaxed">
The first sync backfills 24 months, so your MRR chart is not empty on day one.
AtlasForge imported six years of Stripe history in eleven minutes.
</div>
</div>
<c-vars :roles="[]" />
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Invite the revenue team</h2>
<p class="text-sm text-base-content/60 mt-1">
Arden is worth more with finance in it. Invites go out when you finish setup.
</p>
</div>
<div class="flex flex-col gap-3">
<c-horizontal-steps.field label="Teammate">
<c-lb.input
id="hz-invite1"
type="email"
placeholder="[email protected]"
class="w-full"
bind="$form.invite1" />
</c-horizontal-steps.field>
<c-horizontal-steps.field label="Teammate" hint="(optional)">
<c-lb.input
id="hz-invite2"
type="email"
placeholder="[email protected]"
class="w-full"
bind="$form.invite2" />
</c-horizontal-steps.field>
<c-horizontal-steps.field label="They join as">
<c-lb.select id="hz-role" class="w-full" bind="$form.role">
{% for value, label in roles %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-horizontal-steps.field>
</div>
<div class="flex items-center gap-3 rounded-box border border-base-300 p-4">
<c-lb.avatar placeholder initials="JP" bgColor="secondary" size="sm" />
<p class="text-xs text-base-content/60 leading-relaxed">
“Our controller was in Arden before our first standup. She found $18,400 of
unbilled renewals that week.”
<span class="text-base-content/45">— Jonah Price, Head of Finance, Kite & Bell</span>
</p>
</div>
</div>
<c-vars :regions="[]" />
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Name your workspace</h2>
<p class="text-sm text-base-content/60 mt-1">
This is what your team sees when they sign in to Arden. You can rename it later.
</p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<c-horizontal-steps.field label="Workspace name">
<c-lb.input
data-lbb-start
id="hz-company"
type="text"
placeholder="Kite & Bell"
class="w-full"
bind="$form.company" />
</c-horizontal-steps.field>
<c-horizontal-steps.field label="Workspace address" hint="(arden.app/…)">
<c-lb.input
id="hz-slug"
type="text"
placeholder="kite-and-bell"
class="w-full font-mono"
bind="$form.slug" />
</c-horizontal-steps.field>
</div>
<c-horizontal-steps.field label="Data region" hint="(where your revenue data is stored)">
<c-lb.select id="hz-region" class="w-full" bind="$form.region">
{% for value, label in regions %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-horizontal-steps.field>
<div class="flex items-start gap-2 text-xs text-base-content/50">
<c-lbi n="rmx.shield-check" w="1em" h="1em" class="size-4 mt-0.5 shrink-0" />
<span>
Revenue data never leaves the region you pick. Beacon Labs, Northwind Co and
2,400 other teams run on EU West.
</span>
</div>
</div>
<c-vars :steps="[]" />
<div class="flex items-start gap-1">
{% for s in steps %}
<div class="flex items-start gap-1 {% if not forloop.last %}flex-1{% endif %}">
<div class="flex flex-col items-center gap-1.5 w-28">
<div
id="step-dot-{{ s.n }}"
class="w-9 h-9 rounded-full flex items-center justify-center text-sm font-semibold tabular-nums bg-base-300 text-base-content/50"
data-class="{'bg-primary text-primary-content': $step === {{ s.n }}, 'bg-success text-success-content': $step > {{ s.n }}, 'bg-base-300 text-base-content/50': $step < {{ s.n }}}"
>
<span data-show="$step > {{ s.n }}" style="display:none">
<c-lbi n="rmx.check" w="16" h="16" />
</span>
<span data-show="$step <= {{ s.n }}">{{ s.n }}</span>
</div>
<div class="text-center">
<div
class="text-xs font-medium"
data-class="{'text-base-content': $step >= {{ s.n }}, 'text-base-content/40': $step < {{ s.n }}}"
>{{ s.label }}</div>
<div class="text-[11px] text-base-content/40 hidden sm:block">{{ s.hint }}</div>
</div>
</div>
{% if not forloop.last %}
<div
class="flex-1 h-0.5 mt-4 bg-base-300"
data-class="{'bg-success': $step > {{ s.n }}, 'bg-base-300': $step <= {{ s.n }}}"
></div>
{% endif %}
</div>
{% endfor %}
</div>
<div class="flex flex-col items-center gap-5">
<div class="grid sm:grid-cols-3 gap-4 w-full">
<div class="rounded-box border border-base-300 p-4">
<div class="text-2xl font-semibold tabular-nums">4 min</div>
<div class="text-xs text-base-content/50 mt-1">Median time to finish setup</div>
</div>
<div class="rounded-box border border-base-300 p-4">
<div class="text-2xl font-semibold tabular-nums">24 mo</div>
<div class="text-xs text-base-content/50 mt-1">Backfilled on the first sync</div>
</div>
<div class="rounded-box border border-base-300 p-4">
<div class="text-2xl font-semibold tabular-nums">2,400+</div>
<div class="text-xs text-base-content/50 mt-1">Finance and ops teams on Arden</div>
</div>
</div>
<div class="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm font-medium text-base-content/40">
<span>AtlasForge</span>
<span>Kite & Bell</span>
<span>Northwind Co</span>
<span>Verity Labs</span>
<span>Ironvale Group</span>
</div>
</div>
Learn from this block
-
One signal holds the step, one holds every answer. There is no view behind this block and no session: the wizard is a page that changes what it shows.
-
Each step is always in the DOM and only one is visible. That is why going back never loses a keystroke — the inputs were never destroyed, and their values live in the signal bag anyway.
-
Done, current and upcoming are three states of one number. `data-class` swaps theme tokens — no fixed colours, so the stepper is legible in every daisyUI theme.
-
Continue adds one to a signal. Open the network tab and click through the whole wizard: nothing is sent.
-
Radios, selects and text inputs all bind to a path in the same signal object. Nothing saves them between steps, because nothing has to.
Onboarding
Multi-step workspace onboarding — state carried across steps in signals, per-step validation, one final submit to a real Django view
import datetime
import re
from django.utils import timezone
from labb.contrib.blocks import lm, render_page
from labb.signals import Int, Signals, Str
LbMember = lm("LbMember")
LbPlan = lm("LbPlan")
LbWorkspace = lm("LbWorkspace")
INDEX = "lb/wizard/onboarding/pages/index.html"
TITLE = "Set up your Arden workspace"
TOTAL_STEPS = 3
TRIAL_DAYS = 14
INVITE_SLOTS = (1, 2, 3)
INVITE_PLACEHOLDERS = [
"[email protected]",
"[email protected]",
"[email protected]",
]
REGIONS = [
("eu-west-1", "EU West · Ireland"),
("us-east-1", "US East · Virginia"),
("ap-south-1", "Asia Pacific · Mumbai"),
]
ROLES = [
("member", "Member — can see revenue, cannot change billing"),
("admin", "Admin — can invite people and change billing"),
("viewer", "Viewer — read-only access to dashboards"),
]
PLAN_SLUGS = ["starter", "growth", "scale"]
SLUG_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
class OnboardingSignals(Signals):
"""Every answer the wizard collects lives here, and nowhere else.
The step counter sits alongside the answers, so one signal bag carries the
whole wizard: moving between steps is a normal request that ships the bag,
which is why going back never loses what was typed.
"""
step = Int(default=1, min_value=1, max_value=TOTAL_STEPS)
workspace_name = Str(path="form.workspace_name", default="")
workspace_slug = Str(path="form.workspace_slug", default="")
region = Str(
path="form.region", default="eu-west-1", choices=[r[0] for r in REGIONS]
)
invite_1 = Str(path="form.invite_1", default="")
invite_2 = Str(path="form.invite_2", default="")
invite_3 = Str(path="form.invite_3", default="")
invite_role = Str(
path="form.invite_role", default="member", choices=[r[0] for r in ROLES]
)
plan = Str(path="form.plan", default="", choices=PLAN_SLUGS)
def _invites(s):
"""The invite fields that were actually filled in, in slot order."""
raw = [getattr(s, f"invite_{i}") for i in INVITE_SLOTS]
return [e.strip() for e in raw if e.strip()]
def _initials(email):
local = email.split("@")[0]
parts = [p for p in re.split(r"[._-]+", local) if p]
letters = "".join(p[0] for p in parts[:2]) or local[:1]
return letters.upper()
def _display_name(email):
local = email.split("@")[0]
parts = [p for p in re.split(r"[._-]+", local) if p]
return " ".join(p.capitalize() for p in parts) or local
def _people(s):
"""Everyone who will hold a seat on day one: the invitees plus you."""
return len(_invites(s)) + 1
def _selected_plan(s):
return LbPlan.objects.filter(slug=s.plan).first() if s.plan else None
def _validate_step(s, step, strict=False):
"""Gentle while typing, strict when the step tries to advance.
Step 3 validates against answers given on step 2 — the seat check only works
because the invites are still in the signal bag when the plan is chosen.
"""
errors = {}
if step == 1:
if s.workspace_name.strip():
if len(s.workspace_name.strip()) < 3:
errors["name"] = "Give the workspace a name with at least 3 characters."
elif strict:
errors["name"] = "Name your workspace."
slug = s.workspace_slug.strip()
if slug:
if not SLUG_RE.match(slug):
errors["slug"] = "Lowercase letters, numbers and hyphens only."
elif LbWorkspace.objects.filter(slug=slug).exists():
errors["slug"] = "That address is taken. Try another."
elif strict:
errors["slug"] = "Choose the address your team will sign in at."
elif step == 2:
for i in INVITE_SLOTS:
email = getattr(s, f"invite_{i}").strip()
if not email:
continue
if not EMAIL_RE.match(email):
errors[f"invite_{i}"] = "That does not look like an email address."
elif LbMember.objects.filter(email=email).exists():
errors[f"invite_{i}"] = f"{email} is already on an Arden workspace."
if strict and not _invites(s):
errors["invites"] = (
"Invite at least one teammate — Arden is worth more with the revenue team in it."
)
elif step == 3:
plan = _selected_plan(s)
if plan is None:
if strict:
errors["plan"] = "Choose a plan to finish setting up."
else:
people = _people(s)
if people > plan.seats_included:
errors["plan"] = (
f"{plan.name} includes {plan.seats_included} seats and you are starting with "
f"{people}. Pick a larger plan, or remove an invite."
)
return errors
def _invite_slots(s, errors):
"""One row per invite field, carrying everything the row needs to render."""
slots = []
for i in INVITE_SLOTS:
email = getattr(s, f"invite_{i}").strip()
slots.append(
{
"n": i,
"path": f"form.invite_{i}",
"email": email,
"initials": _initials(email) if EMAIL_RE.match(email) else "",
"placeholder": INVITE_PLACEHOLDERS[i - 1],
"error": errors.get(f"invite_{i}", ""),
}
)
return slots
def _ctx(s, errors=None, workspace=None, members=None):
errors = errors or {}
invites = [
{"email": e, "initials": _initials(e), "name": _display_name(e)}
for e in _invites(s)
]
return {
"signals": s,
"step": s.step,
"invite_slots": _invite_slots(s, errors),
"total_steps": TOTAL_STEPS,
"regions": REGIONS,
"roles": ROLES,
"plans": LbPlan.objects.all(),
"selected_plan": _selected_plan(s),
"invites": invites,
"people": _people(s),
"trial_days": TRIAL_DAYS,
"errors": errors,
"created": workspace is not None,
"workspace": workspace,
"members": members or [],
}
def _page(request, ctx):
return render_page(request, INDEX, ctx, title=TITLE)
def index(request):
return _page(request, _ctx(OnboardingSignals(request)))
def validate(request):
"""Live, gentle validation of the step the user is on. No step change."""
s = OnboardingSignals(request)
return _page(request, _ctx(s, errors=_validate_step(s, s.step)))
def next_step(request):
s = OnboardingSignals(request)
errors = _validate_step(s, s.step, strict=True)
if errors:
return _page(request, _ctx(s, errors=errors))
s.step = min(s.step + 1, TOTAL_STEPS)
return _page(request, _ctx(s))
def prev_step(request):
s = OnboardingSignals(request)
s.step = max(s.step - 1, 1)
return _page(request, _ctx(s))
def submit(request):
if request.method != "POST":
return index(request)
s = OnboardingSignals(request)
for step in range(1, TOTAL_STEPS + 1):
errors = _validate_step(s, step, strict=True)
if errors:
s.step = step
return _page(request, _ctx(s, errors=errors))
today = timezone.now().date()
workspace = LbWorkspace.objects.create(
name=s.workspace_name.strip(),
slug=s.workspace_slug.strip(),
plan=_selected_plan(s),
region=s.region,
currency="USD",
billing_email=_invites(s)[0],
trial_ends_on=today + datetime.timedelta(days=TRIAL_DAYS),
created_at=timezone.now(),
)
members = [
LbMember.objects.create(
workspace=workspace,
name=_display_name(email),
email=email,
title="",
role=s.invite_role,
status="invited",
joined_on=today,
)
for email in _invites(s)
]
return _page(request, _ctx(s, workspace=workspace, members=members))
from django.urls import path
from . import views
app_name = "block_wizard_onboarding"
urlpatterns = [
path("", views.index, name="index"),
path("validate/", views.validate, name="validate"),
path("next/", views.next_step, name="next"),
path("prev/", views.prev_step, name="prev"),
path("submit/", views.submit, name="submit"),
]
<c-lbr.signals id="onboarding-signals" :schema=signals />
<c-onboarding.shell>
<c-slot name="aside">
<c-onboarding.summary
:selected_plan=selected_plan
:invites=invites
:people=people
:trial_days=trial_days />
</c-slot>
{% if created %}
<c-onboarding.success
:workspace=workspace
:invites=invites
:selected_plan=selected_plan
:trial_days=trial_days />
{% else %}
<c-onboarding.wizard
:signals=signals
:step=step
:total_steps=total_steps
:regions=regions
:roles=roles
:plans=plans
:selected_plan=selected_plan
:invite_slots=invite_slots
:people=people
:errors=errors />
{% endif %}
</c-onboarding.shell>
<c-vars label="" hint="" error="" class="" />
<div class="{{ class }}">
{% if label %}
<label class="label label-text text-sm font-medium mb-1">
{{ label }}
{% if hint %}<span class="text-base-content/40 font-normal">{{ hint }}</span>{% endif %}
</label>
{% endif %}
{{ slot }}
{% if error %}
<p class="text-error text-xs mt-1">{{ error }}</p>
{% endif %}
</div>
<c-vars :invite="{}" />
<div class="flex items-start gap-3" id="invite-{{ invite.n }}">
{% if invite.initials %}
<c-lb.avatar placeholder :initials=invite.initials bgColor="secondary" size="sm" rounded="lg" class="mt-1" />
{% else %}
<c-lb.avatar placeholder icon="rmx.user-add" bgColor="neutral" size="sm" rounded="lg" class="mt-1 opacity-40" />
{% endif %}
<c-onboarding.field class="flex-1" :error=invite.error>
<c-lb.input type="email" class="w-full" :placeholder=invite.placeholder :bind=invite.path />
</c-onboarding.field>
</div>
<c-vars :step=1 :total_steps=3 />
<div class="flex items-center justify-between border-t border-base-300 pt-5">
{% if step > 1 %}
<c-lbr.post to="block_wizard_onboarding:prev" tag="div" on="click">
<c-lb.button type="button" btnStyle="ghost" icon="rmx.arrow-left">Back</c-lb.button>
</c-lbr.post>
{% else %}
<span class="text-xs text-base-content/40">Step {{ step }} of {{ total_steps }}</span>
{% endif %}
{% if step < total_steps %}
<c-lbr.post to="block_wizard_onboarding:next" tag="div" on="click">
<c-lb.button type="button" variant="primary" icon.end="rmx.arrow-right">Continue</c-lb.button>
</c-lbr.post>
{% else %}
<c-lbr.post to="block_wizard_onboarding:submit" tag="div" on="click">
<c-lb.button type="button" variant="primary">Create workspace</c-lb.button>
</c-lbr.post>
{% endif %}
</div>
<c-vars :plan="None" :people=1 />
<label class="flex items-start gap-4 rounded-box border border-base-300 p-4 cursor-pointer
transition-colors hover:border-base-content/30
has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<c-lb.radio name="plan" variant="primary" value="{{ plan.slug }}" data-bind="form.plan" class="mt-1" />
<div class="flex-1">
<div class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<span class="font-semibold">{{ plan.name }}</span>
{% if plan.is_featured %}
<c-lb.badge variant="primary" style="soft" size="sm">Most popular</c-lb.badge>
{% endif %}
</div>
<div class="text-right">
<span class="font-semibold tabular-nums">${{ plan.price_monthly|floatformat:0 }}</span>
<span class="text-xs text-base-content/50">/mo</span>
</div>
</div>
<p class="text-sm text-base-content/60 mt-1">{{ plan.tagline }}</p>
<div class="mt-2 flex items-center gap-2 text-xs">
{% if people > plan.seats_included %}
<span class="text-warning font-medium tabular-nums">
{{ plan.seats_included }} seats — too few for your {{ people }}
</span>
{% else %}
<span class="text-base-content/50 tabular-nums">
{{ plan.seats_included }} seats included
</span>
{% endif %}
<span class="text-base-content/30">·</span>
<span class="text-base-content/50">{{ plan.features|first }}</span>
</div>
</div>
</label>
<c-vars aside="" />
<main class="min-h-screen grid lg:grid-cols-3">
<aside class="relative isolate overflow-hidden hidden lg:flex flex-col justify-between gap-10 p-10
bg-base-200 border-r border-base-300">
<div class="pointer-events-none absolute inset-0 -z-10"
style="background:
radial-gradient(60% 48% at 12% 0%, #312e81 0%, transparent 70%),
radial-gradient(56% 44% at 92% 20%, #0f766e 0%, transparent 70%),
radial-gradient(70% 56% at 50% 105%, #65a30d 0%, transparent 74%);
opacity: 0.13;"
aria-hidden="true"></div>
{{ aside }}
</aside>
<section class="lg:col-span-2 flex justify-center p-6 sm:p-12 bg-base-100">
<div class="w-full max-w-2xl">
{{ slot }}
</div>
</section>
</main>
<c-vars :signals="{}" :plans="[]" :selected_plan="None" :people=1 :errors="{}" />
<div>
<h2 class="text-xl font-semibold tracking-tight">Pick a plan</h2>
<p class="text-sm text-base-content/60 mt-1">
You are starting with <span class="font-medium tabular-nums">{{ people }}</span>
{% if people == 1 %}seat{% else %}seats{% endif %}, so the plan has to fit the team you
just invited. Free for 14 days either way.
</p>
</div>
{% if errors.plan %}
<c-lb.alert variant="error" alertStyle="soft" icon="rmx.error-warning">
{{ errors.plan }}
</c-lb.alert>
{% endif %}
{% if plans %}
<div class="grid gap-3">
{% for plan in plans %}
<c-onboarding.plan-card :plan=plan :people=people />
{% endfor %}
</div>
{% else %}
<c-lb.alert alertStyle="soft" icon="rmx.price-tag-3">
No plans are configured yet. Add one in Arden admin and it will show up here.
</c-lb.alert>
{% endif %}
<p class="text-xs text-base-content/50">
Change plan or cancel any time from Settings. We email you three days before the trial ends.
</p>
<c-vars :signals="{}" :roles="[]" :invite_slots="[]" :errors="{}" />
<div>
<h2 class="text-xl font-semibold tracking-tight">Invite your revenue team</h2>
<p class="text-sm text-base-content/60 mt-1">
Arden earns its keep when finance and ops are reading the same numbers.
Invites go out when you finish setting up.
</p>
</div>
{% if errors.invites %}
<c-lb.alert variant="warning" alertStyle="soft" icon="rmx.user-add">
{{ errors.invites }}
</c-lb.alert>
{% endif %}
<c-lbr.post to="block_wizard_onboarding:validate" tag="div" on="input__debounce.500ms" class="grid gap-3">
{% for invite in invite_slots %}
<c-onboarding.invite-row :invite=invite />
{% endfor %}
</c-lbr.post>
<c-onboarding.field label="Invite them as" hint="(you stay the owner)">
<c-lb.select class="w-full" :bind=signals.fields.invite_role>
{% for value, label in roles %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-onboarding.field>
<c-vars :signals="{}" :regions="[]" :errors="{}" />
<div>
<h2 class="text-xl font-semibold tracking-tight">Name your workspace</h2>
<p class="text-sm text-base-content/60 mt-1">
This is what your team will see when they sign in. You can rename it later.
</p>
</div>
<c-lbr.post to="block_wizard_onboarding:validate" tag="div" on="input__debounce.500ms" class="grid gap-4">
<c-onboarding.field label="Workspace name" :error=errors.name>
<c-lb.input type="text" placeholder="Kite & Bell" class="w-full" data-lbb-start
:bind=signals.fields.workspace_name />
</c-onboarding.field>
<c-onboarding.field label="Workspace address" hint="(arden.app/…)" :error=errors.slug>
<c-lb.input type="text" placeholder="kite-and-bell" class="w-full font-mono"
:bind=signals.fields.workspace_slug />
</c-onboarding.field>
</c-lbr.post>
<c-onboarding.field label="Data region" hint="(where your revenue data is stored)">
<c-lb.select class="w-full" :bind=signals.fields.region>
{% for value, label in regions %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-onboarding.field>
<c-vars :step=1 :total_steps=3 :labels="['Workspace', 'Team', 'Plan']" />
<div class="flex items-start gap-1">
{% for label in labels %}
{% with n=forloop.counter %}
<div class="flex items-start {% if not forloop.last %}flex-1{% endif %} gap-1">
<div class="flex flex-col items-center gap-1.5 w-24">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold
{% if n < step %}bg-success text-success-content
{% elif n == step %}bg-primary text-primary-content
{% else %}bg-base-300 text-base-content/40{% endif %}">
{% if n < step %}
<c-lbi n="rmx.check" w="16" h="16" />
{% else %}
{{ n }}
{% endif %}
</div>
<span class="text-xs {% if n == step %}font-semibold{% else %}text-base-content/50{% endif %}">
{{ label }}
</span>
</div>
{% if not forloop.last %}
<div class="flex-1 h-0.5 mt-4 {% if n < step %}bg-success{% else %}bg-base-300{% endif %}"></div>
{% endif %}
</div>
{% endwith %}
{% endfor %}
</div>
<c-vars :workspace="None" :invites="[]" :selected_plan="None" :trial_days=14 />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-6">
<div class="flex items-center gap-4">
<c-lb.avatar placeholder icon="rmx.check" bgColor="success" size="lg" rounded="full" />
<div>
<h2 class="text-2xl font-semibold tracking-tight">{{ workspace.name }} is live.</h2>
<p class="text-sm text-base-content/60 mt-1">
Your team signs in at
<span class="font-mono">arden.app/{{ workspace.slug }}</span>.
</p>
</div>
</div>
<div class="grid sm:grid-cols-3 gap-3 text-sm">
<div class="rounded-box border border-base-300 p-3">
<div class="text-base-content/50 text-xs">Plan</div>
<div class="font-medium mt-0.5">{{ selected_plan.name }}</div>
<div class="text-xs text-base-content/50 tabular-nums">
{{ selected_plan.seats_included }} seats
</div>
</div>
<div class="rounded-box border border-base-300 p-3">
<div class="text-base-content/50 text-xs">Trial ends</div>
<div class="font-medium mt-0.5">{{ workspace.trial_ends_on|date:"j M Y" }}</div>
<div class="text-xs text-base-content/50 tabular-nums">
{{ trial_days }} days from today
</div>
</div>
<div class="rounded-box border border-base-300 p-3">
<div class="text-base-content/50 text-xs">Region</div>
<div class="font-medium mt-0.5">{{ workspace.region }}</div>
<div class="text-xs text-base-content/50">Data stays here</div>
</div>
</div>
<div>
<p class="text-xs uppercase tracking-wider text-base-content/40 mb-3">
Invites sent ({{ invites|length }})
</p>
<div class="grid gap-2">
{% for invite in invites %}
<div class="flex items-center gap-3 text-sm">
<c-lb.avatar placeholder :initials=invite.initials bgColor="secondary" size="sm" rounded="lg" />
<div class="flex-1">
<div class="font-medium">{{ invite.name }}</div>
<div class="text-xs text-base-content/50">{{ invite.email }}</div>
</div>
<c-lb.badge variant="warning" style="soft" size="sm">Invited</c-lb.badge>
</div>
{% endfor %}
</div>
</div>
<div class="flex items-center justify-between border-t border-base-300 pt-5">
<p class="text-sm text-base-content/60">
Next: connect Stripe and Arden backfills your revenue history.
</p>
<c-lb.button as="a" href="{% url 'block_wizard_onboarding:index' %}" btnStyle="ghost" size="sm">
Start over
</c-lb.button>
</div>
</c-lb.card.body>
</c-lb.card>
<c-vars :selected_plan="None" :invites="[]" :people=1 :trial_days=14 />
<div>
<div class="flex items-center gap-3">
<c-arden.mark size="sm" />
<span class="text-lg font-semibold tracking-tight">Arden</span>
</div>
<h1 class="mt-8 text-3xl font-semibold tracking-tight text-balance">
Set up your workspace.
</h1>
<p class="mt-2 text-sm text-base-content/60">
Three steps. Most teams are looking at their MRR inside ten minutes.
</p>
</div>
<c-lb.card class="bg-base-100/70 backdrop-blur border border-base-300 shadow-sm">
<c-lb.card.body class="p-5 gap-3">
<p class="text-xs uppercase tracking-wider text-base-content/40">So far</p>
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-base-content/50">Workspace</span>
<span class="font-medium text-right truncate"
data-text="$form.workspace_name || 'Untitled workspace'"></span>
</div>
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-base-content/50">Address</span>
<span class="font-mono text-xs text-right truncate"
data-text="'arden.app/' + ($form.workspace_slug || 'your-team')"></span>
</div>
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-base-content/50">Team</span>
<span class="font-medium tabular-nums">
{{ people }} {% if people == 1 %}seat{% else %}seats{% endif %}
<span class="text-base-content/40 font-normal">(you{% if invites %} + {{ invites|length }}{% endif %})</span>
</span>
</div>
<div class="flex items-baseline justify-between gap-3 text-sm">
<span class="text-base-content/50">Plan</span>
{% if selected_plan %}
<span class="font-medium">
{{ selected_plan.name }}
<span class="text-base-content/40 font-normal tabular-nums">${{ selected_plan.price_monthly|floatformat:0 }}/mo</span>
</span>
{% else %}
<span class="text-base-content/40">Not chosen yet</span>
{% endif %}
</div>
</c-lb.card.body>
</c-lb.card>
<div class="space-y-4">
<div class="flex gap-3 text-sm text-base-content/40">
<c-lbi n="rmx.shield-check" w="18" h="18" class="mt-0.5 shrink-0" />
<p class="text-base-content/60">
{{ trial_days }} days free, no card. Nothing bills until you connect your first
payment provider.
</p>
</div>
<p class="text-xs uppercase tracking-wider text-base-content/40">Set up this month</p>
<div class="flex flex-wrap gap-x-5 gap-y-2 text-sm font-medium text-base-content/50">
<span>Kite & Bell</span>
<span>Northwind Co</span>
<span>Verity Labs</span>
</div>
</div>
<c-vars :signals="{}" :step=1 :total_steps=3 :regions="[]" :roles="[]" :plans="[]" :selected_plan="None" :invite_slots="[]" :people=1 :errors="{}" />
<c-lb.card id="onboarding-card" class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-7">
<c-onboarding.stepper :step=step :total_steps=total_steps />
{% if step == 1 %}
<c-onboarding.step-workspace :signals=signals :regions=regions :errors=errors />
{% elif step == 2 %}
<c-onboarding.step-team :signals=signals :roles=roles :invite_slots=invite_slots :errors=errors />
{% elif step == 3 %}
<c-onboarding.step-plan :signals=signals :plans=plans :selected_plan=selected_plan :people=people :errors=errors />
{% endif %}
<c-onboarding.nav :step=step :total_steps=total_steps />
</c-lb.card.body>
</c-lb.card>
{% load lb_tags %}
<c-vars
size="sm"
:tile="{'xs':'w-6 h-6 rounded-md','sm':'w-8 h-8 rounded-lg','md':'w-10 h-10 rounded-xl','lg':'w-12 h-12 rounded-xl'}"
:icon="{'xs':'w-4 h-4','sm':'w-5 h-5','md':'w-6 h-6','lg':'w-7 h-7'}"
/>
<span class="inline-flex shrink-0 items-center justify-center bg-primary text-primary-content {{ tile|get_dict_item:size }}" {{ attrs }}>
<svg viewBox="0 0 48 48" class="{{ icon|get_dict_item:size }}" fill="none" aria-hidden="true">
<path d="M12 36 24 12l12 24" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17 27h14" stroke="currentColor" stroke-width="5" stroke-linecap="round" />
</svg>
</span>
<c-vars active="overview" drawerId="" />
<div class="flex h-full flex-col bg-base-100 border-r border-base-300 transition-[width] is-drawer-close:w-16 is-drawer-open:w-60">
{% if drawerId %}
<c-lb.drawer.toggle
for="{{ drawerId }}"
class="btn btn-square btn-sm fixed top-16 z-50 hidden -translate-x-1/2 border border-base-300 bg-base-100 shadow-sm transition-[left] duration-200 lg:flex is-drawer-close:left-16 is-drawer-open:left-60"
aria-label="Toggle sidebar">
<c-lbi n="rmx.sidebar-unfold" w="18" h="18" />
</c-lb.drawer.toggle>
{% endif %}
<div class="flex h-16 items-center gap-3 px-4">
<c-arden.mark size="xs" />
<span class="font-semibold tracking-tight is-drawer-close:hidden">Arden</span>
</div>
<nav class="grow px-2 py-3">
<ul class="menu gap-1 p-0">
<li><a href="#" class="{% if active == 'overview' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Overview"><c-lbi n="rmx.pie-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Overview</span></a></li>
<li><a href="#" class="{% if active == 'customers' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Customers"><c-lbi n="rmx.group-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Customers</span></a></li>
<li><a href="#" class="{% if active == 'activity' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Activity"><c-lbi n="rmx.pulse" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Activity</span></a></li>
<li><a href="#" class="{% if active == 'reports' %}active {% endif %}is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Reports"><c-lbi n="rmx.bar-chart-2" w="18" h="18" class="shrink-0" /><span class="is-drawer-close:hidden">Reports</span></a></li>
</ul>
</nav>
<div class="border-t border-base-300 p-2">
<a href="#" class="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-base-content/60 hover:bg-base-200 is-drawer-close:tooltip is-drawer-close:tooltip-right" data-tip="Settings">
<c-lbi n="rmx.settings-3" w="18" h="18" class="shrink-0" />
<span class="is-drawer-close:hidden">Settings</span>
</a>
</div>
</div>
Learn from this block
-
The schema names the values the flow carries between steps and gives the view validated Python values. Each step reads the same state rather than rebuilding form data.
-
Passing the field descriptor to bind keeps the template on the schema’s path. Rename the path in one place and the form keeps following it.
-
The step posts its signals to a Django view for validation. The response returns the same step with errors or the next state, so the user stays in the flow.
-
The next-step view reads the submitted signals, decides which template belongs next, and returns HTML. You can add business rules here without creating a separate client router.
Progress minimal
A four-question setup with nothing but a bar and a count — the bar's value and its colour are both driven by one client signal, with no server behind it
-
components
-
templates
-
pages
-
<c-lbr.signals id="mini-wizard" :$step=step :$form=form $tone="primary" />
<main class="container mx-auto max-w-xl px-4 py-12 sm:py-20 flex flex-col gap-8">
<c-minimal-wizard.progress />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-7 p-6 sm:p-8">
<div data-show="$step === 1">
<c-minimal-wizard.step-name />
</div>
<div data-show="$step === 2" style="display:none">
<c-minimal-wizard.step-model :models=models />
</div>
<div data-show="$step === 3" style="display:none">
<c-minimal-wizard.step-source :sources=sources />
</div>
<div data-show="$step === 4" style="display:none">
<c-minimal-wizard.step-invite />
</div>
<c-minimal-wizard.nav />
</c-lb.card.body>
</c-lb.card>
<c-minimal-wizard.reassurance />
</main>
<div class="flex items-center justify-between gap-4 border-t border-base-300 pt-5">
<c-lb.button
id="mw-back"
type="button"
btnStyle="ghost"
size="sm"
icon="rmx.arrow-left"
disabled
data-attr:disabled="$step === 1"
data-on:click="$step = Math.max($step - 1, 1); $tone = $step === 4 ? 'success' : 'primary'"
>Back</c-lb.button>
<div>
<c-lb.button
id="mw-next"
type="button"
variant="primary"
size="sm"
icon.end="rmx.arrow-right"
data-show="$step < 4"
data-on:click="$step = Math.min($step + 1, 4); $tone = $step === 4 ? 'success' : 'primary'"
>Continue</c-lb.button>
<c-lb.button
id="mw-finish"
type="button"
variant="success"
size="sm"
icon="rmx.check"
data-show="$step === 4"
style="display:none"
>Open Arden</c-lb.button>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-lg bg-primary
flex items-center justify-center text-primary-content font-bold text-xs">A</div>
<span class="text-sm font-semibold tracking-tight">Arden</span>
</div>
<div class="flex items-center gap-3">
<div id="mw-count" class="text-xs text-base-content/50 tabular-nums">
Step <span data-text="$step">1</span> of 4
</div>
<c-lb.badge
id="mw-tone"
size="sm"
style="soft"
variant="$tone:primary"
data-text="$step === 4 ? 'Ready' : 'In progress'"
>In progress</c-lb.badge>
</div>
</div>
<c-lb.progress
id="mw-bar"
variant="primary"
value="25"
max="100"
class="w-full h-1.5"
data-attr:value="$step * 25" />
</div>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<div class="text-lg font-semibold tabular-nums">4</div>
<div class="text-xs text-base-content/45 mt-0.5">Questions, and that is all</div>
</div>
<div>
<div class="text-lg font-semibold tabular-nums">6 min</div>
<div class="text-xs text-base-content/45 mt-0.5">To your first forecast</div>
</div>
<div>
<div class="text-lg font-semibold tabular-nums">0</div>
<div class="text-xs text-base-content/45 mt-0.5">Sales calls required</div>
</div>
</div>
<p class="text-center text-xs text-base-content/45">
Every answer can be changed later in workspace settings.
</p>
</div>
<div class="flex flex-col gap-4">
<h2 class="text-xl font-semibold tracking-tight">Who else needs to see the numbers?</h2>
<p class="text-sm text-base-content/60">
One is enough to start. Finance is the usual answer.
</p>
<c-lb.input
id="mw-invite"
type="email"
placeholder="[email protected]"
size="lg"
class="w-full"
bind="$form.invite" />
<div class="flex items-center gap-3 rounded-box bg-base-200/60 p-4">
<c-lb.avatar placeholder initials="TA" bgColor="primary" size="sm" />
<p class="text-xs text-base-content/60 leading-relaxed">
“Four questions and we had a forecast the same afternoon. Nobody scheduled a
kickoff call.”
<span class="text-base-content/45">— Tom Ainsworth, Head of Revenue, Beacon Labs</span>
</p>
</div>
</div>
<c-vars :models="[]" />
<div class="flex flex-col gap-4">
<h2 class="text-xl font-semibold tracking-tight">How do you charge?</h2>
<p class="text-sm text-base-content/60">
This decides how Arden calculates MRR, and which churn figure it shows you first.
</p>
<div class="flex flex-col gap-2">
{% for model in models %}
<label class="flex items-start gap-3 rounded-box border border-base-300 p-4 cursor-pointer
transition-colors hover:border-base-content/30
has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<c-lb.radio
name="mw-model"
variant="primary"
value="{{ model.value }}"
data-bind="form.model"
class="mt-0.5" />
<div>
<div class="font-medium text-sm">{{ model.name }}</div>
<div class="text-xs text-base-content/55 mt-0.5">{{ model.note }}</div>
</div>
</label>
{% endfor %}
</div>
</div>
<div class="flex flex-col gap-4">
<h2 class="text-xl font-semibold tracking-tight">What should we call your workspace?</h2>
<p class="text-sm text-base-content/60">
Usually the company name. Your team sees it every time they sign in.
</p>
<c-lb.input
data-lbb-start
id="mw-company"
type="text"
placeholder="Northwind Co"
size="lg"
class="w-full"
bind="$form.company" />
<p class="text-xs text-base-content/45">You can rename it later without breaking any links.</p>
</div>
<c-vars :sources="[]" />
<div class="flex flex-col gap-4">
<h2 class="text-xl font-semibold tracking-tight">Where does the revenue data live?</h2>
<p class="text-sm text-base-content/60">
The first sync backfills 24 months, so your dashboards are not empty on day one.
</p>
<c-lb.select id="mw-source" size="lg" class="w-full" bind="$form.source">
{% for value, label in sources %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
<div class="flex items-start gap-2 text-xs text-base-content/50">
<c-lbi n="rmx.shield-check" w="1em" h="1em" class="size-4 mt-0.5 shrink-0" />
<span>Arden reads your billing system. It never writes to it.</span>
</div>
</div>
Learn from this block
-
Three signals and no server. `tone` exists so a component prop can track it — a reactive prop takes a signal path, never an expression, so the value has to live somewhere.
-
No circles, no connectors: an attribute computed from the step. The server renders the opening value, so the bar is already at 25% before any JavaScript arrives.
-
A reactive prop. The part after the colon is the server-rendered fallback; when `tone` changes, labb recomputes the daisyUI classes in the browser.
-
One click, two assignments, zero requests. Going back runs the same arithmetic in reverse and every answer is still in the bag, because nothing ever took it out.
-
The radio writes `form.model`. There is no step-local state to reconcile — that is the whole reason back and forth is free here.
Vertical steps
Workspace setup with a step rail down the side — the rail, the panel and every answer read one client signal, with no server behind them
-
components
-
vertical_steps
-
-
templates
-
pages
-
<c-lbr.signals id="vsteps" :$step=step :$form=form />
<main class="container mx-auto max-w-5xl px-4 py-10 sm:py-14">
<div class="grid lg:grid-cols-[16rem_1fr] gap-8 lg:gap-12">
<c-vertical-steps.rail :steps=steps />
<c-lb.card class="border border-base-300 shadow-sm h-fit">
<c-lb.card.body class="gap-7 p-6 sm:p-8">
<div data-show="$step === 1">
<c-vertical-steps.step-workspace :regions=regions />
</div>
<div data-show="$step === 2" style="display:none">
<c-vertical-steps.step-billing :currencies=currencies />
</div>
<div data-show="$step === 3" style="display:none">
<c-vertical-steps.step-team :roles=roles />
</div>
<c-vertical-steps.nav />
</c-lb.card.body>
</c-lb.card>
</div>
</main>
<c-vars label="" hint="" class="" />
<div class="{{ class }}">
{% if label %}
<label class="label label-text text-sm font-medium mb-1">
{{ label }}
{% if hint %}<span class="text-base-content/40 font-normal">{{ hint }}</span>{% endif %}
</label>
{% endif %}
{{ slot }}
</div>
<div class="flex items-center justify-between gap-4 border-t border-base-300 pt-5">
<c-lb.button
id="vt-back"
type="button"
btnStyle="ghost"
size="sm"
icon="rmx.arrow-left"
disabled
data-attr:disabled="$step === 1"
data-on:click="$step = Math.max($step - 1, 1)"
>Back</c-lb.button>
<div id="vt-count" class="text-xs text-base-content/50 tabular-nums">
Step <span data-text="$step">1</span> of 3
</div>
<div>
<c-lb.button
id="vt-next"
type="button"
variant="primary"
size="sm"
icon.end="rmx.arrow-right"
data-show="$step < 3"
data-on:click="$step = Math.min($step + 1, 3)"
>Continue</c-lb.button>
<c-lb.button
id="vt-finish"
type="button"
variant="primary"
size="sm"
icon="rmx.check"
data-show="$step === 3"
style="display:none"
>Finish setup</c-lb.button>
</div>
</div>
<c-vars :steps="[]" />
<aside class="flex flex-col gap-8">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-primary
flex items-center justify-center text-primary-content font-bold text-sm">A</div>
<span class="font-semibold tracking-tight">Arden</span>
</div>
<h1 class="text-xl font-semibold tracking-tight mt-6">Set up your workspace</h1>
<p class="text-sm text-base-content/55">
Three steps. Your answers stay in the browser until the last one.
</p>
</div>
<ol class="flex flex-col gap-1">
{% for s in steps %}
<li
id="rail-step-{{ s.n }}"
class="flex items-start gap-3 rounded-box p-3 border border-transparent"
data-class="{'bg-primary/5 border-primary/30': $step === {{ s.n }}}"
>
<div
class="w-7 h-7 shrink-0 rounded-full flex items-center justify-center text-xs font-semibold tabular-nums bg-base-300 text-base-content/50"
data-class="{'bg-primary text-primary-content': $step === {{ s.n }}, 'bg-success text-success-content': $step > {{ s.n }}, 'bg-base-300 text-base-content/50': $step < {{ s.n }}}"
>
<span data-show="$step > {{ s.n }}" style="display:none">
<c-lbi n="rmx.check" w="14" h="14" />
</span>
<span data-show="$step <= {{ s.n }}">{{ s.n }}</span>
</div>
<div class="min-w-0">
<div
class="text-sm font-medium"
data-class="{'text-base-content': $step >= {{ s.n }}, 'text-base-content/45': $step < {{ s.n }}}"
>{{ s.label }}</div>
<div class="text-xs text-base-content/45 mt-0.5">{{ s.hint }}</div>
</div>
</li>
{% endfor %}
</ol>
<div class="flex flex-col gap-3 border-t border-base-300 pt-5">
<div class="flex items-center gap-2 text-xs text-base-content/55">
<c-lbi n="rmx.shield-check" w="1em" h="1em" class="size-4 shrink-0" />
<span>SOC 2 Type II. Revenue data never leaves your region.</span>
</div>
<div class="flex items-center gap-2 text-xs text-base-content/55">
<c-lbi n="rmx.time" w="1em" h="1em" class="size-4 shrink-0" />
<span>Median setup: four minutes.</span>
</div>
<div class="flex items-center gap-3 rounded-box bg-base-200/60 p-3 mt-1">
<c-lb.avatar placeholder initials="HI" bgColor="accent" size="sm" />
<div class="text-xs">
<div class="font-medium">Stuck on something?</div>
<div class="text-base-content/50">Hana in onboarding answers in minutes.</div>
</div>
</div>
</div>
</aside>
<c-vars :currencies="[]" />
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Billing</h2>
<p class="text-sm text-base-content/60 mt-1">
Arden reports every figure in one currency, and sends invoices to one person.
Both are easy to change later.
</p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<c-vertical-steps.field label="Reporting currency">
<c-lb.select id="vt-currency" class="w-full" bind="$form.currency">
{% for value, label in currencies %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-vertical-steps.field>
<c-vertical-steps.field label="Billing contact" hint="(where invoices go)">
<c-lb.input
id="vt-contact"
type="email"
placeholder="[email protected]"
class="w-full"
bind="$form.contact" />
</c-vertical-steps.field>
</div>
<div class="rounded-box border border-base-300 divide-y divide-base-300">
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium">Fourteen day trial</div>
<div class="text-xs text-base-content/55 mt-0.5">No card until day fifteen.</div>
</div>
<c-lb.badge variant="success" style="soft" size="sm">Included</c-lb.badge>
</div>
<div class="flex items-center justify-between p-4">
<div>
<div class="text-sm font-medium">Multi-currency customers</div>
<div class="text-xs text-base-content/55 mt-0.5">
Converted daily at the ECB rate, with the original on every invoice.
</div>
</div>
<c-lb.badge style="soft" size="sm">Automatic</c-lb.badge>
</div>
</div>
</div>
<c-vars :roles="[]" />
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Bring the team</h2>
<p class="text-sm text-base-content/60 mt-1">
Invites go out when you finish. Roles can be changed by any admin afterwards.
</p>
</div>
<div class="grid gap-3">
<c-vertical-steps.field label="Teammate">
<c-lb.input
id="vt-invite1"
type="email"
placeholder="[email protected]"
class="w-full"
bind="$form.invite1" />
</c-vertical-steps.field>
<c-vertical-steps.field label="Teammate" hint="(optional)">
<c-lb.input
id="vt-invite2"
type="email"
placeholder="[email protected]"
class="w-full"
bind="$form.invite2" />
</c-vertical-steps.field>
<c-vertical-steps.field label="They join as">
<c-lb.select id="vt-role" class="w-full" bind="$form.role">
{% for value, label in roles %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</c-lb.select>
</c-vertical-steps.field>
</div>
<div class="rounded-box bg-base-200/60 p-4">
<div class="text-xs uppercase tracking-wider text-base-content/40 mb-2">Who usually gets invited first</div>
<div class="flex flex-wrap gap-x-5 gap-y-2 text-xs text-base-content/60">
<span>Head of Finance</span>
<span>Controller</span>
<span>RevOps Manager</span>
<span>Account Executives</span>
</div>
</div>
</div>
<c-vars :regions="[]" />
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Your workspace</h2>
<p class="text-sm text-base-content/60 mt-1">
The name your team signs in to, and the region their revenue data lives in.
</p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<c-vertical-steps.field label="Workspace name">
<c-lb.input
data-lbb-start
id="vt-company"
type="text"
placeholder="Verity Labs"
class="w-full"
bind="$form.company" />
</c-vertical-steps.field>
<c-vertical-steps.field label="Workspace address" hint="(arden.app/…)">
<c-lb.input
id="vt-slug"
type="text"
placeholder="verity-labs"
class="w-full font-mono"
bind="$form.slug" />
</c-vertical-steps.field>
</div>
<div class="flex flex-col gap-2">
<span class="label label-text text-sm font-medium">Data region</span>
{% for region in regions %}
<label class="flex items-start gap-3 rounded-box border border-base-300 p-4 cursor-pointer
transition-colors hover:border-base-content/30
has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<c-lb.radio
name="vt-region"
variant="primary"
value="{{ region.value }}"
data-bind="form.region"
class="mt-0.5" />
<div>
<div class="font-medium text-sm">{{ region.name }}</div>
<div class="text-xs text-base-content/55 mt-0.5">{{ region.note }}</div>
</div>
</label>
{% endfor %}
</div>
</div>
Learn from this block
-
`step` is a client signal and so is the answer bag. The rail on the left and the card on the right both read it — they are two views of one number.
-
Current, done and upcoming come out of a comparison against the step signal. Every colour is a theme token, so the rail reads correctly in any daisyUI theme.
-
Back subtracts one. The panels were never removed from the page and the answers never left the signal bag, so there is nothing to restore and nothing to fetch.
-
A radio group writes one path in the bag. The card styling comes from `has-[:checked]`, so the selected region is obvious without any JavaScript of its own.
Steps with summary
A checkout-shaped setup wizard whose summary panel is derived live from the answers — plan, seats and monthly total recompute from signals with no server round-trip
-
components
-
templates
-
pages
-
<c-lbr.signals id="sw-wizard" :$step=step :$form=form :$plans=plans :$seatprice=seat_price />
<main class="container mx-auto max-w-6xl px-4 py-10 sm:py-14">
<div class="grid lg:grid-cols-[1fr_22rem] gap-8">
<div class="flex flex-col gap-6">
<c-summary-wizard.stepper :steps=steps />
<c-lb.card class="border border-base-300 shadow-sm">
<c-lb.card.body class="gap-7 p-6 sm:p-8">
<div data-show="$step === 1">
<c-summary-wizard.step-workspace />
</div>
<div data-show="$step === 2" style="display:none">
<c-summary-wizard.step-plan :plan_list=plan_list :seat_price=seat_price />
</div>
<div data-show="$step === 3" style="display:none">
<c-summary-wizard.step-billing />
</div>
<c-summary-wizard.nav />
</c-lb.card.body>
</c-lb.card>
</div>
<c-summary-wizard.summary :seat_price=seat_price />
</div>
</main>
<c-vars label="" hint="" class="" />
<div class="{{ class }}">
{% if label %}
<label class="label label-text text-sm font-medium mb-1">
{{ label }}
{% if hint %}<span class="text-base-content/40 font-normal">{{ hint }}</span>{% endif %}
</label>
{% endif %}
{{ slot }}
</div>
<div class="flex items-center justify-between gap-4 border-t border-base-300 pt-5">
<c-lb.button
id="sw-back"
type="button"
btnStyle="ghost"
size="sm"
icon="rmx.arrow-left"
disabled
data-attr:disabled="$step === 1"
data-on:click="$step = Math.max($step - 1, 1)"
>Back</c-lb.button>
<div id="sw-count" class="text-xs text-base-content/50 tabular-nums">
Step <span data-text="$step">1</span> of 3
</div>
<div>
<c-lb.button
id="sw-next"
type="button"
variant="primary"
size="sm"
icon.end="rmx.arrow-right"
data-show="$step < 3"
data-on:click="$step = Math.min($step + 1, 3)"
>Continue</c-lb.button>
<c-lb.button
id="sw-finish"
type="button"
variant="primary"
size="sm"
icon="rmx.check"
data-show="$step === 3"
style="display:none"
>Start the trial</c-lb.button>
</div>
</div>
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Who do we invoice?</h2>
<p class="text-sm text-base-content/60 mt-1">
Nothing is charged during the trial. This is where the first invoice goes when it ends.
</p>
</div>
<div class="grid gap-4">
<c-summary-wizard.field label="Billing email">
<c-lb.input
id="sw-contact"
type="email"
placeholder="[email protected]"
class="w-full"
bind="$form.contact" />
</c-summary-wizard.field>
<c-summary-wizard.field label="Purchase order number" hint="(optional)">
<c-lb.input
id="sw-po"
type="text"
placeholder="PO-2026-114"
class="w-full font-mono"
bind="$form.po" />
</c-summary-wizard.field>
</div>
<div class="rounded-box border border-base-300 divide-y divide-base-300 text-sm">
<div class="flex items-center justify-between p-4">
<span class="text-base-content/60">Trial ends</span>
<span class="tabular-nums font-medium">28 July 2026</span>
</div>
<div class="flex items-center justify-between p-4">
<span class="text-base-content/60">First invoice</span>
<span class="tabular-nums font-medium">29 July 2026</span>
</div>
<div class="flex items-center justify-between p-4">
<span class="text-base-content/60">Payment terms</span>
<span class="font-medium">Net 14</span>
</div>
</div>
</div>
<c-vars :plan_list="[]" :seat_price=12 />
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Plan and seats</h2>
<p class="text-sm text-base-content/60 mt-1">
Seats above the included allowance are ${{ seat_price }} each per month. The total on the
right updates as you go.
</p>
</div>
<div class="flex flex-col gap-2">
{% for plan in plan_list %}
<label class="flex items-start gap-4 rounded-box border border-base-300 p-4 cursor-pointer
transition-colors hover:border-base-content/30
has-[:checked]:border-primary has-[:checked]:bg-primary/5">
<c-lb.radio
name="sw-plan"
variant="primary"
value="{{ plan.key }}"
data-bind="form.plan"
class="mt-1" />
<div class="flex-1">
<div class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<span class="font-semibold text-sm">{{ plan.name }}</span>
{% if plan.featured %}
<c-lb.badge variant="primary" style="soft" size="sm">Most popular</c-lb.badge>
{% endif %}
</div>
<div class="whitespace-nowrap">
<span class="font-semibold tabular-nums">${{ plan.base }}</span>
<span class="text-xs text-base-content/50">/mo</span>
</div>
</div>
<p class="text-xs text-base-content/55 mt-1">{{ plan.tagline }}</p>
<p class="text-xs text-base-content/45 mt-1 tabular-nums">{{ plan.seats }} seats included</p>
</div>
</label>
{% endfor %}
</div>
<c-summary-wizard.field label="Seats you need on day one">
<c-lb.input
id="sw-seats"
type="number"
min="1"
max="200"
class="w-40 tabular-nums"
bind="$form.seats" />
</c-summary-wizard.field>
<div class="rounded-box bg-base-200/60 p-4 text-xs text-base-content/60 leading-relaxed">
Suspended members do not hold a seat and are not billed. Most teams start with the
finance team plus two account executives.
</div>
</div>
<div class="flex flex-col gap-5">
<div>
<h2 class="text-lg font-semibold tracking-tight">Name your workspace</h2>
<p class="text-sm text-base-content/60 mt-1">
Type it and watch the summary fill in. Nothing is sent anywhere yet.
</p>
</div>
<div class="grid gap-4">
<c-summary-wizard.field label="Workspace name">
<c-lb.input
data-lbb-start
id="sw-company"
type="text"
placeholder="Ironvale Group"
class="w-full"
bind="$form.company" />
</c-summary-wizard.field>
<c-summary-wizard.field label="Workspace address" hint="(arden.app/…)">
<c-lb.input
id="sw-slug"
type="text"
placeholder="ironvale-group"
class="w-full font-mono"
bind="$form.slug" />
</c-summary-wizard.field>
</div>
<div class="flex items-start gap-2 text-xs text-base-content/50">
<c-lbi n="rmx.information" w="1em" h="1em" class="size-4 mt-0.5 shrink-0" />
<span>The address is how your team reaches Arden, and it appears on every invoice.</span>
</div>
</div>
<c-vars :steps="[]" />
<div class="flex flex-col gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Set up Arden</h1>
<p class="text-sm text-base-content/60 mt-1">
Three steps, and the price on the right follows what you pick.
</p>
</div>
<div class="flex items-center gap-2">
{% for s in steps %}
<div
id="sw-tab-{{ s.n }}"
class="flex items-center gap-2 rounded-full border border-base-300 px-3 py-1.5"
data-class="{'border-primary bg-primary/5': $step === {{ s.n }}, 'border-base-300': $step !== {{ s.n }}}"
>
<span
class="w-5 h-5 rounded-full text-[11px] font-semibold tabular-nums flex items-center justify-center bg-base-300 text-base-content/50"
data-class="{'bg-primary text-primary-content': $step === {{ s.n }}, 'bg-success text-success-content': $step > {{ s.n }}, 'bg-base-300 text-base-content/50': $step < {{ s.n }}}"
>{{ s.n }}</span>
<span class="text-xs font-medium whitespace-nowrap">{{ s.label }}</span>
</div>
{% if not forloop.last %}
<div class="h-px flex-1 bg-base-300"></div>
{% endif %}
{% endfor %}
</div>
</div>
<c-vars :seat_price=12 />
<aside class="lg:sticky lg:top-8 h-fit flex flex-col gap-4">
<c-lb.card class="border border-base-300 bg-base-200/40">
<c-lb.card.body class="gap-5 p-6">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold uppercase tracking-wider text-base-content/50">
Your Arden plan
</h2>
<c-lb.badge size="sm" style="soft" variant="success">14-day trial</c-lb.badge>
</div>
<div class="flex flex-col gap-1">
<div id="sw-summary-name" class="font-semibold truncate"
data-text="$form.company || 'Untitled workspace'">Untitled workspace</div>
<div id="sw-summary-address" class="text-xs font-mono text-base-content/50 truncate"
data-text="'arden.app/' + ($form.slug || 'your-workspace')">arden.app/your-workspace</div>
</div>
<div class="flex flex-col gap-3 border-t border-base-300 pt-4">
<div class="flex items-start justify-between gap-3">
<div>
<div id="sw-summary-plan" class="text-sm font-medium"
data-text="$plans[$form.plan].name">Arden Growth</div>
<div class="text-xs text-base-content/50">
<span data-text="$plans[$form.plan].seats">10</span> seats included
</div>
</div>
<div class="text-sm tabular-nums whitespace-nowrap">
<span>$</span><span id="sw-summary-base" data-text="$plans[$form.plan].base">149</span>
</div>
</div>
<div
id="sw-summary-extra-row"
class="flex items-start justify-between gap-3"
data-show="Number($form.seats) > $plans[$form.plan].seats"
style="display:none"
>
<div>
<div class="text-sm font-medium">Extra seats</div>
<div class="text-xs text-base-content/50 tabular-nums">
<span id="sw-summary-extra"
data-text="Math.max(0, Number($form.seats) - $plans[$form.plan].seats)">0</span>
× <span>$</span><span data-text="$seatprice">{{ seat_price }}</span> per month
</div>
</div>
<div class="text-sm tabular-nums whitespace-nowrap">
<span>$</span><span id="sw-summary-extra-cost"
data-text="Math.max(0, Number($form.seats) - $plans[$form.plan].seats) * $seatprice">0</span>
</div>
</div>
<div class="flex items-start justify-between gap-3">
<div class="text-sm text-base-content/60">Seats in total</div>
<div id="sw-summary-seats" class="text-sm tabular-nums" data-text="$form.seats">8</div>
</div>
</div>
<div class="flex items-end justify-between border-t border-base-300 pt-4">
<div>
<div class="text-sm font-semibold">Monthly total</div>
<div class="text-xs text-base-content/50">After the trial ends.</div>
</div>
<div class="text-2xl font-bold tabular-nums">
<span class="text-base font-semibold text-base-content/60">$</span><span
id="sw-summary-total"
data-text="$plans[$form.plan].base + Math.max(0, Number($form.seats) - $plans[$form.plan].seats) * $seatprice"
>149</span>
</div>
</div>
<div class="flex items-center gap-2 text-xs text-base-content/50 border-t border-base-300 pt-4">
<c-lbi n="rmx.mail" w="1em" h="1em" class="size-4 shrink-0" />
<span id="sw-summary-contact" class="truncate"
data-text="$form.contact || 'No billing contact yet'">No billing contact yet</span>
</div>
</c-lb.card.body>
</c-lb.card>
<div class="flex items-start gap-3 rounded-box border border-base-300 p-4">
<c-lb.avatar placeholder initials="PR" bgColor="accent" size="sm" />
<p class="text-xs text-base-content/60 leading-relaxed">
“We started on ten seats and were on fourteen by the second month. The bill
changed by forty-eight dollars, not by a sales call.”
<span class="text-base-content/45">— Priya Raghunathan, Finance Lead, Beacon Labs</span>
</p>
</div>
<p class="text-xs text-base-content/45 leading-relaxed">
No card is charged today. Cancel during the trial and the workspace closes with
nothing owed.
</p>
</aside>
Learn from this block
-
`form` holds what the user types, `plans` holds the price list, `seatprice` the per-seat rate. Putting the price list in the signal bag is what lets the summary be an expression instead of an endpoint.
-
The plan name is read out of the price-list signal, keyed by the plan the user chose. Nothing stores it.
-
Base price, plus every seat over the allowance at the seat rate. The server renders the opening figure so the page is correct before any JavaScript runs, and the browser takes it from there.
-
One input, two readers: the step you are on and the panel beside it. That is the whole trick of a checkout-shaped wizard, and it needs no state management.
-
Back changes one number. The summary is unaffected because it never depended on the step — only on the answers, which are still there.