1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🔄💬 React×TypeScriptで作るカスタムチャットウィジェット - 実装からVercelデプロイまで

Posted at

こんにちは、株式会社プロドウガ@YushiYamamotoです!

今回は、外部サービスに依存せずに自社専用のチャットウィジェットを開発する方法について解説します。React と TypeScript を活用して、高品質なチャットインターフェースを実装し、最終的に Vercel にデプロイするまでの全工程を紹介します。

初心者の方にも分かりやすく、かつ実務で使える専門的な知識を盛り込みましたので、ぜひ最後までお付き合いください!

🌟 なぜカスタムチャットウィジェットが必要なのか

多くのウェブサイトでは既存のチャットサービス(Intercom、Drift など)を利用していますが、以下のような理由から自社専用のチャットウィジェットを開発するケースが増えています:

  • 自社ブランディングに合わせたUIカスタマイズ
  • 特定の業務フローに最適化したチャット体験
  • 社内システムとの緊密な連携
  • データプライバシーの強化
  • 外部サービスのサブスクリプション費用削減

では、実際に React と TypeScript を使って、どのようにカスタムチャットウィジェットを開発するのか見ていきましょう!

🛠 開発環境のセットアップ

まずは必要なツールとライブラリをインストールします。ターミナルで以下のコマンドを実行してプロジェクトを作成しましょう。

# Create React App を使ってTypeScriptプロジェクトを作成
npx create-react-app chat-widget --template typescript

# プロジェクトディレクトリに移動
cd chat-widget

# 必要なパッケージをインストール
npm install styled-components @types/styled-components framer-motion

📝 プロジェクト構造の設計

チャットウィジェットを効率的に開発するために、以下のようなフォルダ構造を採用します:

/src
  /components
    /ChatWidget
      ChatWidget.tsx       # メインコンポーネント
      ChatHeader.tsx       # ヘッダー部分
      ChatMessages.tsx     # メッセージ表示部分
      ChatInput.tsx        # 入力フォーム部分
      ChatButton.tsx       # チャット起動ボタン
      index.tsx            # エクスポート用
  /hooks
    useChat.ts             # チャットロジック用カスタムフック
    useLocalStorage.ts     # ローカルストレージ用フック
  /types
    index.ts               # TypeScript型定義
  /utils
    animations.ts          # アニメーション定義
    formatters.ts          # 日付などのフォーマット関数
  /styles
    theme.ts               # テーマ設定
    globalStyles.ts        # グローバルスタイル
  index.tsx                # エントリーポイント

この構造により、責務が明確に分離され、メンテナンスやコードの再利用が容易になります。

💻 基本的なコンポーネント実装

まずは、チャットウィジェットの基本的なコンポーネント構造を実装していきましょう。

型定義(/src/types/index.ts)

// チャットメッセージの型定義
export interface ChatMessage {
  id: string;
  content: string;
  sender: 'user' | 'bot';
  timestamp: number;
}

// チャットの状態を表す型定義
export interface ChatState {
  isOpen: boolean;
  isMinimized: boolean;
  messages: ChatMessage[];
}

// チャットウィジェットのプロパティ型定義
export interface ChatWidgetProps {
  title?: string;
  subtitle?: string;
  primaryColor?: string;
  secondaryColor?: string;
  position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
  welcomeMessage?: string;
  placeholder?: string;
  onSendMessage?: (message: string) => Promise<void>;
  onClose?: () => void;
}

チャットカスタムフック(/src/hooks/useChat.ts)

import { useState, useCallback, useEffect } from 'react';
import { ChatState, ChatMessage } from '../types';
import { useLocalStorage } from './useLocalStorage';

