Vibe coding with AI course Β· Module 13: Twitch Clone

Channel and Subscriptions

4 min read
In this lesson3

Twitch is about monetization - subscriptions, bits, followers.

Subscription System

interface Subscription {
  userId: string;
  channelId: string;
  tier: 'tier1' | 'tier2' | 'tier3'; // $4.99, $9.99, $24.99
  startDate: Date;
  renewDate: Date;
  isGift: boolean;
  giftedBy?: string;
  streak: number; // consecutive months
  cumulativeMonths: number; // total months
}

interface SubBenefits {
  tier1: {
    price: 4.99;
    emotes: string[]; // unlock emotes
    badge: string;
    adFree: true;
    chatPerks: {
      slowModeExempt: true;
      subsOnlyChat: true;
    };
  };
  tier2: {
    price: 9.99;
    emotes: string[];
    badge: string;
    adFree: true;
    chatPerks: {
      slowModeExempt: true;
      subsOnlyChat: true;
    };
  };
  tier3: {
    price: 24.99;
    emotes: string[];
    badge: string;
    adFree: true;
    chatPerks: {
      slowModeExempt: true;
      subsOnlyChat: true;
    };
  };
}

interface Bits {
  userId: string;
  channelId: string;
  amount: number; // 100 bits = $1
  message?: string;
  timestamp: Date;
}

Channel Page

'use client';

interface ChannelPageProps {
  channel: Channel;
  isFollowing: boolean;
  isSubscribed: boolean;
  subscriptionTier?: 'tier1' | 'tier2' | 'tier3';
}

export default function ChannelPage({
  channel,
  isFollowing,
  isSubscribed,
  subscriptionTier
}: ChannelPageProps) {
  const [activeTab, setActiveTab] = useState<'home' | 'videos' | 'schedule' | 'about'>('home');

  return (
    <div className="min-h-screen bg-gray-950 text-white">
      {/* Banner */}
      {channel.banner && (
        <div
          className="h-80 bg-cover bg-center"
          style={{ backgroundImage: `url(${channel.banner})` }}
        />
      )}

      {/* Channel Header */}
      <div className="max-w-7xl mx-auto px-6 -mt-16 relative z-10">
        <div className="flex items-end gap-6 mb-6">
          {/* Avatar */}
          <img
            src={channel.avatar}
            alt={channel.displayName}
            className="w-32 h-32 rounded-full border-4 border-gray-950"
          />

          {/* Info */}
          <div className="flex-1 pb-4">
            <h1 className="text-4xl font-bold mb-2">{channel.displayName}</h1>
            <p className="text-gray-400">{channel.description}</p>
          </div>

          {/* Actions */}
          <div className="flex items-center gap-3 pb-4">
            <button
              className={`
                px-6 py-2 rounded font-semibold
                ${isFollowing
                  ? 'bg-gray-700 hover:bg-gray-600'
                  : 'bg-purple-600 hover:bg-purple-700'
                }
              `}
            >
              {isFollowing ? 'Following' : 'Follow'}
            </button>

            {!isSubscribed ? (
              <button className="px-6 py-2 bg-purple-600 rounded font-semibold hover:bg-purple-700">
                Subscribe
              </button>
            ) : (
              <button className="px-6 py-2 bg-purple-900 border border-purple-600 rounded font-semibold hover:bg-purple-800">
                Subscribed ({subscriptionTier})
              </button>
            )}

            <button className="p-2 hover:bg-gray-800 rounded">
              Settings
            </button>
          </div>
        </div>

        {/* Stats */}
        <div className="flex gap-6 mb-6 text-sm text-gray-400">
          <div>
            <span className="font-semibold text-white">{channel.followers.toLocaleString()}</span> followers
          </div>
          <div>
            <span className="font-semibold text-white">{channel.totalViews.toLocaleString()}</span> total views
          </div>
          {channel.isPartner && (
            <div className="flex items-center gap-1">
              <span className="text-purple-500">√</span>
              <span>Partner</span>
            </div>
          )}
        </div>

        {/* Tabs */}
        <div className="border-b border-gray-800">
          <div className="flex gap-8">
            {[
              { key: 'home', label: 'Home' },
              { key: 'videos', label: 'Videos' },
              { key: 'schedule', label: 'Schedule' },
              { key: 'about', label: 'About' }
            ].map(tab => (
              <button
                key={tab.key}
                onClick={() => setActiveTab(tab.key as any)}
                className={`
                  pb-3 font-semibold
                  ${activeTab === tab.key
                    ? 'border-b-2 border-purple-500 text-white'
                    : 'text-gray-400 hover:text-white'
                  }
                `}
              >
                {tab.label}
              </button>
            ))}
          </div>
        </div>
      </div>

      {/* Content */}
      <div className="max-w-7xl mx-auto px-6 py-8">
        {activeTab === 'home' && (
          <div>
            {/* Live Stream or Offline */}
            {channel.isLive && channel.currentStream ? (
              <div className="mb-8">
                <h2 className="text-xl font-bold mb-4">Live Now</h2>
                <StreamCard stream={channel.currentStream} />
              </div>
            ) : (
              <div className="text-center py-12 text-gray-500">
                <div className="text-4xl mb-4">β—†</div>
                <p className="text-lg">Channel is offline</p>
                <p>Check back later or follow to get notified</p>
              </div>
            )}
          </div>
        )}

        {activeTab === 'videos' && (
          <div>
            <h2 className="text-xl font-bold mb-4">Recent Videos</h2>
            <div className="grid grid-cols-3 gap-4">
              {/* VODs grid */}
            </div>
          </div>
        )}

        {activeTab === 'about' && (
          <div className="max-w-2xl">
            <h2 className="text-xl font-bold mb-4">About</h2>
            <p className="text-gray-300 leading-relaxed">
              {channel.description}
            </p>
          </div>
        )}
      </div>
    </div>
  );
}

