Refactor stream status fetch to use local API proxy; add /api/stream-status route; update Stripe config references; modify checkout to use proPriceId; adjust fetch URLs in landing page.

This commit is contained in:
Nikita
2025-12-24 18:18:19 -08:00
parent 157f9a69d8
commit 2d3d7e0185
7 changed files with 202 additions and 190 deletions

View File

@@ -4,12 +4,12 @@ export interface StreamStatus {
} }
/** /**
* Fetches stream status from nikiv.dev/api/stream-status * Fetches stream status via local API proxy (avoids CORS)
* This is set by Lin when streaming starts/stops * This is set by Lin when streaming starts/stops
*/ */
export async function getStreamStatus(): Promise<StreamStatus> { export async function getStreamStatus(): Promise<StreamStatus> {
try { try {
const response = await fetch("https://nikiv.dev/api/stream-status", { const response = await fetch("/api/stream-status", {
cache: "no-store", cache: "no-store",
}) })
if (!response.ok) { if (!response.ok) {

View File

@@ -3,13 +3,13 @@ import Stripe from "stripe"
type StripeEnv = { type StripeEnv = {
STRIPE_SECRET_KEY?: string STRIPE_SECRET_KEY?: string
STRIPE_WEBHOOK_SECRET?: string STRIPE_WEBHOOK_SECRET?: string
STRIPE_ARCHIVE_PRICE_ID?: string // Archive subscription price STRIPE_PRO_PRICE_ID?: string // Linsa Pro subscription price ($8/month)
} }
const getEnv = (): StripeEnv => { const getEnv = (): StripeEnv => {
let STRIPE_SECRET_KEY: string | undefined let STRIPE_SECRET_KEY: string | undefined
let STRIPE_WEBHOOK_SECRET: string | undefined let STRIPE_WEBHOOK_SECRET: string | undefined
let STRIPE_ARCHIVE_PRICE_ID: string | undefined let STRIPE_PRO_PRICE_ID: string | undefined
try { try {
const { getServerContext } = require("@tanstack/react-start/server") as { const { getServerContext } = require("@tanstack/react-start/server") as {
@@ -18,7 +18,7 @@ const getEnv = (): StripeEnv => {
const ctx = getServerContext() const ctx = getServerContext()
STRIPE_SECRET_KEY = ctx?.cloudflare?.env?.STRIPE_SECRET_KEY STRIPE_SECRET_KEY = ctx?.cloudflare?.env?.STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET = ctx?.cloudflare?.env?.STRIPE_WEBHOOK_SECRET STRIPE_WEBHOOK_SECRET = ctx?.cloudflare?.env?.STRIPE_WEBHOOK_SECRET
STRIPE_ARCHIVE_PRICE_ID = ctx?.cloudflare?.env?.STRIPE_ARCHIVE_PRICE_ID STRIPE_PRO_PRICE_ID = ctx?.cloudflare?.env?.STRIPE_PRO_PRICE_ID
} catch { } catch {
// Not in server context // Not in server context
} }
@@ -26,10 +26,10 @@ const getEnv = (): StripeEnv => {
STRIPE_SECRET_KEY = STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY STRIPE_SECRET_KEY = STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_SECRET =
STRIPE_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET STRIPE_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET
STRIPE_ARCHIVE_PRICE_ID = STRIPE_PRO_PRICE_ID =
STRIPE_ARCHIVE_PRICE_ID ?? process.env.STRIPE_ARCHIVE_PRICE_ID STRIPE_PRO_PRICE_ID ?? process.env.STRIPE_PRO_PRICE_ID
return { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_ARCHIVE_PRICE_ID } return { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRO_PRICE_ID }
} }
export const getStripe = (): Stripe | null => { export const getStripe = (): Stripe | null => {
@@ -46,6 +46,6 @@ export const getStripeConfig = () => {
const env = getEnv() const env = getEnv()
return { return {
webhookSecret: env.STRIPE_WEBHOOK_SECRET, webhookSecret: env.STRIPE_WEBHOOK_SECRET,
archivePriceId: env.STRIPE_ARCHIVE_PRICE_ID, proPriceId: env.STRIPE_PRO_PRICE_ID,
} }
} }

View File

@@ -28,6 +28,7 @@ import { Route as CanvasCanvasIdRouteImport } from './routes/canvas.$canvasId'
import { Route as ArchiveArchiveIdRouteImport } from './routes/archive.$archiveId' import { Route as ArchiveArchiveIdRouteImport } from './routes/archive.$archiveId'
import { Route as ApiUsersRouteImport } from './routes/api/users' import { Route as ApiUsersRouteImport } from './routes/api/users'
import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events' import { Route as ApiUsageEventsRouteImport } from './routes/api/usage-events'
import { Route as ApiStreamStatusRouteImport } from './routes/api/stream-status'
import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays' import { Route as ApiStreamReplaysRouteImport } from './routes/api/stream-replays'
import { Route as ApiStreamCommentsRouteImport } from './routes/api/stream-comments' import { Route as ApiStreamCommentsRouteImport } from './routes/api/stream-comments'
import { Route as ApiStreamRouteImport } from './routes/api/stream' import { Route as ApiStreamRouteImport } from './routes/api/stream'
@@ -166,6 +167,11 @@ const ApiUsageEventsRoute = ApiUsageEventsRouteImport.update({
path: '/api/usage-events', path: '/api/usage-events',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const ApiStreamStatusRoute = ApiStreamStatusRouteImport.update({
id: '/api/stream-status',
path: '/api/stream-status',
getParentRoute: () => rootRouteImport,
} as any)
const ApiStreamReplaysRoute = ApiStreamReplaysRouteImport.update({ const ApiStreamReplaysRoute = ApiStreamReplaysRouteImport.update({
id: '/api/stream-replays', id: '/api/stream-replays',
path: '/api/stream-replays', path: '/api/stream-replays',
@@ -407,6 +413,7 @@ export interface FileRoutesByFullPath {
'/api/stream': typeof ApiStreamRoute '/api/stream': typeof ApiStreamRoute
'/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren
'/archive/$archiveId': typeof ArchiveArchiveIdRoute '/archive/$archiveId': typeof ArchiveArchiveIdRoute
@@ -469,6 +476,7 @@ export interface FileRoutesByTo {
'/api/stream': typeof ApiStreamRoute '/api/stream': typeof ApiStreamRoute
'/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren
'/archive/$archiveId': typeof ArchiveArchiveIdRoute '/archive/$archiveId': typeof ArchiveArchiveIdRoute
@@ -533,6 +541,7 @@ export interface FileRoutesById {
'/api/stream': typeof ApiStreamRoute '/api/stream': typeof ApiStreamRoute
'/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-comments': typeof ApiStreamCommentsRoute
'/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren
'/api/stream-status': typeof ApiStreamStatusRoute
'/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/usage-events': typeof ApiUsageEventsRouteWithChildren
'/api/users': typeof ApiUsersRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren
'/archive/$archiveId': typeof ArchiveArchiveIdRoute '/archive/$archiveId': typeof ArchiveArchiveIdRoute
@@ -598,6 +607,7 @@ export interface FileRouteTypes {
| '/api/stream' | '/api/stream'
| '/api/stream-comments' | '/api/stream-comments'
| '/api/stream-replays' | '/api/stream-replays'
| '/api/stream-status'
| '/api/usage-events' | '/api/usage-events'
| '/api/users' | '/api/users'
| '/archive/$archiveId' | '/archive/$archiveId'
@@ -660,6 +670,7 @@ export interface FileRouteTypes {
| '/api/stream' | '/api/stream'
| '/api/stream-comments' | '/api/stream-comments'
| '/api/stream-replays' | '/api/stream-replays'
| '/api/stream-status'
| '/api/usage-events' | '/api/usage-events'
| '/api/users' | '/api/users'
| '/archive/$archiveId' | '/archive/$archiveId'
@@ -723,6 +734,7 @@ export interface FileRouteTypes {
| '/api/stream' | '/api/stream'
| '/api/stream-comments' | '/api/stream-comments'
| '/api/stream-replays' | '/api/stream-replays'
| '/api/stream-status'
| '/api/usage-events' | '/api/usage-events'
| '/api/users' | '/api/users'
| '/archive/$archiveId' | '/archive/$archiveId'
@@ -787,6 +799,7 @@ export interface RootRouteChildren {
ApiStreamRoute: typeof ApiStreamRoute ApiStreamRoute: typeof ApiStreamRoute
ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute
ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren
ApiStreamStatusRoute: typeof ApiStreamStatusRoute
ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren
ApiUsersRoute: typeof ApiUsersRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren
I1focusDemoRoute: typeof I1focusDemoRoute I1focusDemoRoute: typeof I1focusDemoRoute
@@ -948,6 +961,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiUsageEventsRouteImport preLoaderRoute: typeof ApiUsageEventsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/api/stream-status': {
id: '/api/stream-status'
path: '/api/stream-status'
fullPath: '/api/stream-status'
preLoaderRoute: typeof ApiStreamStatusRouteImport
parentRoute: typeof rootRouteImport
}
'/api/stream-replays': { '/api/stream-replays': {
id: '/api/stream-replays' id: '/api/stream-replays'
path: '/api/stream-replays' path: '/api/stream-replays'
@@ -1404,6 +1424,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiStreamRoute: ApiStreamRoute, ApiStreamRoute: ApiStreamRoute,
ApiStreamCommentsRoute: ApiStreamCommentsRoute, ApiStreamCommentsRoute: ApiStreamCommentsRoute,
ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren, ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren,
ApiStreamStatusRoute: ApiStreamStatusRoute,
ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren, ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren,
ApiUsersRoute: ApiUsersRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren,
I1focusDemoRoute: I1focusDemoRoute, I1focusDemoRoute: I1focusDemoRoute,

View File

@@ -0,0 +1,40 @@
import { createFileRoute } from "@tanstack/react-router"
const handler = async () => {
try {
const response = await fetch("https://nikiv.dev/api/stream-status", {
cache: "no-store",
})
if (!response.ok) {
return new Response(JSON.stringify({ isLive: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}
const data = await response.json()
return new Response(JSON.stringify({
isLive: Boolean(data.isLive),
updatedAt: data.updatedAt,
}), {
status: 200,
headers: { "Content-Type": "application/json" },
})
} catch (error) {
console.error("Failed to fetch stream status:", error)
return new Response(JSON.stringify({ isLive: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
})
}
}
export const Route = createFileRoute("/api/stream-status")({
server: {
handlers: {
GET: handler,
},
},
})

View File

@@ -23,16 +23,16 @@ export const Route = createFileRoute("/api/stripe/checkout")({
} }
const stripe = getStripe() const stripe = getStripe()
const { archivePriceId } = getStripeConfig() const { proPriceId } = getStripeConfig()
if (!stripe) { if (!stripe) {
console.error("[stripe] Stripe not configured - missing STRIPE_SECRET_KEY") console.error("[stripe] Stripe not configured - missing STRIPE_SECRET_KEY")
return json({ error: "Stripe not configured" }, 500) return json({ error: "Stripe not configured" }, 500)
} }
if (!archivePriceId) { if (!proPriceId) {
console.error("[stripe] Price ID not configured - missing STRIPE_ARCHIVE_PRICE_ID") console.error("[stripe] Price ID not configured - missing STRIPE_PRO_PRICE_ID")
return json({ error: "Price ID not configured" }, 500) return json({ error: "Price ID not configured. Set STRIPE_PRO_PRICE_ID" }, 500)
} }
const database = db() const database = db()
@@ -67,23 +67,17 @@ export const Route = createFileRoute("/api/stripe/checkout")({
stripeCustomerId = stripeCustomer.id stripeCustomerId = stripeCustomer.id
} }
// Parse request body for success/cancel URLs
const body = (await request.json().catch(() => ({}))) as {
successUrl?: string
cancelUrl?: string
}
const origin = new URL(request.url).origin const origin = new URL(request.url).origin
const successUrl = body.successUrl ?? `${origin}/archive?billing=success` const successUrl = `${origin}/settings?subscribed=true`
const cancelUrl = body.cancelUrl ?? `${origin}/archive?billing=canceled` const cancelUrl = `${origin}/settings?canceled=true`
// Create checkout session // Create checkout session for Linsa Pro ($8/month)
const checkoutSession = await stripe.checkout.sessions.create({ const checkoutSession = await stripe.checkout.sessions.create({
customer: stripeCustomerId, customer: stripeCustomerId,
mode: "subscription", mode: "subscription",
line_items: [ line_items: [
{ {
price: archivePriceId, price: proPriceId,
quantity: 1, quantity: 1,
}, },
], ],

View File

@@ -48,7 +48,7 @@ function LandingPage() {
useEffect(() => { useEffect(() => {
const checkLiveStatus = async () => { const checkLiveStatus = async () => {
try { try {
const response = await fetch("https://nikiv.dev/api/stream-status") const response = await fetch("/api/stream-status")
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
setIsLive(Boolean(data.isLive)) setIsLive(Boolean(data.isLive))

View File

@@ -1,21 +1,15 @@
import { useMemo, useState, type FormEvent, type ReactNode } from "react" import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react"
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import SettingsPanel from "@/components/Settings-panel" import SettingsPanel from "@/components/Settings-panel"
import { import {
BadgeDollarSign,
Check, Check,
ChevronDown, ChevronDown,
CreditCard,
Gem,
LogOut, LogOut,
Shield,
Sparkles, Sparkles,
UserRoundPen, UserRoundPen,
Lock, Lock,
X,
} from "lucide-react" } from "lucide-react"
import { BillingStatusNew } from "@/components/billing"
type SectionId = "preferences" | "profile" | "billing" type SectionId = "preferences" | "profile" | "billing"
@@ -27,9 +21,6 @@ export const Route = createFileRoute("/settings")({
ssr: false, ssr: false,
}) })
const CHANGE_PLAN_BACKGROUND =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='0.18'/%3E%3C/svg%3E"
type Option = { value: string; label: string } type Option = { value: string; label: string }
function InlineSelect({ function InlineSelect({
@@ -440,171 +431,137 @@ function ProfileSection({
) )
} }
function UsageBar({
icon,
label,
current,
total,
gradient,
}: {
icon: ReactNode
label: string
current: number
total: number
gradient: string
}) {
const pct = Math.min(100, Math.round((current / total) * 100))
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-slate-200">
<div className="text-lg">{icon}</div>
<span className="font-semibold text-white">{current}</span>
<span className="text-slate-400">
/ {total.toLocaleString()} {label}
</span>
</div>
<div className="h-3 bg-white/5 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${pct}%`,
background: gradient,
}}
/>
</div>
</div>
)
}
function PlanCard({
name,
price,
cadence,
brand,
last4,
active,
badge,
gradient,
}: {
name: string
price: string
cadence: string
brand: string
last4: string
active?: boolean
badge?: string
gradient: string
}) {
const cardBackground = gradient
return (
<div
className={`rounded-3xl w-full p-5 border relative overflow-hidden ${
active ? "border-white/10" : "border-white/5"
}`}
style={{
backgroundImage: `url("${PLAN_CARD_NOISE}")`,
backgroundColor: cardBackground,
backgroundBlendMode: "overlay, normal",
backgroundSize: "280px 280px, cover",
backgroundRepeat: "repeat, no-repeat",
opacity: active ? 1 : 0.6,
}}
>
{badge ? (
<span className="absolute top-3 right-3 text-[11px] px-3 py-1 rounded-lg bg-white/10 text-white/80 border border-white/10">
{badge}
</span>
) : null}
<div className="flex items-center gap-2 text-2xl font-semibold text-white">
<Sparkles className="w-5 h-5 text-pink-200" />
<span>{name}</span>
</div>
<p className="text-slate-200 mt-1">{price}</p>
<div className="flex items-center gap-3 text-sm text-slate-100 mt-6">
<span>{cadence}</span>
<span className="text-white/40"></span>
<span className="inline-flex items-center gap-2">
<CreditCard className="w-4 h-4" />
{brand} {last4}
</span>
</div>
{!active ? (
<div className="absolute inset-0 bg-black/35 backdrop-blur-[1px]" />
) : null}
</div>
)
}
function BillingSection() { function BillingSection() {
const [isSubscribed, setIsSubscribed] = useState(false)
const [loading, setLoading] = useState(true)
const [subscribing, setSubscribing] = useState(false)
useEffect(() => {
const checkSubscription = async () => {
try {
const res = await fetch("/api/stripe/billing", { credentials: "include" })
if (res.ok) {
const data = await res.json()
setIsSubscribed(data.hasActiveSubscription)
}
} catch {
// Ignore errors
} finally {
setLoading(false)
}
}
checkSubscription()
}, [])
const handleSubscribe = async () => {
setSubscribing(true)
try {
const res = await fetch("/api/stripe/checkout", {
method: "POST",
credentials: "include",
})
const data = await res.json()
if (data.url) {
window.location.href = data.url
}
} catch (err) {
console.error("Failed to start checkout:", err)
} finally {
setSubscribing(false)
}
}
const handleManageBilling = async () => {
try {
const res = await fetch("/api/stripe/portal", {
method: "POST",
credentials: "include",
})
const data = await res.json()
if (data.url) {
window.location.href = data.url
}
} catch (err) {
console.error("Failed to open billing portal:", err)
}
}
return ( return (
<div id="billing" className="scroll-mt-24 mx-auto"> <div id="billing" className="scroll-mt-24 mx-auto">
<SectionHeader <SectionHeader
title="Subscription" title="Subscription"
description="Current plan, upcoming tiers, and credit usage." description="Manage your Linsa Pro subscription."
/> />
<div className="flex flex-row gap-8 w-full">
<div className="flex flex-col gap-4 w-full">
<PlanCard
name="Gen Pro"
price="$7.99 / month"
cadence="Monthly"
brand="Visa"
last4="1777"
active
badge="Current plan"
gradient="linear-gradient(135deg, #aa5ea4 0%, #2d254a 40%, #494281 90%)"
/>
<PlanCard <div className="max-w-xl">
name="Gen Teams" {/* Plan Card */}
price="$19.99 / month" <div
cadence="Team plan — coming soon" className="rounded-3xl p-6 border border-white/10 relative overflow-hidden"
brand="—" style={{
last4="0000" backgroundImage: `url("${PLAN_CARD_NOISE}")`,
gradient="linear-gradient(135deg, #000000 0%, #2d254a 40%, #494281 90%)" background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
badge="Coming soon" backgroundBlendMode: "overlay, normal",
/> }}
</div> >
<div className="space-y-6 self-stretch w-full h-fit"> {isSubscribed && (
<UsageBar <span className="absolute top-4 right-4 text-xs px-3 py-1 rounded-full bg-teal-500/20 text-teal-400 border border-teal-500/30">
icon={<BadgeDollarSign className="w-4 h-4 text-white/80" />} Active
label="standard credits" </span>
current={875} )}
total={1000}
gradient="linear-gradient(90deg, #7da2ff, #a36bff, #d870ff)" <div className="flex items-center gap-3 mb-4">
/> <Sparkles className="w-6 h-6 text-teal-400" />
<UsageBar <h3 className="text-2xl font-bold text-white">Linsa Pro</h3>
icon={<Gem className="w-4 h-4 text-white/80" />}
label="premium credits"
current={56}
total={100}
gradient="linear-gradient(90deg, #ff7dcf, #7df3ff, #4f5bff)"
/>
<div className="flex flex-row gap-2">
<button
type="button"
style={{
backgroundImage: `url("${CHANGE_PLAN_BACKGROUND}")`,
}}
className="w-full text-sm font-medium text-white bg-white/5 shadow-inner shadow-neutral-800/65 hover:bg-white/10 border border-white/10 transition-colors rounded-lg py-2 bg-cover bg-center bg-no-repeat"
>
Change plan
</button>
<button
type="button"
style={{
backgroundImage: `url("${CHANGE_PLAN_BACKGROUND}")`,
}}
className="w-full text-sm font-medium text-white bg-blue-200/15 hover:bg-blue-100/20 shadow-inner shadow-neutral-800/65 border border-white/5 transition-colors rounded-lg py-2 bg-cover bg-center bg-no-repeat"
>
Get more credits
</button>
</div> </div>
</div>
</div>
<BillingStatusNew /> <div className="flex items-baseline gap-1 mb-6">
<span className="text-4xl font-bold text-white">$8</span>
<span className="text-white/60">/ month</span>
</div>
<ul className="space-y-3 mb-6">
<li className="flex items-center gap-3 text-white/90">
<Check className="w-5 h-5 text-teal-400 shrink-0" />
<span>Unlimited bookmark saving</span>
</li>
<li className="flex items-center gap-3 text-white/90">
<Check className="w-5 h-5 text-teal-400 shrink-0" />
<span>Access to all stream archives</span>
</li>
<li className="flex items-center gap-3 text-white/90">
<Check className="w-5 h-5 text-teal-400 shrink-0" />
<span>Priority support</span>
</li>
</ul>
{loading ? (
<div className="h-12 bg-white/5 rounded-xl animate-pulse" />
) : isSubscribed ? (
<button
type="button"
onClick={handleManageBilling}
className="w-full py-3 rounded-xl text-sm font-medium bg-white/10 hover:bg-white/15 text-white border border-white/10 transition-colors"
>
Manage Billing
</button>
) : (
<button
type="button"
onClick={handleSubscribe}
disabled={subscribing}
className="w-full py-3 rounded-xl text-sm font-semibold bg-teal-500 hover:bg-teal-400 text-white transition-colors disabled:opacity-50"
>
{subscribing ? "Loading..." : "Subscribe Now"}
</button>
)}
</div>
{!isSubscribed && !loading && (
<p className="text-center text-white/50 text-sm mt-4">
Cancel anytime. No questions asked.
</p>
)}
</div>
</div> </div>
) )
} }