React Integration

Learn how to integrate AI Interview into your React application with a reusable component.

Installation

npm install @ai-interview/sdk
# or
yarn add @ai-interview/sdk

Complete Example Component

import { useEffect, useRef, useState } from 'react';
import InterviewSDK, { InterviewEmbed } from '@ai-interview/sdk';

interface InterviewProps {
  inviteToken: string;
  onCompleted?: (sessionId: string) => void;
  onError?: (error: { message: string; code: string }) => void;
}

export function Interview({ inviteToken, onCompleted, onError }: InterviewProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const embedRef = useRef<InterviewEmbed | null>(null);
  const [status, setStatus] = useState<'loading' | 'ready' | 'in-progress' | 'completed' | 'error'>('loading');
  const [progress, setProgress] = useState(0);
  const [errorMessage, setErrorMessage] = useState<string>('');

  useEffect(() => {
    if (!containerRef.current || embedRef.current) return;

    try {
      // Mount the interview embed
      const embed = InterviewSDK.mount(containerRef.current, {
        invite: inviteToken,
        theme: {
          primary: '#22d3ee',
          background: '#0f172a',
          text: '#e5e7eb',
        },
      });

      embedRef.current = embed;

      // Event handlers
      embed.on('start', () => {
        setStatus('in-progress');
        console.log('Interview started');
      });

      embed.on('pause', () => {
        console.log('Interview paused');
      });

      embed.on('resume', () => {
        console.log('Interview resumed');
      });

      embed.on('progress', (payload) => {
        setProgress(payload.percentage);
      });

      embed.on('completed', (payload) => {
        setStatus('completed');
        console.log('Interview completed:', payload.sessionId);
        onCompleted?.(payload.sessionId);
      });

      embed.on('error', (payload) => {
        setStatus('error');
        setErrorMessage(payload.message);
        console.error('Interview error:', payload);
        onError?.(payload);
      });

      setStatus('ready');

    } catch (error) {
      setStatus('error');
      setErrorMessage(error instanceof Error ? error.message : 'Failed to load interview');
      console.error('Mount error:', error);
    }

    // Cleanup
    return () => {
      if (embedRef.current) {
        embedRef.current.destroy();
        embedRef.current = null;
      }
    };
  }, [inviteToken, onCompleted, onError]);

  return (
    <div className="interview-wrapper">
      <div ref={containerRef} className="interview-container" />
      
      {status === 'in-progress' && progress > 0 && (
        <div className="progress-bar">
          <div className="progress-fill" style={{ width: `${progress}%` }} />
          <span className="progress-text">{Math.round(progress)}% complete</span>
        </div>
      )}

      {status === 'completed' && (
        <div className="status-message success">
          ✓ Thank you! Your interview has been completed.
        </div>
      )}

      {status === 'error' && (
        <div className="status-message error">
          ✗ Error: {errorMessage}
        </div>
      )}
      
      <style jsx>{`
        .interview-wrapper {
          width: 100%;
          max-width: 900px;
          margin: 0 auto;
        }

        .interview-container {
          width: 100%;
          height: 600px;
          background: #1e293b;
          border-radius: 12px;
          overflow: hidden;
          box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
        }

        .progress-bar {
          margin-top: 20px;
          height: 40px;
          background: #1e293b;
          border-radius: 8px;
          position: relative;
          overflow: hidden;
        }

        .progress-fill {
          height: 100%;
          background: linear-gradient(90deg, #22d3ee, #06b6d4);
          transition: width 0.3s ease;
        }

        .progress-text {
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          color: #e5e7eb;
          font-weight: 600;
        }

        .status-message {
          margin-top: 20px;
          padding: 15px;
          border-radius: 8px;
          text-align: center;
          font-weight: 500;
        }

        .status-message.success {
          background: #10b981;
          color: white;
        }

        .status-message.error {
          background: #ef4444;
          color: white;
        }

        @media (max-width: 768px) {
          .interview-container {
            height: 500px;
          }
        }
      `}</style>
    </div>
  );
}

Usage in Your App

import { Interview } from './components/Interview';

function InterviewPage() {
  const handleCompleted = (sessionId: string) => {
    console.log('Interview completed:', sessionId);
    // Navigate to thank you page
    // router.push('/thank-you');
  };

  const handleError = (error: { message: string; code: string }) => {
    console.error('Interview error:', error);
    // Show error notification
  };

  return (
    <div className="page">
      <h1>Customer Feedback Interview</h1>
      <p>Share your thoughts in a quick 5-minute voice interview</p>
      
      <Interview
        inviteToken="inv_abc123"
        onCompleted={handleCompleted}
        onError={handleError}
      />
    </div>
  );
}

