Kurs vibe coding z AI · Moduł 13: Twitch Clone
Channel i Subscriptions
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. Jakie subscription tiers ma Twitch?
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