From f8d50ec15c7f779a77754d73ae9fa144c652d0f3 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Fri, 26 Jun 2026 11:29:58 +0200 Subject: [PATCH] feat(web): refresh enterprise organization onboarding --- .../CreateOrganizationPage.stories.tsx | 21 +- apps/web/src/components/AnimatedKiloLogo.tsx | 8 +- .../new/CreateOrganizationPage.tsx | 443 +++++++++++------- 3 files changed, 285 insertions(+), 187 deletions(-) diff --git a/apps/storybook/stories/CreateOrganizationPage.stories.tsx b/apps/storybook/stories/CreateOrganizationPage.stories.tsx index 251fcdb699..a23ca425c0 100644 --- a/apps/storybook/stories/CreateOrganizationPage.stories.tsx +++ b/apps/storybook/stories/CreateOrganizationPage.stories.tsx @@ -16,7 +16,7 @@ const meta: Meta = { }, decorators: [ Story => ( -
+
), @@ -26,8 +26,23 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Default: Story = { +export const Empty: Story = { + globals: { + viewport: { value: 'desktop', isRotated: false }, + }, +}; + +export const Prefilled: Story = { args: { - mockSelectedOrgName: 'Acme Corp', + initialOrganizationName: 'Acme Engineering', + }, + globals: { + viewport: { value: 'desktop', isRotated: false }, + }, +}; + +export const Mobile: Story = { + globals: { + viewport: { value: 'mobile2', isRotated: false }, }, }; diff --git a/apps/web/src/components/AnimatedKiloLogo.tsx b/apps/web/src/components/AnimatedKiloLogo.tsx index 5c2315fd08..7d23e3b78d 100644 --- a/apps/web/src/components/AnimatedKiloLogo.tsx +++ b/apps/web/src/components/AnimatedKiloLogo.tsx @@ -2,6 +2,10 @@ import { DotLottieReact } from '@lottiefiles/dotlottie-react'; -export default function AnimatedKiloLogo() { - return ; +type AnimatedKiloLogoProps = { + loop?: boolean; +}; + +export default function AnimatedKiloLogo({ loop = true }: AnimatedKiloLogoProps) { + return ; } diff --git a/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx b/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx index a7f60ac6ee..0e938cdb50 100644 --- a/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx +++ b/apps/web/src/components/organizations/new/CreateOrganizationPage.tsx @@ -1,40 +1,34 @@ 'use client'; +import { useRef, useState, type FormEvent } from 'react'; +import Link from 'next/link'; import * as z from 'zod'; -import { useState, useRef } from 'react'; -import { Card, CardContent } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; import { AlertCircle, ArrowRight, - Clock, - TrendingUp, - ArrowLeftRight, - CreditCard, - Key, - Users, - Filter, - FileText, - KeyRound, - Star, - Coins, + ChartNoAxesCombined, + GitBranch, + LoaderCircle, + ShieldCheck, type LucideIcon, } from 'lucide-react'; -import { OrganizationNameSchema } from '@/lib/organizations/organization-types'; -import { motion, AnimatePresence } from 'motion/react'; import { useCreateOrganization } from '@/app/api/organizations/hooks'; -import { SubscriptionsSeatQuantitySchema } from '@/app/payments/subscriptions/types'; -import Link from 'next/link'; +import AnimatedKiloLogo from '@/components/AnimatedKiloLogo'; +import { PageContainer } from '@/components/layouts/PageContainer'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { CompanyDomainSchema } from '@/lib/organizations/company-domain'; +import { OrganizationNameSchema } from '@/lib/organizations/organization-types'; const CreateOrganizationSchema = z.object({ organizationName: OrganizationNameSchema, - seats: SubscriptionsSeatQuantitySchema, + companyDomain: CompanyDomainSchema, }); -type CreateOrganizationForm = z.infer; - -const DEFAULT_ERROR = 'Failed to create organization. Please try again.'; +const DEFAULT_ERROR = "We couldn't create the organization. Check your connection and try again."; function extractErrorMessage(error: unknown): string { if (!(error instanceof Error)) return DEFAULT_ERROR; @@ -52,215 +46,300 @@ function extractErrorMessage(error: unknown): string { } } } catch { - // not JSON — use raw message + return error.message || DEFAULT_ERROR; } return error.message || DEFAULT_ERROR; } type CreateOrganizationPageProps = { - mockSelectedOrgName?: string; // For Storybook + initialOrganizationName?: string; +}; + +type FormErrors = { + organizationName?: string; + companyDomain?: string; + general?: string; }; -type FeatureItem = { - text: string; +type EnterpriseValue = { + title: string; + description: string; icon: LucideIcon; }; -const enterpriseTrialFeatures: FeatureItem[] = [ - { text: 'Usage analytics & reporting', icon: Clock }, - { text: 'AI adoption score', icon: TrendingUp }, - { text: 'Shared agent modes', icon: ArrowLeftRight }, - { text: 'Centralized billing', icon: CreditCard }, - { text: 'Shared BYOK', icon: Key }, - { text: 'Team management', icon: Users }, - { text: 'Limit models and/or providers', icon: Filter }, - { text: 'Audit logs', icon: FileText }, - { text: 'SSO, OIDC, & SCIM support', icon: KeyRound }, - { text: 'Priority support', icon: Star }, +const enterpriseValues: EnterpriseValue[] = [ + { + title: 'Choose without lock-in', + description: + 'Use an inspectable open-source harness across your workflows, with 300+ models, automated routing, BYOK, and private inference.', + icon: GitBranch, + }, + { + title: 'Govern every team', + description: + 'Apply shared identity, role-based access, model and provider controls, and audit logs from one control plane.', + icon: ShieldCheck, + }, + { + title: 'Make spend visible', + description: + 'Track adoption and usage by team, model, and provider with centralized billing and shared configuration.', + icon: ChartNoAxesCombined, + }, ]; -export function CreateOrganizationPage({ mockSelectedOrgName }: CreateOrganizationPageProps = {}) { - const [name, setName] = useState(mockSelectedOrgName || ''); +export function CreateOrganizationPage({ + initialOrganizationName = '', +}: CreateOrganizationPageProps = {}) { + const [organizationName, setOrganizationName] = useState(initialOrganizationName); const [companyDomain, setCompanyDomain] = useState(''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errors, setErrors] = useState<{ - name?: string; - general?: string; - }>({}); - const nameInputRef = useRef(null); - + const [errors, setErrors] = useState({}); + const organizationNameInputRef = useRef(null); + const companyDomainInputRef = useRef(null); const createOrganizationMutation = useCreateOrganization(); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setErrors({}); - setIsSubmitting(true); + const validateOrganizationName = () => { + const result = OrganizationNameSchema.safeParse(organizationName); + setErrors(current => ({ + ...current, + organizationName: result.success ? undefined : result.error.issues[0]?.message, + })); + }; + + const validateCompanyDomain = () => { + const result = CompanyDomainSchema.safeParse(companyDomain); + setErrors(current => ({ + ...current, + companyDomain: result.success ? undefined : result.error.issues[0]?.message, + })); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (createOrganizationMutation.isPending) return; - const formData: CreateOrganizationForm = { - organizationName: name, - seats: 1, - }; - const validationResult = CreateOrganizationSchema.safeParse(formData); + const validationResult = CreateOrganizationSchema.safeParse({ + organizationName, + companyDomain, + }); if (!validationResult.success) { - const fieldErrors: typeof errors = {}; - validationResult.error.issues.forEach(issue => { - const field = issue.path[0] as keyof typeof errors; - if (field && field !== 'general') { - fieldErrors[field] = issue.message; + const fieldErrors: FormErrors = {}; + for (const issue of validationResult.error.issues) { + if (issue.path[0] === 'organizationName' && !fieldErrors.organizationName) { + fieldErrors.organizationName = issue.message; } - }); + if (issue.path[0] === 'companyDomain' && !fieldErrors.companyDomain) { + fieldErrors.companyDomain = issue.message; + } + } setErrors(fieldErrors); - setIsSubmitting(false); + + if (fieldErrors.organizationName) { + organizationNameInputRef.current?.focus(); + } else if (fieldErrors.companyDomain) { + companyDomainInputRef.current?.focus(); + } return; } + setErrors({}); + try { - const orgId = ( - await createOrganizationMutation.mutateAsync({ - name: validationResult.data.organizationName, - autoAddCreator: true, - company_domain: companyDomain.trim() || undefined, - }) - ).organization.id; + const result = await createOrganizationMutation.mutateAsync({ + name: validationResult.data.organizationName, + autoAddCreator: true, + company_domain: validationResult.data.companyDomain ?? undefined, + }); - // Redirect with query param that will force users to invite a single user. - window.location.href = `/organizations/${orgId}/welcome?firstTime=1`; + window.location.href = `/organizations/${result.organization.id}/welcome?firstTime=1`; } catch (error) { console.error('Failed to create organization:', error); - setErrors({ - general: extractErrorMessage(error), - }); - setIsSubmitting(false); + setErrors({ general: extractErrorMessage(error) }); } }; - const isFormValid = name.trim().length > 0; + const isSubmitting = createOrganizationMutation.isPending; return ( -
-
-

- Create an organization and start your -
- 14-day free trial for Kilo Enterprise -

+ +
+ + Kilo +
+ +
+

Kilo Enterprise

+

Agentic engineering, on your terms

+

+ Give every engineering team the interfaces and models that fit their work, while leaders + keep shared security, visibility, and spend controls. +

- Or continue as individual instead. + Continue with an individual account -
+ -
- - -

What your Kilo Enterprise trial includes:

-
    - {enterpriseTrialFeatures.map((feature, index) => { - const Icon = feature.icon; - return ( -
  • - - {feature.text} -
  • - ); - })} -
  • - - - Pay as you go usage based pricing, no tokens included in trial - -
  • -
-
-
+
+
+
+

Built for enterprise engineering

+

+ Choice where it matters. Control where it counts. +

+

+ Standardize how agentic work is managed without forcing every team onto one tool, + model, or provider. +

+
-
- - -
- - {errors.general && ( - - - {errors.general} - - )} - +
    + {enterpriseValues.map(value => { + const Icon = value.icon; + return ( +
  • +
    +
    +
    +

    {value.title}

    +

    + {value.description} +

    +
    +
  • + ); + })} +
+
+ + +

14-day Enterprise trial

+

Set up your organization

+

+ No credit card required. You can invite your team after creating the organization. +

+
+ + + {errors.general ? ( + + + ) : null} + +
+ ) => setName(e.target.value)} - placeholder="Company Name" - className={`h-12 text-center text-lg transition-all duration-200 focus:ring-2 ${ - errors.name ? 'border-red-400 focus:ring-red-400/20' : 'focus:ring-blue-500/20' - }`} - autoFocus + ref={organizationNameInputRef} + id="organization-name" + name="organizationName" + value={organizationName} + onChange={event => setOrganizationName(event.target.value)} + onBlur={validateOrganizationName} + autoComplete="organization" + maxLength={100} + required + disabled={isSubmitting} + aria-invalid={errors.organizationName ? true : undefined} + aria-describedby={errors.organizationName ? 'organization-name-error' : undefined} + placeholder="Acme Engineering" /> + {errors.organizationName ? ( +

+ {errors.organizationName} +

+ ) : null} +
+ +
+ ) => - setCompanyDomain(e.target.value) + onChange={event => setCompanyDomain(event.target.value)} + onBlur={validateCompanyDomain} + inputMode="url" + autoComplete="url" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + disabled={isSubmitting} + aria-invalid={errors.companyDomain ? true : undefined} + aria-describedby={ + errors.companyDomain + ? 'company-domain-help company-domain-error' + : 'company-domain-help' } - placeholder="Company Website (e.g. acme.com)" - className={`h-12 text-center text-lg transition-all duration-200 focus:ring-2 focus:ring-blue-500/20`} + placeholder="acme.com" /> - - {errors.name && ( - - - {errors.name} - - )} - - - - +

+ Enter a domain such as acme.com. +

+ {errors.companyDomain ? ( +

+ {errors.companyDomain} +

+ ) : null} +
- - - -

- (after the trial pricing for Kilo Teams starts at $15 per user/month, billed - annually. You can choose your tier at the end of your trial) -

-
-
-
+ + +
-
+ ); }