export const useChat = (initialWelcomeMessage?: string) => {
  // ローカルストレージからチャット状態を復元、なければ初期状態を使用
  const [chatState, setChatState] = useLocalStorage<ChatState>('chat_state', {
    isOpen: false,
    isMinimized: false,
    messages: [],
  });

  // チャットを開く処理
  const openChat = useCallback(() => {
    setChatState(prev => ({
      ...prev,
      isOpen: true,
      isMinimized: false
    }));
  }, [setChatState]);

  // チャットを閉じる処理
  const closeChat = useCallback(() => {
    setChatState(prev => ({
      ...prev,
      isOpen: false
    }));
  }, [setChatState]);

  // チャットを最小化する処理
  const minimizeChat = useCallback(() => {
    setChatState(prev => ({
      ...prev,
      isMinimized: true
    }));
  }, [setChatState]);

  // チャットを最大化する処理
  const maximizeChat = useCallback(() => {
    setChatState(prev => ({
      ...prev,
      isMinimized: false
    }));
  }, [setChatState]);

  // メッセージを追加する処理
  const addMessage = useCallback((content: string, sender: 'user' | 'bot') => {
    const newMessage: ChatMessage = {
      id: Date.now().toString(),
      content,
      sender,
      timestamp: Date.now()
    };

    setChatState(prev => ({
      ...prev,
      messages: [...prev.messages, newMessage]
    }));
  }, [setChatState]);

  // ユーザーメッセージを送信する処理
  const sendMessage = useCallback((content: string) => {
    if (!content.trim()) return;
    
    // ユーザーメッセージを追加
    addMessage(content, 'user');
    
    // ここで外部APIとの連携やボットの応答などを実装
    // サンプルとして即時に自動応答
    setTimeout(() => {
      addMessage(`「${content}」についてのお問い合わせを受け付けました。担当者から回答があるまでお待ちください。`, 'bot');
    }, 1000);
  }, [addMessage]);

  // 初回マウント時にウェルカムメッセージを表示
  useEffect(() => {
    if (chatState.messages.length === 0 && initialWelcomeMessage) {
      addMessage(initialWelcomeMessage, 'bot');
    }
  }, [initialWelcomeMessage, addMessage, chatState.messages.length]);

  return {
    chatState,
    openChat,
    closeChat,
    minimizeChat,
    maximizeChat,
    sendMessage
  };
};

ローカルストレージフック(/src/hooks/useLocalStorage.ts)

import { useState, useEffect } from 'react';

export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
  // 状態の初期化
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }

    try {
      // ローカルストレージから値を取得
      const item = window.localStorage.getItem(key);
      // 保存されたJSON文字列をパースして返すか、初期値を使用
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // 値を更新する関数
  const setValue = (value: T | ((val: T) => T)) => {
    try {
      // 新しい値を状態に設定
      const valueToStore =
        value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);

      // ローカルストレージにも保存
      if (typeof window !== 'undefined') {
        window.localStorage.setItem(key, JSON.stringify(valueToStore));
      }
    } catch (error) {
      console.error(`Error setting localStorage key "${key}":`, error);
    }
  };

  // ウィンドウのストレージイベントをリッスン(他のタブでの変更を検知)
  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key && event.newValue) {
        setStoredValue(JSON.parse(event.newValue));
      }
    };

    // イベントリスナーを追加
    window.addEventListener('storage', handleStorageChange);
    
    // クリーンアップ
    return () => {
      window.removeEventListener('storage', handleStorageChange);
    };
  }, [key]);

  return [storedValue, setValue];
}

チャットウィジェットのメインコンポーネント(/src/components/ChatWidget/ChatWidget.tsx)

import React, { useRef, useEffect } from 'react';
import styled from 'styled-components';
import { motion, AnimatePresence } from 'framer-motion';
import { ChatWidgetProps } from '../../types';
import { useChat } from '../../hooks/useChat';
import ChatHeader from './ChatHeader';
import ChatMessages from './ChatMessages';
import ChatInput from './ChatInput';
import ChatButton from './ChatButton';

// スタイル定義
const WidgetContainer = styled(motion.div)<{ position: string }>`
  position: fixed;
  width: 350px;
  height: 500px;
  background-color: white;
  border-radius: 12px;
  box-shadow: 0 5px 40px rgba(0, 0, 0, 0.16);
  display: flex;
  flex-direction: column;
  overflow: hidden;
  z-index: 1000;
  
  ${({ position }) => {
    switch (position) {
      case 'bottom-right':
        return `
          bottom: 20px;
          right: 20px;
        `;
      case 'bottom-left':
        return `
          bottom: 20px;
          left: 20px;
        `;
      case 'top-right':
        return `
          top: 20px;
          right: 20px;
        `;
      case 'top-left':
        return `
          top: 20px;
          left: 20px;
        `;
      default:
        return `
          bottom: 20px;
          right: 20px;
        `;
    }
  }}
  
  @media (max-width: 768px) {
    width: calc(100% - 40px);
    max-width: 350px;
    height: 450px;
  }
`;

