ConnectStep.tsx7.6 KBView on GitHub
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, CheckCircle2, Presentation } from 'lucide-react';
import { GoogleSlidesManager } from '@/modules/integrations/google-slides-manager';
import { LinkedInIntegrationCard } from '@/modules/integrations/linkedin-integration-card';
import { SlackIntegrationCard } from '@/modules/integrations/slack-integration-card';
import { SystemsAndCredentialsSection } from '@/modules/integrations/systems/systems-and-credentials-section';
import { WhatsAppIntegrationCard } from '@/modules/integrations/whatsapp-integration-card';
import { MeetingRecorderOnboardingStep } from '@/modules/onboarding/components/meeting-recorder-step';
import { CrmStep } from '@/modules/onboarding/components/crm-step';
import { authClient } from '@/modules/auth/utils/auth-client';
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { SetupPreviewFrame } from '../components/SetupPreviewFrame';
import { useSetupConnect } from '../hooks/use-setup-connect';
import type { SetupStepProps } from '../types';

interface ProviderPickerProps {
  providers: { id: string; name: string; connected?: boolean }[];
  onSelect: (id: string) => void;
  accentClassName: string;
}

/** Provider cards for the step kinds that offer a choice (meeting recorder, CRM). */
function ProviderPicker({ providers, onSelect, accentClassName }: ProviderPickerProps) {
  return (
    <div className={cn('grid gap-3', providers.length === 1 ? 'grid-cols-1' : 'grid-cols-2')}>
      {providers.map((provider) => (
        <button
          key=[redacted]
          type="button"
          onClick={() => onSelect(provider.id)}
          className="hover:border-foreground/30 flex cursor-pointer flex-col items-center gap-3 rounded-xl border p-6 transition-colors"
        >
          <div
            className={cn(
              'flex h-12 w-12 items-center justify-center rounded-lg text-lg font-bold',
              accentClassName,
            )}
          >
            {provider.name.charAt(0).toUpperCase()}
          </div>
          <div className="text-center">
            <p className="text-sm font-semibold">{provider.name}</p>
            {provider.connected && (
              <p className="mt-1 text-xs font-medium text-emerald-600">Connected</p>
            )}
          </div>
        </button>
      ))}
    </div>
  );
}

/**
 * One integration step.
 *
 * Provider selection is LOCAL to the step rather than hoisted to the flow, which
 * is what the old page did. Hoisting it meant the footer's Back button had two
 * jobs — leave the provider detail, or leave the step — and the same click did
 * different things depending on invisible state.
 */
export function ConnectStepBody({ step }: SetupStepProps) {
  const trpc = useTRPC();
  const { session, integrationsData, meetingProviders, crmProviders } = useSetupConnect();
  const { copiedValue, copyToClipboard } = useCopyToClipboard();
  const [selectedProvider, setSelectedProvider] = useState<string | null>(null);

  const { data: pickerConfig } = useQuery(
    trpc.presentations.getPickerConfig.queryOptions(undefined, { retry: false }),
  );

  const back = (
    <button
      type="button"
      onClick={() => setSelectedProvider(null)}
      className="text-muted-foreground hover:text-foreground mb-4 flex cursor-pointer items-center gap-1 text-sm"
    >
      <ArrowLeft className="h-4 w-4" />
      Back
    </button>
  );

  switch (step.connect) {
    case 'meeting':
      return selectedProvider ? (
        <div>
          {back}
          <MeetingRecorderOnboardingStep
            integrationsData={integrationsData}
            session={session}
            copyToClipboard={copyToClipboard}
            copiedValue={copiedValue}
            selectedProviderId={selectedProvider}
          />
        </div>
      ) : (
        <ProviderPicker
          providers={meetingProviders}
          onSelect={setSelectedProvider}
          accentClassName="bg-blue-50 text-blue-600 dark:bg-blue-900/20"
        />
      );

    case 'crm':
      return selectedProvider ? (
        <div>
          {back}
          <CrmStep
            integrationsData={integrationsData}
            session={session}
            selectedProviderId={selectedProvider}
          />
        </div>
      ) : (
        <ProviderPicker
          providers={crmProviders}
          onSelect={setSelectedProvider}
          accentClassName="bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20"
        />
      );

    case 'slack':
      return <SlackIntegrationCard userId={session?.user?.id} />;

    case 'mcp':
      return <SystemsAndCredentialsSection userId={session?.user?.id} />;

    case 'linkedin':
      return <LinkedInIntegrationCard />;

    case 'whatsapp':
      return <WhatsAppIntegrationCard />;

    case 'drive':
      return pickerConfig?.needsReauth === false ? (
        <div className="space-y-6">
          <div className="flex items-center gap-3">
            <CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-500" />
            <p className="text-sm font-medium">Google Drive access granted</p>
          </div>
          <GoogleSlidesManager />
        </div>
      ) : (
        <div className="flex flex-col items-start gap-4">
          <div className="rounded-full bg-blue-50 p-5 dark:bg-blue-900/20">
            <Presentation className="h-8 w-8 text-blue-600 dark:text-blue-400" />
          </div>
          <Button
            className="cursor-pointer"
            onClick={() =>
              authClient.linkSocial({
                provider: 'google',
                // Comes back into the flow on the Drive step. `drive` names the step
                // rather than a position, so adding a step later cannot land the
                // redirect somewhere else.
                callbackURL: `${window.location.origin}/onboarding?step=connect-drive`,
              })
            }
          >
            Grant access
          </Button>
        </div>
      );

    default:
      return null;
  }
}

/** What is wired up so far — updates as each connection lands. */
export function ConnectStepPreview() {
  const { integrations } = useSetupConnect();
  const connected = integrations.filter((i) => i.connected);

  return (
    <SetupPreviewFrame label="Connected sources">
      <div className="flex h-full flex-col gap-1 overflow-y-auto p-3">
        {integrations.length === 0 ? (
          <p className="text-muted-foreground p-6 text-center text-xs">
            No integrations are available for your organisation.
          </p>
        ) : (
          <>
            <p className="text-muted-foreground px-2 pb-2 text-xs">
              {connected.length} of {integrations.length} connected
            </p>
            {integrations.map((integration) => (
              <div
                key=[redacted]
                className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm"
              >
                <span
                  className={cn(
                    'h-1.5 w-1.5 shrink-0 rounded-full',
                    integration.connected ? 'bg-emerald-500' : 'bg-muted-foreground/30',
                  )}
                />
                <span className="min-w-0 flex-1 truncate">{integration.name}</span>
                {integration.connected && (
                  <CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
                )}
              </div>
            ))}
          </>
        )}
      </div>
    </SetupPreviewFrame>
  );
}