Kurs vibe coding z AI · Moduł 13: Twitch Clone

Channel i Subscriptions

4 min czytania
W tej lekcji3

Twitch to 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">
              Ustawienia
            </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>
  );
}

Podsumowanie

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

W następnym ćwiczeniu dodamy browse categories i clips!

Do zobaczenia!

Kod do tej lekcji: 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: 'Kapitan Redbeard',
    badges: ['broadcaster'],
    message: 'Ahoj, piraci! Witajcie na streamie!',
    timestamp: new Date()
  },
  {
    id: '2',
    username: 'sailor_sam',
    displayName: 'Marynarz Sam',
    badges: ['subscriber'],
    message: 'PogChamp, niesamowity stream!',
    timestamp: new Date()
  },
  {
    id: '3',
    username: 'navigator_nancy',
    displayName: 'Nawigatorka Nancy',
    badges: ['moderator'],
    message: 'Pamiętajcie o zasadach czatu! Bądźcie mili :)',
    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: 'Ty',
      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">
        {/* Nagłówek */}
        <div className="p-3 border-b border-gray-800 bg-gray-950">
          <h3 className="text-white font-semibold">CZAT TRANSMISJI</h3>
        </div>

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

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

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

        {/* Pole wiadomości */}
        <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="Wyślij wiadomość"
              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"
            >
              Czat
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jakie subscription tiers ma Twitch?

  2. 2. Ile dolarów to 100 bits na Twitch?

To 2 z 6 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Klikanie w kolejności

    Zbuduj wyświetlenie sformatowanej liczby obserwujących: channel.followers.toLocaleString()

  • Układanie w pionie

    Ułóż sekcje channel page od góry strony:

  • Układanie w pionie

    Uporządkuj subscription tiers od najtańszego do najdroższego:

  • Edytor kodu

    Zbuduj stronę kanału streamera

  • Klikanie w kolejności

    Zbuduj zmianę aktywnej zakładki na 'home': setActiveTab('home')

  • Edytor kodu

    Stwórz kartę live streamu

Przydatne artykuły