const MinimizedContainer = styled(motion.div)<{ position: string }>`
  position: fixed;
  background-color: white;
  border-radius: 12px;
  box-shadow: 0 5px 40px rgba(0, 0, 0, 0.16);
  display: flex;
  height: 60px;
  padding: 0 15px;
  align-items: center;
  z-index: 1000;
  cursor: pointer;
  
  ${({ position }) => {
    switch (position) {
      case 'bottom-right':
        return `
          bottom: 20px;
          right: 20px;
        `;
      case 'bottom-left':
        return `
          bottom: 20px;
          left: 20px;
        `;
      case 'top-right':
        return `
          top: 20px;
          right: 20px;
        `;
      case 'top-left':
        return `
          top: 20px;
          left: 20px;
        `;
      default:
        return `
          bottom: 20px;
          right: 20px;
        `;
    }
  }}
`;

const ChatWidget: React.FC<ChatWidgetProps> = ({
  title = 'カスタマーサポート',
  subtitle = 'お気軽にお問い合わせください',
  primaryColor = '#4a6cf7',
  secondaryColor = '#f5f5f5',
  position = 'bottom-right',
  welcomeMessage = 'こんにちは!どのようなことでお困りですか?',
  placeholder = 'メッセージを入力...',
  onSendMessage,
  onClose
}) => {
  const messagesEndRef = useRef<HTMLDivElement>(null);
  const { 
    chatState, 
    openChat, 
    closeChat, 
    minimizeChat, 
    maximizeChat, 
    sendMessage 
  } = useChat(welcomeMessage);

  // メッセージが追加されたら自動スクロール
  useEffect(() => {
    if (messagesEndRef.current && chatState.isOpen && !chatState.isMinimized) {
      messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
    }
  }, [chatState.messages, chatState.isOpen, chatState.isMinimized]);

  // メッセージ送信処理
  const handleSendMessage = async (message: string) => {
    sendMessage(message);
    if (onSendMessage) {
      await onSendMessage(message);
    }
  };

  // チャット閉じる処理
  const handleClose = () => {
    closeChat();
    if (onClose) {
      onClose();
    }
  };

  // アニメーション定義
  const widgetVariants = {
    hidden: { opacity: 0, y: 20 },
    visible: { opacity: 1, y: 0, transition: { duration: 0.3 } }
  };

  return (
    <>
      {/* チャットボタン */}
      {!chatState.isOpen && (
        <ChatButton 
          onClick={openChat} 
          primaryColor={primaryColor} 
          position={position}
        />
      )}

      {/* 最小化されたチャットウィジェット */}
      <AnimatePresence>
        {chatState.isOpen && chatState.isMinimized && (
          <MinimizedContainer
            position={position}
            initial="hidden"
            animate="visible"
            exit="hidden"
            variants={widgetVariants}
            onClick={maximizeChat}
          >
            <span>{title}</span>
          </MinimizedContainer>
        )}
      </AnimatePresence>

      {/* 展開されたチャットウィジェット */}
      <AnimatePresence>
        {chatState.isOpen && !chatState.isMinimized && (
          <WidgetContainer
            position={position}
            initial="hidden"
            animate="visible"
            exit="hidden"
            variants={widgetVariants}
          >
            <ChatHeader 
              title={title}
              subtitle={subtitle}
              primaryColor={primaryColor}
              onMinimize={minimizeChat}
              onClose={handleClose}
            />
            
            <ChatMessages 
              messages={chatState.messages}
              primaryColor={primaryColor}
              secondaryColor={secondaryColor}
              messagesEndRef={messagesEndRef}
            />
            
            <ChatInput 
              onSendMessage={handleSendMessage}
              primaryColor={primaryColor}
              placeholder={placeholder}
            />
          </WidgetContainer>
        )}
      </AnimatePresence>
    </>
  );
};

export default ChatWidget;

チャットボタン(/src/components/ChatWidget/ChatButton.tsx)

import React from 'react';
import styled from 'styled-components';
import { motion } from 'framer-motion';

interface ChatButtonProps {
  onClick: () => void;
  primaryColor: string;
  position: string;
}