function StreamCard({ stream }: { stream: Stream }) {
  return (
    <div className="bg-gray-900 rounded overflow-hidden hover:scale-105 transition cursor-pointer">
      <div className="relative">
        <img
          src={stream.thumbnailUrl}
          alt={stream.title}
          className="w-full aspect-video object-cover"
        />
        <div className="absolute top-2 left-2 px-2 py-1 bg-red-600 text-white text-xs font-bold rounded">
          LIVE
        </div>
        <div className="absolute bottom-2 left-2 px-2 py-1 bg-black/70 text-white text-xs rounded">
          {stream.viewerCount.toLocaleString()} viewers
        </div>
      </div>

      <div className="p-3">
        <h3 className="font-semibold mb-1 line-clamp-1">{stream.title}</h3>
        <p className="text-sm text-gray-400">{stream.category.name}</p>
      </div>
    </div>
  );
}

Summary

Channel page - banner, avatar, description, tabs Follow button - follow/unfollow channel Subscribe button - subscription tiers Channel stats - followers, total views Partner badge - verified partner status Live stream card - thumbnail, viewer count, category Offline state - message when not streaming Subscription tiers - tier1, tier2, tier3 with benefits

In the next exercise we'll add browse categories and clips!

See you soon!

Code for this lesson: App.tsx
'use client';

import { useState } from 'react';

interface ChatMessage {
  id: string;
  username: string;
  displayName: string;
  badges: string[];
  message: string;
  timestamp: Date;
}

const mockMessages: ChatMessage[] = [
  {
    id: '1',
    username: 'captain_redbeard',
    displayName: 'Captain Redbeard',
    badges: ['broadcaster'],
    message: 'Ahoj pirates! Welcome to the stream!',
    timestamp: new Date()
  },
  {
    id: '2',
    username: 'sailor_sam',
    displayName: 'Sailor Sam',
    badges: ['subscriber'],
    message: 'PogChamp amazing stream!',
    timestamp: new Date()
  },
  {
    id: '3',
    username: 'navigator_nancy',
    displayName: 'Navigator Nancy',
    badges: ['moderator'],
    message: 'Remember chat rules! Be nice :)',
    timestamp: new Date()
  }
];

const badges = {
  broadcaster: '',
  moderator: '',
  subscriber: '',
  vip: ''
};

export default function LiveChat() {
  const [messages, setMessages] = useState(mockMessages);
  const [inputValue, setInputValue] = useState('');
  const [showEmotes, setShowEmotes] = useState(false);

  const sendMessage = () => {
    if (!inputValue.trim()) return;

    const newMessage: ChatMessage = {
      id: Date.now().toString(),
      username: 'you',
      displayName: 'You',
      badges: [],
      message: inputValue,
      timestamp: new Date()
    };

    setMessages([...messages, newMessage]);
    setInputValue('');
  };

  return (
    <div className="min-h-screen bg-black flex items-center justify-center p-4">
      <div className="w-80 h-[600px] bg-gray-900 flex flex-col rounded-lg overflow-hidden">
        {/* Header */}
        <div className="p-3 border-b border-gray-800 bg-gray-950">
          <h3 className="text-white font-semibold">STREAM CHAT</h3>
        </div>

        {/* Messages */}
        <div className="flex-1 overflow-y-auto p-3 space-y-2">
          {messages.map(msg => (
            <div key={msg.id} className="text-sm">
              {/* Badges */}
              {msg.badges.map(badge => (
                <span key={badge} className="mr-1" title={badge}>
                  {badges[badge as keyof typeof badges]}
                </span>
              ))}

              {/* Username */}
              <span className="font-semibold text-purple-400">
                {msg.displayName}:
              </span>

              {/* Message */}
              <span className="text-white ml-1">{msg.message}</span>
            </div>
          ))}
        </div>

        {/* Input */}
        <div className="p-3 border-t border-gray-800">
          <div className="flex gap-2">
            <button
              onClick={() => setShowEmotes(!showEmotes)}
              className="p-2 text-gray-400 hover:text-white bg-gray-800 rounded"
            >
              :)
            </button>

            <input
              type="text"
              value={inputValue}
              onChange={(e) => setInputValue(e.target.value)}
              onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
              placeholder="Send a message"
              className="flex-1 bg-gray-800 text-white px-3 py-2 rounded outline-none focus:ring-2 focus:ring-purple-500"
            />

            <button
              onClick={sendMessage}
              disabled={!inputValue.trim()}
              className="px-4 py-2 bg-purple-600 text-white rounded font-semibold hover:bg-purple-700 disabled:bg-gray-700"
            >
              Chat
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What subscription tiers does Twitch have?

  2. 2. How many dollars is 100 bits on Twitch?

These are 2 of 6 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Click in order

    Build the display of the formatted follower count: channel.followers.toLocaleString()

  • Vertical ordering

    Arrange the channel page sections from the top of the page:

  • Vertical ordering

    Order the subscription tiers from cheapest to most expensive:

  • Code editor

    Build a streamer channel page

  • Click in order

    Build the active tab change to 'home': setActiveTab('home')

  • Code editor

    Create a live stream card

Useful articles