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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions apps/web/src/app/(app)/claw/components/CalendarConnectStep.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
'use client';

import Link from 'next/link';
import { Calendar, Check, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { OnboardingStepView } from './OnboardingStepView';

type CalendarConnectStepViewProps = {
currentStep: number;
totalSteps: number;
connectUrl: string;
isConnected: boolean;
connectedAccountEmail?: string | null;
/**
* Whether the kiloclaw instance row is provisioned. The
* /api/integrations/google/connect route requires an active instance and
* bounces the user out of onboarding when it can't find one, so the Connect
* button stays disabled until the instance row exists.
*/
instanceReady: boolean;
onSkip: () => void;
onContinue: () => void;
onConnectClick?: () => void;
};

const FEATURES: Array<{ included: boolean; title: string; detail: string }> = [
{
included: true,
title: 'Read your calendar events',
detail: 'Titles, attendees, locations, descriptions for the next 14 days.',
},
{
included: true,
title: 'Read calendars you own and subscribe to',
detail: 'Including team calendars shared with you.',
},
{
included: false,
title: 'Create, modify, or delete events',
detail: "We don't request write access.",
},
];

export function CalendarConnectStepView({
currentStep,
totalSteps,
connectUrl,
isConnected,
connectedAccountEmail,
instanceReady,
onSkip,
onContinue,
onConnectClick,
}: CalendarConnectStepViewProps) {
return (
<OnboardingStepView
currentStep={currentStep}
totalSteps={totalSteps}
stepLabel={`Step ${currentStep} of ${totalSteps} · Calendar`}
title="Connect a calendar."
description="This is what day one of your briefing is built from. Read access only, no writes."
showProvisioningBanner
>
<div className="border-border bg-card flex flex-col gap-5 rounded-lg border p-5 sm:p-6">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="border-border flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border">
<Calendar className="h-5 w-5" />
</div>
<div className="flex flex-col gap-0.5">
<h3 className="text-foreground text-base font-semibold">Google Calendar</h3>
<p className="text-muted-foreground text-xs">
{isConnected && connectedAccountEmail
? `Connected as ${connectedAccountEmail}`
: 'via OAuth · read-only'}
</p>
</div>
</div>
<span
className={cn(
'rounded-full border px-2.5 py-0.5 text-[10px] font-semibold tracking-wider uppercase',
isConnected
? 'border-emerald-500/40 text-emerald-500'
: 'border-amber-500/40 text-amber-500'
)}
>
{isConnected ? 'Connected' : 'Recommended'}
</span>
</div>

<div className="flex flex-col gap-3">
{FEATURES.map(feature => (
<div key={feature.title} className="flex items-start gap-3">
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border',
feature.included
? 'border-emerald-500/60 bg-emerald-500/10 text-emerald-500'
: 'border-border text-muted-foreground/60'
)}
>
{feature.included ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
</div>
<div className="flex flex-col gap-0.5">
<p
className={cn(
'text-sm font-medium',
feature.included ? 'text-foreground' : 'text-muted-foreground/70'
)}
>
{feature.title}
</p>
<p className="text-muted-foreground text-xs">{feature.detail}</p>
</div>
</div>
))}
</div>

<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={() => onSkip()}
className="text-muted-foreground hover:text-foreground text-sm font-medium transition-colors"
>
Skip for now
</button>
{isConnected ? (
<Button variant="primary" onClick={() => onContinue()}>
Continue
</Button>
) : instanceReady ? (
<Button asChild variant="primary">
<Link href={connectUrl} onClick={() => onConnectClick?.()}>
Connect Google Calendar
</Link>
</Button>
) : (
<Button variant="primary" disabled>
Setting up your instance…
</Button>
)}
</div>
</div>
</OnboardingStepView>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import { ChannelPairingStepView } from './ChannelPairingStep';
import { ChannelSelectionStepView } from './ChannelSelectionStep';
import { ClawConfigServiceBanner } from './ClawConfigServiceBanner';
import { ClawHeader } from './ClawHeader';
import { CalendarConnectStepView } from './CalendarConnectStep';
import { ClawSetupCompleteStep, ClawSetupErrorStep } from './ClawOnboardingFlow';
import { ProvisioningStepView } from './ProvisioningStep';

const FAKE_STEP_LABELS: Record<ClawOnboardingRenderStep, string> = {
identity: 'Identity',
calendar: 'Calendar',
channels: 'Channels',
provisioning: 'Provisioning',
pairing: 'Pairing',
Expand Down Expand Up @@ -187,6 +189,7 @@ function getFakeStepProgress(
function getFakeOnboardingStep(step: ClawOnboardingRenderStep): OnboardingStep {
switch (step) {
case 'identity':
case 'calendar':
case 'channels':
case 'provisioning':
case 'pairing':
Expand All @@ -208,7 +211,21 @@ function renderFakeStep({
}: RenderFakeStepInput) {
switch (step) {
case 'identity': {
return <BotIdentityStep {...stepProgress} onContinue={() => setStep('channels')} />;
return <BotIdentityStep {...stepProgress} onContinue={() => setStep('calendar')} />;
}
case 'calendar': {
return (
<CalendarConnectStepView
{...stepProgress}
connectUrl="#"
isConnected={false}
connectedAccountEmail={null}
instanceReady
onConnectClick={() => setStep('channels')}
onSkip={() => setStep('channels')}
onContinue={() => setStep('channels')}
/>
);
}
case 'channels': {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ describe('ClawOnboardingFlow state machine', () => {
expect(getClawOnboardingFlowState(createInput({ createSetupStarted: true })).renderStep).toBe(
'identity'
);
expect(
getClawOnboardingFlowState(
createInput({
createSetupStarted: true,
onboardingStep: 'calendar',
hasBotIdentity: true,
})
).renderStep
).toBe('calendar');
expect(
getClawOnboardingFlowState(
createInput({
Expand Down Expand Up @@ -159,60 +168,68 @@ describe('ClawOnboardingFlow state machine', () => {
).toBe('complete');
});

test('uses four steps only when the selected channel requires pairing', () => {
test('uses five steps only when the selected channel requires pairing', () => {
const pairingTelegram = getClawOnboardingFlowState(
createInput({ selectedChannelId: 'telegram' })
);
expect(pairingTelegram.totalSteps).toBe(4);
expect(pairingTelegram.totalSteps).toBe(5);
expect(pairingTelegram.currentStep).toBe(1);

const pairingDiscord = getClawOnboardingFlowState(
createInput({ selectedChannelId: 'discord' })
);
expect(pairingDiscord.totalSteps).toBe(4);
expect(pairingDiscord.totalSteps).toBe(5);
expect(pairingDiscord.currentStep).toBe(1);

const noPairingSlack = getClawOnboardingFlowState(createInput({ selectedChannelId: 'slack' }));
expect(noPairingSlack.totalSteps).toBe(3);
expect(noPairingSlack.totalSteps).toBe(4);
expect(noPairingSlack.currentStep).toBe(1);

const defaultState = getClawOnboardingFlowState(createInput());
expect(defaultState.totalSteps).toBe(3);
expect(defaultState.totalSteps).toBe(4);
expect(defaultState.currentStep).toBe(1);
});

test('getClawOnboardingStepProgress returns correct live current and total steps', () => {
expect(getClawOnboardingStepProgress('identity', false)).toEqual({
currentStep: 1,
totalSteps: 3,
totalSteps: 4,
});
expect(getClawOnboardingStepProgress('channels', false)).toEqual({
expect(getClawOnboardingStepProgress('calendar', false)).toEqual({
currentStep: 2,
totalSteps: 3,
totalSteps: 4,
});
expect(getClawOnboardingStepProgress('provisioning', false)).toEqual({
expect(getClawOnboardingStepProgress('channels', false)).toEqual({
currentStep: 3,
totalSteps: 3,
totalSteps: 4,
});
expect(getClawOnboardingStepProgress('provisioning', false)).toEqual({
currentStep: 4,
totalSteps: 4,
});
expect(getClawOnboardingStepProgress('done', false)).toEqual({ currentStep: 3, totalSteps: 3 });
expect(getClawOnboardingStepProgress('done', false)).toEqual({ currentStep: 4, totalSteps: 4 });

expect(getClawOnboardingStepProgress('identity', true)).toEqual({
currentStep: 1,
totalSteps: 4,
totalSteps: 5,
});
expect(getClawOnboardingStepProgress('channels', true)).toEqual({
expect(getClawOnboardingStepProgress('calendar', true)).toEqual({
currentStep: 2,
totalSteps: 4,
totalSteps: 5,
});
expect(getClawOnboardingStepProgress('provisioning', true)).toEqual({
expect(getClawOnboardingStepProgress('channels', true)).toEqual({
currentStep: 3,
totalSteps: 4,
totalSteps: 5,
});
expect(getClawOnboardingStepProgress('pairing', true)).toEqual({
expect(getClawOnboardingStepProgress('provisioning', true)).toEqual({
currentStep: 4,
totalSteps: 4,
totalSteps: 5,
});
expect(getClawOnboardingStepProgress('pairing', true)).toEqual({
currentStep: 5,
totalSteps: 5,
});
expect(getClawOnboardingStepProgress('done', true)).toEqual({ currentStep: 4, totalSteps: 4 });
expect(getClawOnboardingStepProgress('done', true)).toEqual({ currentStep: 5, totalSteps: 5 });
});

test.each(CLAW_ONBOARDING_PROVISIONING_STATUSES)(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ export type PopulatedClawStatus = KiloClawDashboardStatus & {

export type ClawOnboardingMode = 'create-first' | 'post-provisioning';

export type OnboardingStep = 'identity' | 'channels' | 'provisioning' | 'pairing' | 'done';
export type OnboardingStep =
| 'identity'
| 'calendar'
| 'channels'
| 'provisioning'
| 'pairing'
| 'done';

export const CLAW_ONBOARDING_WIZARD_STEPS = [
'identity',
'calendar',
'channels',
'provisioning',
'pairing',
Expand All @@ -19,6 +26,7 @@ export type ClawOnboardingWizardStep = (typeof CLAW_ONBOARDING_WIZARD_STEPS)[num

export type ClawOnboardingRenderStep =
| 'identity'
| 'calendar'
| 'channels'
| 'provisioning'
| 'pairing'
Expand All @@ -31,6 +39,7 @@ export const FAKE_ONBOARDING_STEP_PARAM = 'fakeOnboardingStep';

export const CLAW_ONBOARDING_FAKE_STEPS = [
'identity',
'calendar',
Comment thread
St0rmz1 marked this conversation as resolved.
'channels',
'provisioning',
'pairing',
Expand Down Expand Up @@ -280,6 +289,13 @@ function getRenderStepDecision({
};
}

if (onboardingStep === 'calendar') {
return {
renderStep: 'calendar',
reason: 'stored onboarding step is calendar',
};
}

if (onboardingStep === 'channels') {
return {
renderStep: 'channels',
Expand Down
Loading