const ButtonContainer = styled(motion.button)<{ primaryColor: string; position: string }>`
  position: fixed;
  width: 60px;
  height: 60px;
  border-radius: 30px;
  background-color: ${props => props.primaryColor};
  color: white;
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
  z-index: 1000;
  
  ${({ position }) => {
    switch (position) {
      case 'bottom-right':
        return `
          bottom: 20px;
          right: 20px;
        `;
      case 'bottom-left':
        return `
          bottom: 20px;
          left: 20px;
        `;
      case 'top-right':
        return `
          top: 20px;
          right: 20px;
        `;
      case 'top-left':
        return `
          top: 20px;
          left: 20px;
        `;
      default:
        return `
          bottom: 20px;
          right: 20px;
        `;
    }
  }}
  
  &:hover {
    transform: scale(1.05);
  }
  
  &:focus {
    outline: none;
    box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
  }
`;

const ChatIcon = () => (
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path 
      d="M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z" 
      fill="white" 
    />
  </svg>
);

const ChatButton: React.FC<ChatButtonProps> = ({ onClick, primaryColor, position }) => {
  // アニメーション設定
  const buttonVariants = {
    initial: { scale: 0 },
    animate: { scale: 1, transition: { type: "spring", stiffness: 260, damping: 20 } },
    whileHover: { scale: 1.1 },
    whileTap: { scale: 0.9 }
  };

  return (
    <ButtonContainer
      primaryColor={primaryColor}
      position={position}
      onClick={onClick}
      initial="initial"
      animate="animate"
      whileHover="whileHover"
      whileTap="whileTap"
      variants={buttonVariants}
      aria-label="チャットを開く"
    >
      <ChatIcon />
    </ButtonContainer>
  );
};

export default ChatButton;

🔄 コンポーネント間のデータフロー

ウィジェットのコンポーネント構造とデータの流れを理解するために、以下の図で視覚化しましょう。

📱 レスポンシブデザインの実装

チャットウィジェットはモバイルデバイスでも快適に利用できるよう、レスポンシブデザインを実装しましょう。

// ChatWidget.tsxのレスポンシブ対応を強化

// モバイル特有の調整を追加
useEffect(() => {
  const handleResize = () => {
    const isMobile = window.innerWidth <= 768;
    
    if (isMobile && chatState.isOpen && !chatState.isMinimized) {
      // モバイルでキーボードが表示された時の調整
      const viewportHeight = window.innerHeight;
      const chatContainer = document.querySelector('.chat-widget-container') as HTMLElement;
      
      if (chatContainer) {
        chatContainer.style.height = `${viewportHeight * 0.7}px`;
      }
    }
  };
  
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, [chatState.isOpen, chatState.isMinimized]);

// モバイルで全画面表示するためのスタイル調整(特定の条件下で)
const WidgetContainer = styled(motion.div)<{ position: string; isMobile: boolean }>`
  // 既存のスタイル...
  
  ${({ isMobile }) => isMobile && `
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    height: 100%;
    max-width: none;
    border-radius: 0;
    z-index: 10000;
  `}
`;

♿ アクセシビリティの実装

ウェブサイトのアクセシビリティを損なわないよう、WAI-ARIAガイドラインに沿った実装を行いましょう。

// ChatWidget.tsxにアクセシビリティ機能を追加

// フォーカス管理用のref
const inputRef = useRef<HTMLInputElement>(null);

// チャットが開いたらフォーカスを入力欄に移動
useEffect(() => {
  if (chatState.isOpen && !chatState.isMinimized && inputRef.current) {
    // 少し遅延させることでアニメーション完了後にフォーカスされるようにする
    setTimeout(() => {
      inputRef.current?.focus();
    }, 300);
  }
}, [chatState.isOpen, chatState.isMinimized]);

// キーボードナビゲーション対応
const handleKeyDown = (e: React.KeyboardEvent) => {
  if (e.key === 'Escape') {
    // ESCキーでチャットを閉じる
    handleClose();
  }
};

// ARIAロール属性の追加
return (
  <WidgetContainer
    role="dialog"
    aria-labelledby="chat-widget-title"
    aria-describedby="chat-widget-subtitle"
    onKeyDown={handleKeyDown}
    // 他の属性...
  >
    <ChatHeader 
      id="chat-widget-title"
      titleId="chat-widget-title"
      subtitleId="chat-widget-subtitle"
      // 他のプロップス...
    />
    
    <ChatMessages 
      aria-live="polite"
      // 他のプロップス...
    />
    
    <ChatInput 
      ref={inputRef}
      aria-label="メッセージを入力"
      // 他のプロップス...
    />
  </WidgetContainer>
);

🚀 パフォーマンス最適化

チャットウィジェットをウェブサイトに埋め込む際、メインコンテンツの読み込みを妨げないようにパフォーマンスを最適化することが重要です。

// index.tsxでの遅延読み込み実装

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';

// チャットウィジェットの読み込みを遅延させる関数
const loadChatWidget = () => {
  // すでにDOMにウィジェットコンテナがあるか確認
  let widgetContainer = document.getElementById('chat-widget-container');
  
  if (!widgetContainer) {
    // コンテナが存在しない場合は作成
    widgetContainer = document.createElement('div');
    widgetContainer.id = 'chat-widget-container';
    document.body.appendChild(widgetContainer);
  }
  
  // Appコンポーネントをレンダリング
  ReactDOM.render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
    widgetContainer
  );
};