With React Router

import { useParams } from 'react-router-dom';
import { Interview } from './components/Interview';

function InterviewPage() {
  const { token } = useParams<{ token: string }>();

  if (!token) {
    return <div>Invalid interview link</div>;
  }

  return <Interview inviteToken={token} />;
}

// Route configuration
<Route path="/interview/:token" element={<InterviewPage />} />

With Next.js

// pages/interview/[token].tsx
import { useRouter } from 'next/router';
import { Interview } from '@/components/Interview';

export default function InterviewPage() {
  const router = useRouter();
  const { token } = router.query;

  if (!token || typeof token !== 'string') {
    return <div>Loading...</div>;
  }

  const handleCompleted = (sessionId: string) => {
    router.push('/thank-you');
  };

  return (
    <div>
      <h1>AI Interview</h1>
      <Interview 
        inviteToken={token}
        onCompleted={handleCompleted}
      />
    </div>
  );
}

Fetching Tokens Dynamically

import { useState, useEffect } from 'react';
import { Interview } from './components/Interview';

function DynamicInterview({ userId }: { userId: string }) {
  const [token, setToken] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchToken() {
      try {
        const response = await fetch('/api/create-interview', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ userId }),
        });

        if (!response.ok) {
          throw new Error('Failed to create interview');
        }

        const data = await response.json();
        setToken(data.inviteToken);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'An error occurred');
      } finally {
        setLoading(false);
      }
    }

    fetchToken();
  }, [userId]);

  if (loading) return <div>Loading interview...</div>;
  if (error) return <div>Error: {error}</div>;
  if (!token) return <div>No interview available</div>;

  return <Interview inviteToken={token} />;
}

TypeScript Types

// types/interview.ts
export interface InterviewEmbedOptions {
  invite: string;
  baseUrl?: string;
  theme?: {
    primary?: string;
    background?: string;
    text?: string;
    borderRadius?: string;
    fontFamily?: string;
  };
  locale?: string;
  autoStart?: boolean;
}

export interface InterviewCompletedPayload {
  sessionId: string;
  transcript?: string;
}

export interface InterviewErrorPayload {
  message: string;
  code: string;
}

export interface InterviewProgressPayload {
  percentage: number;
}

Custom Hooks

Create a reusable hook:

// hooks/useInterview.ts
import { useEffect, useRef, useState } from 'react';
import InterviewSDK, { InterviewEmbed, InterviewEmbedOptions } from '@ai-interview/sdk';

export function useInterview(
  containerRef: React.RefObject<HTMLElement>,
  options: InterviewEmbedOptions
) {
  const embedRef = useRef<InterviewEmbed | null>(null);
  const [status, setStatus] = useState<'loading' | 'ready' | 'in-progress' | 'completed' | 'error'>('loading');
  const [progress, setProgress] = useState(0);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!containerRef.current || embedRef.current) return;

    try {
      const embed = InterviewSDK.mount(containerRef.current, options);
      embedRef.current = embed;

      embed.on('start', () => setStatus('in-progress'));
      embed.on('progress', (p) => setProgress(p.percentage));
      embed.on('completed', () => setStatus('completed'));
      embed.on('error', (e) => {
        setStatus('error');
        setError(e.message);
      });

      setStatus('ready');
    } catch (err) {
      setStatus('error');
      setError(err instanceof Error ? err.message : 'Unknown error');
    }

    return () => {
      if (embedRef.current) {
        embedRef.current.destroy();
        embedRef.current = null;
      }
    };
  }, [containerRef, options]);

  return { status, progress, error, embed: embedRef.current };
}

// Usage
function InterviewComponent({ token }: { token: string }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const { status, progress, error } = useInterview(containerRef, {
    invite: token,
  });

  return (
    <div>
      <div ref={containerRef} style={{ height: '600px' }} />
      {status === 'in-progress' && <p>Progress: {progress}%</p>}
      {error && <p>Error: {error}</p>}
    </div>
  );
}

Best Practices

1. Error Boundaries

Wrap the interview component in an error boundary:

import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error }: { error: Error }) {
  return (
    <div className="error-fallback">
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={() => window.location.reload()}>
        Try Again
      </button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <Interview inviteToken={token} />
    </ErrorBoundary>
  );
}

2. Loading States

Show proper loading states:

{status === 'loading' && (
  <div className="loading-spinner">
    Loading interview...
  </div>
)}

3. Cleanup on Unmount

Always clean up the embed on component unmount to prevent memory leaks:

useEffect(() => {
  // ... mount logic

  return () => {
    if (embedRef.current) {
      embedRef.current.destroy();
    }
  };
}, []);

Additional Resources