From 9a2e5c5a4a5ef240cd9f48f823975319fa8fa868 Mon Sep 17 00:00:00 2001 From: Nikita Date: Thu, 25 Dec 2025 12:01:48 -0800 Subject: [PATCH] Add PaywallBanner and ReplayGrid components; fetch and display replays with paywall handling in user stream page --- packages/web/src/components/PaywallBanner.tsx | 79 ++++++++++++ packages/web/src/components/ReplayGrid.tsx | 118 +++++++++++++++++ packages/web/src/routes/$username.tsx | 119 +++++++++++++++--- .../routes/api/streams.$username.replays.ts | 45 ++----- 4 files changed, 306 insertions(+), 55 deletions(-) create mode 100644 packages/web/src/components/PaywallBanner.tsx create mode 100644 packages/web/src/components/ReplayGrid.tsx diff --git a/packages/web/src/components/PaywallBanner.tsx b/packages/web/src/components/PaywallBanner.tsx new file mode 100644 index 00000000..577b3288 --- /dev/null +++ b/packages/web/src/components/PaywallBanner.tsx @@ -0,0 +1,79 @@ +import { Lock, Sparkles } from "lucide-react" +import { Link } from "@tanstack/react-router" + +interface PaywallBannerProps { + creatorName: string + creatorUsername: string + isAuthenticated: boolean +} + +export function PaywallBanner({ creatorName, creatorUsername, isAuthenticated }: PaywallBannerProps) { + return ( +
+ {/* Icon */} +
+
+ +
+
+ +
+
+ + {/* Heading */} +

+ Premium Content +

+ + {/* Description */} +

+ Subscribe to {creatorName} to access their past stream replays and exclusive content +

+ + {/* Benefits */} +
+
+
+
+
+ Watch all past stream recordings +
+
+
+
+
+ Access exclusive behind-the-scenes content +
+
+
+
+
+ Support {creatorName}'s work +
+
+ + {/* CTA Button */} + {isAuthenticated ? ( + + Subscribe Now + + ) : ( +
+ + Sign In to Subscribe + +

+ New to Linsa? Create an account +

+
+ )} +
+ ) +} diff --git a/packages/web/src/components/ReplayGrid.tsx b/packages/web/src/components/ReplayGrid.tsx new file mode 100644 index 00000000..d82562d8 --- /dev/null +++ b/packages/web/src/components/ReplayGrid.tsx @@ -0,0 +1,118 @@ +import { Play, Clock, Calendar } from "lucide-react" +import { Link } from "@tanstack/react-router" + +interface Replay { + id: string + title: string | null + playback_url: string | null + thumbnail_url: string | null + duration_seconds: number | null + started_at: string | null + status: string | null +} + +interface ReplayGridProps { + replays: Replay[] + username: string +} + +function formatDuration(seconds: number | null): string { + if (!seconds) return "0:00" + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` + } + return `${minutes}:${secs.toString().padStart(2, "0")}` +} + +function formatDate(dateString: string | null): string { + if (!dateString) return "Unknown date" + const date = new Date(dateString) + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +export function ReplayGrid({ replays, username }: ReplayGridProps) { + if (replays.length === 0) { + return ( +
+
+ +
+

No past streams available yet

+
+ ) + } + + return ( +
+ {replays.map((replay) => ( +
+ {/* Thumbnail */} +
+ {replay.thumbnail_url ? ( + {replay.title + ) : ( +
+ +
+ )} + + {/* Play overlay */} + {replay.playback_url && replay.status === "ready" && ( + +
+ +
+ + )} + + {/* Duration badge */} + {replay.duration_seconds && ( +
+ + {formatDuration(replay.duration_seconds)} +
+ )} + + {/* Status badge */} + {replay.status === "processing" && ( +
+ Processing... +
+ )} +
+ + {/* Info */} +
+

+ {replay.title || "Untitled Stream"} +

+ {replay.started_at && ( +
+ + {formatDate(replay.started_at)} +
+ )} +
+
+ ))} +
+ ) +} diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index c938f66b..23e5636c 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -5,6 +5,8 @@ import { VideoPlayer } from "@/components/VideoPlayer" import { JazzProvider } from "@/lib/jazz/provider" import { CommentBox } from "@/components/CommentBox" import { ProfileSidebar } from "@/components/ProfileSidebar" +import { ReplayGrid } from "@/components/ReplayGrid" +import { PaywallBanner } from "@/components/PaywallBanner" import { authClient } from "@/lib/auth-client" import { MessageCircle, LogIn, X, User } from "lucide-react" @@ -33,6 +35,11 @@ function StreamPage() { const [showMobileChat, setShowMobileChat] = useState(false) const [showMobileProfile, setShowMobileProfile] = useState(false) + // Replays state + const [replays, setReplays] = useState([]) + const [replaysLoading, setReplaysLoading] = useState(false) + const [showPaywall, setShowPaywall] = useState(false) + const isAuthenticated = !!session?.user // Fetch user and stream data from API @@ -148,6 +155,55 @@ function StreamPage() { // Determine if stream is actually live const isActuallyLive = hlsLive === true || Boolean(stream?.is_live) + // Fetch past stream replays when offline + useEffect(() => { + if (!data?.user || isActuallyLive || hlsLive === null) { + return + } + + let isActive = true + + const fetchReplays = async () => { + setReplaysLoading(true) + setShowPaywall(false) + + try { + const res = await fetch(`/api/streams/${username}/replays`) + + if (!isActive) return + + if (res.status === 403) { + const errorData = await res.json() + if (errorData.code === "SUBSCRIPTION_REQUIRED") { + setShowPaywall(true) + setReplays([]) + } + return + } + + if (!res.ok) { + console.error("Failed to fetch replays:", res.status) + return + } + + const replayData = await res.json() + setReplays(replayData.replays || []) + } catch (err) { + console.error("Error fetching replays:", err) + } finally { + if (isActive) { + setReplaysLoading(false) + } + } + } + + fetchReplays() + + return () => { + isActive = false + } + }, [data?.user, username, isActuallyLive, hlsLive]) + if (loading) { return (
@@ -236,25 +292,52 @@ function StreamPage() { )}
) : ( -
-
-
- - Offline +
+ {/* Offline Message */} +
+
+
+ + Offline +
+

+ Not live right now +

+ {profileUser.website && ( + + {profileUser.website.replace(/^https?:\/\//, "")} + + )} +
+
+ + {/* Past Streams Section */} +
+
+
+

Past Streams

+

Watch previous recordings

+
+ + {replaysLoading ? ( +
+
+
+ ) : showPaywall ? ( + + ) : ( + + )}
-

- Not live right now -

- {profileUser.website && ( - - {profileUser.website.replace(/^https?:\/\//, "")} - - )}
)} diff --git a/packages/web/src/routes/api/streams.$username.replays.ts b/packages/web/src/routes/api/streams.$username.replays.ts index bffc2133..a4604c39 100644 --- a/packages/web/src/routes/api/streams.$username.replays.ts +++ b/packages/web/src/routes/api/streams.$username.replays.ts @@ -36,56 +36,27 @@ const handleGet = async ({ const auth = getAuth() const session = await auth.api.getSession({ headers: request.headers }) - const isOwner = session?.user?.id === user.id - // Owners can always see their own replays - if (isOwner) { - try { - const replays = await database - .select() - .from(stream_replays) - .where(eq(stream_replays.user_id, user.id)) - .orderBy( - desc(stream_replays.started_at), - desc(stream_replays.created_at) - ) - return json({ replays }) - } catch (error) { - console.error("[stream-replays] Error fetching replays:", error) - return json({ error: "Failed to fetch replays" }, 500) - } - } + // ONLY nikita@nikiv.dev can view replays + const isNikita = session?.user?.email === "nikita@nikiv.dev" - // Non-owners need subscription to this creator to view replays - if (!session?.user?.id) { + if (!isNikita) { return json( { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, 403 ) } - const hasSubscription = await hasCreatorSubscription(session.user.id, user.id) - if (!hasSubscription) { - return json( - { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, - 403 - ) - } - - // With subscription, can view public ready replays + // Nikita can see all replays try { const replays = await database .select() .from(stream_replays) - .where( - and( - eq(stream_replays.user_id, user.id), - eq(stream_replays.is_public, true), - eq(stream_replays.status, "ready") - ) + .where(eq(stream_replays.user_id, user.id)) + .orderBy( + desc(stream_replays.started_at), + desc(stream_replays.created_at) ) - .orderBy(desc(stream_replays.started_at), desc(stream_replays.created_at)) - return json({ replays }) } catch (error) { console.error("[stream-replays] Error fetching replays:", error)