// 優先度の低いタスクとしてチャットウィジェットを読み込む
if (typeof window !== 'undefined') {
  if (window.requestIdleCallback) {
    window.requestIdleCallback(() => {
      loadChatWidget();
    });
  } else {
    // requestIdleCallbackがサポートされていない場合はsetTimeoutを使用
    setTimeout(() => {
      loadChatWidget();
    }, 3000); // 3秒後に読み込み
  }
}

📦 Webpackでの最適化とバンドル設定

チャットウィジェットを外部Webサイトに埋め込みやすくするため、Webpackの設定を調整します。

// webpack.config.js
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  entry: './src/index.tsx',
  output: {
    filename: 'chat-widget.js',
    path: path.resolve(__dirname, 'dist'),
    libraryTarget: 'umd',
    library: 'ChatWidget',
    umdNamedDefine: true
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  optimization: {
    minimize: true,
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            drop_console: true,
          },
        },
      }),
    ],
  },
  externals: {
    // 必要に応じて外部依存関係を設定
    react: {
      commonjs: 'react',
      commonjs2: 'react',
      amd: 'React',
      root: 'React'
    },
    'react-dom': {
      commonjs: 'react-dom',
      commonjs2: 'react-dom',
      amd: 'ReactDOM',
      root: 'ReactDOM'
    }
  }
};

🌐 Vercelへのデプロイ手順

実装したチャットウィジェットを Vercel にデプロイして、簡単に他のサイトに埋め込めるようにしましょう。

1. Vercel CLIのインストール

# グローバルにVercel CLIをインストール
npm install -g vercel

2. プロジェクトの準備

Vercelでの最適なデプロイのために、vercel.jsonを作成します。

{
  "version": 2,
  "builds": [
    {
      "src": "package.json",
      "use": "@vercel/static-build",
      "config": { "distDir": "build" }
    }
  ],
  "routes": [
    {
      "src": "/static/(.*)",
      "headers": { "cache-control": "s-maxage=31536000, immutable" },
      "dest": "/static/$1"
    },
    { "src": "/favicon.ico", "dest": "/favicon.ico" },
    { "src": "/asset-manifest.json", "dest": "/asset-manifest.json" },
    { "src": "/manifest.json", "dest": "/manifest.json" },
    { 
      "src": "/chat-widget.js",
      "headers": { 
        "cache-control": "public, max-age=0, must-revalidate",
        "Access-Control-Allow-Origin": "*"
      }
    },
    {
      "src": "/(.*)",
      "headers": { "cache-control": "s-maxage=0" },
      "dest": "/index.html"
    }
  ]
}

3. ビルド設定の調整

package.jsonのスクリプトセクションを更新して、Vercelがビルドできるようにします。

"scripts": {
  "start": "react-scripts start",
  "build": "react-scripts build && cp build/static/js/main.*.js build/chat-widget.js",
  "test": "react-scripts test",
  "eject": "react-scripts eject",
  "vercel-build": "npm run build"
}

4. Vercelへのデプロイ

# ログイン(初回のみ)
vercel login

# デプロイ
vercel

コマンドを実行すると、いくつかの質問が表示されます。基本的にはデフォルト値でOKですが、必要に応じて調整しましょう。

? Set up and deploy "~/chat-widget"? [Y/n] y
? Which scope do you want to deploy to? [your-name]
? Link to existing project? [y/N] n
? What's your project's name? chat-widget
? In which directory is your code located? ./
? Want to override the settings? [y/N] n

デプロイが完了すると、Vercelは自動的にプロダクションURLをクリップボードにコピーします。このURLを使って、チャットウィジェットのJavaScriptを他のサイトに埋め込むことができます。

5. 他のサイトへの埋め込み

デプロイしたチャットウィジェットを他のサイトに埋め込むには、以下のようなHTMLを使用します。

<!-- ウェブサイトのbodyタグの最後 -->
<script async src="https://your-vercel-deployment-url.vercel.app/chat-widget.js"></script>

📈 使用状況の分析と改善

チャットウィジェットの効果を測定するために、使用状況の分析機能を実装しましょう。

// src/utils/analytics.ts

export interface ChatAnalyticsEvent {
  event: string;
  properties?: Record<string, any>;
  timestamp: number;
}

export class ChatAnalytics {
  private events: ChatAnalyticsEvent[] = [];
  private readonly sessionId: string;
  private readonly apiEndpoint?: string;
  
  constructor(apiEndpoint?: string) {
    this.sessionId = this.generateSessionId();
    this.apiEndpoint = apiEndpoint;
    
    // 5分ごとにイベントをフラッシュ
    setInterval(() => this.flushEvents(), 5 * 60 * 1000);
    
    // ページ離脱時にもイベントをフラッシュ
    if (typeof window !== 'undefined') {
      window.addEventListener('beforeunload', () => this.flushEvents());
    }
  }
  
  // セッションIDの生成
  private generateSessionId(): string {
    return Math.random().toString(36).substring(2, 15) + 
           Math.random().toString(36).substring(2, 15);
  }
  
  // イベントの記録
  public trackEvent(event: string, properties?: Record<string, any>): void {
    const analyticsEvent: ChatAnalyticsEvent = {
      event,
      properties: {
        ...properties,
        sessionId: this.sessionId,
        url: typeof window !== 'undefined' ? window.location.href : '',
        userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : ''
      },
      timestamp: Date.now()
    };
    
    this.events.push(analyticsEvent);
    
    // イベント数が10を超えたらフラッシュ
    if (this.events.length >= 10) {
      this.flushEvents();
    }
  }
  
  // イベントのフラッシュ(APIに送信)
  private flushEvents(): void {
    if (this.events.length === 0) return;
    
    const eventsToSend = [...this.events];
    this.events = [];
    
    if (this.apiEndpoint) {
      // APIエンドポイントが設定されている場合は送信
      fetch(this.apiEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ events: eventsToSend }),
        // ページ離脱時でもリクエストを完了させる
        keepalive: true
      }).catch(error => {
        console.error('Analytics events sending failed:', error);
        
        // エラー時はイベントを復元(最大100件まで)
        if (this.events.length + eventsToSend.length <= 100) {
          this.events = [...eventsToSend, ...this.events];
        }
      });
    } else {
      // APIエンドポイントが設定されていない場合はローカルストレージに保存
      const storedEvents = JSON.parse(
        localStorage.getItem('chat_analytics_events') || '[]'
      );
      localStorage.setItem(
        'chat_analytics_events', 
        JSON.stringify([...storedEvents, ...eventsToSend].slice(-100))
      );
    }
  }
}

// 使用例
export const chatAnalytics = new ChatAnalytics(
  process.env.REACT_APP_ANALYTICS_ENDPOINT
);

チャットウィジェットのコンポーネントで分析機能を使用する例:

import { chatAnalytics } from '../../utils/analytics';

// チャットを開いたイベントを記録
const openChat = useCallback(() => {
  setChatState(prev => ({
    ...prev,
    isOpen: true,
    isMinimized: false
  }));
  
  // 分析イベントの記録
  chatAnalytics.trackEvent('chat_opened');
}, [setChatState]);

// メッセージを送信したイベントを記録
const sendMessage = useCallback((content: string) => {
  if (!content.trim()) return;
  
  addMessage(content, 'user');
  
  // 分析イベントの記録
  chatAnalytics.trackEvent('message_sent', {
    messageLength: content.length,
    wordCount: content.split(/\s+/).length
  });
  
  // その他の処理...
}, [addMessage]);

🧪 テスト戦略

チャットウィジェットの品質を確保するためのテスト戦略も考慮しましょう。

// src/components/ChatWidget/ChatWidget.test.tsx

import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import ChatWidget from './ChatWidget';

describe('ChatWidget', () => {
  test('renders chat button when closed', () => {
    render(<ChatWidget />);
    const chatButton = screen.getByRole('button', { name: /チャットを開く/i });
    expect(chatButton).toBeInTheDocument();
  });
  
  test('opens chat when button is clicked', async () => {
    render(<ChatWidget />);
    const chatButton = screen.getByRole('button', { name: /チャットを開く/i });
    
    fireEvent.click(chatButton);
    
    await waitFor(() => {
      const chatHeader = screen.getByText('カスタマーサポート');
      expect(chatHeader).toBeInTheDocument();
    });
  });
  
  test('displays welcome message when opened', async () => {
    render(<ChatWidget welcomeMessage="テストメッセージです" />);
    const chatButton = screen.getByRole('button', { name: /チャットを開く/i });
    
    fireEvent.click(chatButton);
    
    await waitFor(() => {
      const welcomeMessage = screen.getByText('テストメッセージです');
      expect(welcomeMessage).toBeInTheDocument();
    });
  });
  
  test('sends message when form is submitted', async () => {
    render(<ChatWidget />);
    const chatButton = screen.getByRole('button', { name: /チャットを開く/i });
    
    fireEvent.click(chatButton);
    
    await waitFor(() => {
      const input = screen.getByPlaceholderText('メッセージを入力...');
      const sendButton = screen.getByRole('button', { name: /送信/i });
      
      fireEvent.change(input, { target: { value: 'テストメッセージ' } });
      fireEvent.click(sendButton);
    });
    
    await waitFor(() => {
      const userMessage = screen.getByText('テストメッセージ');
      expect(userMessage).toBeInTheDocument();
    });
  });
  
  test('closes chat when close button is clicked', async () => {
    render(<ChatWidget />);
    const chatButton = screen.getByRole('button', { name: /チャットを開く/i });
    
    fireEvent.click(chatButton);
    
    await waitFor(() => {
      const closeButton = screen.getByRole('button', { name: /閉じる/i });
      fireEvent.click(closeButton);
    });
    
    await waitFor(() => {
      const reopenButton = screen.getByRole('button', { name: /チャットを開く/i });
      expect(reopenButton).toBeInTheDocument();
    });
  });
});

📱 デモ画面

以下は、実装したチャットウィジェットのデモです。デスクトップ表示とモバイル表示の両方を示しています。

🔍 まとめ

この記事では、React と TypeScript を使用してカスタムチャットウィジェットを実装し、Vercel にデプロイする方法を解説しました。

実装したウィジェットの主な特徴は以下の通りです:

  • TypeScriptによる型安全な実装
  • フレーマーモーションを使用した滑らかなアニメーション
  • レスポンシブデザインによるモバイル対応
  • アクセシビリティに配慮した実装
  • ローカルストレージによるメッセージの永続化
  • パフォーマンスを考慮した遅延読み込み
  • Vercelを活用した簡単なデプロイと配信
  • 分析機能による使用状況の追跡

このようなカスタムウィジェットは、特定の業務要件に合わせたチャット体験を提供したい場合や、外部サービスへの依存を減らしたい場合に非常に有効です。

今回の実装をベースに、さらに以下のような機能を追加することも検討できます:

  • リアルタイムチャット機能(Socket.ioやFirebaseなどを活用)
  • ファイルのアップロード・共有機能
  • 既読機能とタイピングインジケータ
  • 音声メッセージの送受信
  • チャットボット統合(ルールベースやAI)

ぜひこの記事を参考に、あなた自身のカスタムチャットウィジェットを開発してみてください!

最後に:業務委託のご相談を承ります
私は業務委託エンジニアとしてWEB制作やシステム開発を請け負っています。最新技術を活用したレスポンシブなWebサイト制作、インタラクティブなアプリケーション開発、API連携など幅広いご要望に対応可能です。

「課題解決に向けた即戦力が欲しい」「高品質なWeb制作を依頼したい」という方は、お気軽にご相談ください。一緒にビジネスの成長を目指しましょう!

👉 ポートフォリオ

🌳 らくらくサイト

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?