From 2d3d7e0185b125f1137d4a2cb73713e81745bd81 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 24 Dec 2025 18:18:19 -0800 Subject: [PATCH] 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. --- packages/web/src/lib/stream/status.ts | 4 +- packages/web/src/lib/stripe.ts | 14 +- packages/web/src/routeTree.gen.ts | 21 ++ packages/web/src/routes/api/stream-status.ts | 40 +++ .../web/src/routes/api/stripe/checkout.ts | 22 +- packages/web/src/routes/index.tsx | 2 +- packages/web/src/routes/settings.tsx | 289 ++++++++---------- 7 files changed, 202 insertions(+), 190 deletions(-) create mode 100644 packages/web/src/routes/api/stream-status.ts diff --git a/packages/web/src/lib/stream/status.ts b/packages/web/src/lib/stream/status.ts index eab9473e..f3eb1fab 100644 --- a/packages/web/src/lib/stream/status.ts +++ b/packages/web/src/lib/stream/status.ts @@ -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 */ export async function getStreamStatus(): Promise { try { - const response = await fetch("https://nikiv.dev/api/stream-status", { + const response = await fetch("/api/stream-status", { cache: "no-store", }) if (!response.ok) { diff --git a/packages/web/src/lib/stripe.ts b/packages/web/src/lib/stripe.ts index 3344e26a..d806924e 100644 --- a/packages/web/src/lib/stripe.ts +++ b/packages/web/src/lib/stripe.ts @@ -3,13 +3,13 @@ import Stripe from "stripe" type StripeEnv = { STRIPE_SECRET_KEY?: 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 => { let STRIPE_SECRET_KEY: string | undefined let STRIPE_WEBHOOK_SECRET: string | undefined - let STRIPE_ARCHIVE_PRICE_ID: string | undefined + let STRIPE_PRO_PRICE_ID: string | undefined try { const { getServerContext } = require("@tanstack/react-start/server") as { @@ -18,7 +18,7 @@ const getEnv = (): StripeEnv => { const ctx = getServerContext() STRIPE_SECRET_KEY = ctx?.cloudflare?.env?.STRIPE_SECRET_KEY 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 { // Not in server context } @@ -26,10 +26,10 @@ const getEnv = (): StripeEnv => { STRIPE_SECRET_KEY = STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY STRIPE_WEBHOOK_SECRET = STRIPE_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET - STRIPE_ARCHIVE_PRICE_ID = - STRIPE_ARCHIVE_PRICE_ID ?? process.env.STRIPE_ARCHIVE_PRICE_ID + STRIPE_PRO_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 => { @@ -46,6 +46,6 @@ export const getStripeConfig = () => { const env = getEnv() return { webhookSecret: env.STRIPE_WEBHOOK_SECRET, - archivePriceId: env.STRIPE_ARCHIVE_PRICE_ID, + proPriceId: env.STRIPE_PRO_PRICE_ID, } } diff --git a/packages/web/src/routeTree.gen.ts b/packages/web/src/routeTree.gen.ts index 3ada8858..4fdb61c2 100644 --- a/packages/web/src/routeTree.gen.ts +++ b/packages/web/src/routeTree.gen.ts @@ -28,6 +28,7 @@ import { Route as CanvasCanvasIdRouteImport } from './routes/canvas.$canvasId' import { Route as ArchiveArchiveIdRouteImport } from './routes/archive.$archiveId' import { Route as ApiUsersRouteImport } from './routes/api/users' 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 ApiStreamCommentsRouteImport } from './routes/api/stream-comments' import { Route as ApiStreamRouteImport } from './routes/api/stream' @@ -166,6 +167,11 @@ const ApiUsageEventsRoute = ApiUsageEventsRouteImport.update({ path: '/api/usage-events', getParentRoute: () => rootRouteImport, } as any) +const ApiStreamStatusRoute = ApiStreamStatusRouteImport.update({ + id: '/api/stream-status', + path: '/api/stream-status', + getParentRoute: () => rootRouteImport, +} as any) const ApiStreamReplaysRoute = ApiStreamReplaysRouteImport.update({ id: '/api/stream-replays', path: '/api/stream-replays', @@ -407,6 +413,7 @@ export interface FileRoutesByFullPath { '/api/stream': typeof ApiStreamRoute '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren + '/api/stream-status': typeof ApiStreamStatusRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/archive/$archiveId': typeof ArchiveArchiveIdRoute @@ -469,6 +476,7 @@ export interface FileRoutesByTo { '/api/stream': typeof ApiStreamRoute '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren + '/api/stream-status': typeof ApiStreamStatusRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/archive/$archiveId': typeof ArchiveArchiveIdRoute @@ -533,6 +541,7 @@ export interface FileRoutesById { '/api/stream': typeof ApiStreamRoute '/api/stream-comments': typeof ApiStreamCommentsRoute '/api/stream-replays': typeof ApiStreamReplaysRouteWithChildren + '/api/stream-status': typeof ApiStreamStatusRoute '/api/usage-events': typeof ApiUsageEventsRouteWithChildren '/api/users': typeof ApiUsersRouteWithChildren '/archive/$archiveId': typeof ArchiveArchiveIdRoute @@ -598,6 +607,7 @@ export interface FileRouteTypes { | '/api/stream' | '/api/stream-comments' | '/api/stream-replays' + | '/api/stream-status' | '/api/usage-events' | '/api/users' | '/archive/$archiveId' @@ -660,6 +670,7 @@ export interface FileRouteTypes { | '/api/stream' | '/api/stream-comments' | '/api/stream-replays' + | '/api/stream-status' | '/api/usage-events' | '/api/users' | '/archive/$archiveId' @@ -723,6 +734,7 @@ export interface FileRouteTypes { | '/api/stream' | '/api/stream-comments' | '/api/stream-replays' + | '/api/stream-status' | '/api/usage-events' | '/api/users' | '/archive/$archiveId' @@ -787,6 +799,7 @@ export interface RootRouteChildren { ApiStreamRoute: typeof ApiStreamRoute ApiStreamCommentsRoute: typeof ApiStreamCommentsRoute ApiStreamReplaysRoute: typeof ApiStreamReplaysRouteWithChildren + ApiStreamStatusRoute: typeof ApiStreamStatusRoute ApiUsageEventsRoute: typeof ApiUsageEventsRouteWithChildren ApiUsersRoute: typeof ApiUsersRouteWithChildren I1focusDemoRoute: typeof I1focusDemoRoute @@ -948,6 +961,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiUsageEventsRouteImport 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': { id: '/api/stream-replays' path: '/api/stream-replays' @@ -1404,6 +1424,7 @@ const rootRouteChildren: RootRouteChildren = { ApiStreamRoute: ApiStreamRoute, ApiStreamCommentsRoute: ApiStreamCommentsRoute, ApiStreamReplaysRoute: ApiStreamReplaysRouteWithChildren, + ApiStreamStatusRoute: ApiStreamStatusRoute, ApiUsageEventsRoute: ApiUsageEventsRouteWithChildren, ApiUsersRoute: ApiUsersRouteWithChildren, I1focusDemoRoute: I1focusDemoRoute, diff --git a/packages/web/src/routes/api/stream-status.ts b/packages/web/src/routes/api/stream-status.ts new file mode 100644 index 00000000..72050eef --- /dev/null +++ b/packages/web/src/routes/api/stream-status.ts @@ -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, + }, + }, +}) diff --git a/packages/web/src/routes/api/stripe/checkout.ts b/packages/web/src/routes/api/stripe/checkout.ts index efbb5e18..3ed7898a 100644 --- a/packages/web/src/routes/api/stripe/checkout.ts +++ b/packages/web/src/routes/api/stripe/checkout.ts @@ -23,16 +23,16 @@ export const Route = createFileRoute("/api/stripe/checkout")({ } const stripe = getStripe() - const { archivePriceId } = getStripeConfig() + const { proPriceId } = getStripeConfig() if (!stripe) { console.error("[stripe] Stripe not configured - missing STRIPE_SECRET_KEY") return json({ error: "Stripe not configured" }, 500) } - if (!archivePriceId) { - console.error("[stripe] Price ID not configured - missing STRIPE_ARCHIVE_PRICE_ID") - return json({ error: "Price ID not configured" }, 500) + if (!proPriceId) { + console.error("[stripe] Price ID not configured - missing STRIPE_PRO_PRICE_ID") + return json({ error: "Price ID not configured. Set STRIPE_PRO_PRICE_ID" }, 500) } const database = db() @@ -67,23 +67,17 @@ export const Route = createFileRoute("/api/stripe/checkout")({ 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 successUrl = body.successUrl ?? `${origin}/archive?billing=success` - const cancelUrl = body.cancelUrl ?? `${origin}/archive?billing=canceled` + const successUrl = `${origin}/settings?subscribed=true` + const cancelUrl = `${origin}/settings?canceled=true` - // Create checkout session + // Create checkout session for Linsa Pro ($8/month) const checkoutSession = await stripe.checkout.sessions.create({ customer: stripeCustomerId, mode: "subscription", line_items: [ { - price: archivePriceId, + price: proPriceId, quantity: 1, }, ], diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index ad8322d0..2366e3a9 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -48,7 +48,7 @@ function LandingPage() { useEffect(() => { const checkLiveStatus = async () => { try { - const response = await fetch("https://nikiv.dev/api/stream-status") + const response = await fetch("/api/stream-status") if (response.ok) { const data = await response.json() setIsLive(Boolean(data.isLive)) diff --git a/packages/web/src/routes/settings.tsx b/packages/web/src/routes/settings.tsx index 49edf4b3..283e63f0 100644 --- a/packages/web/src/routes/settings.tsx +++ b/packages/web/src/routes/settings.tsx @@ -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 { authClient } from "@/lib/auth-client" import SettingsPanel from "@/components/Settings-panel" import { - BadgeDollarSign, Check, ChevronDown, - CreditCard, - Gem, LogOut, - Shield, Sparkles, UserRoundPen, Lock, - X, } from "lucide-react" -import { BillingStatusNew } from "@/components/billing" type SectionId = "preferences" | "profile" | "billing" @@ -27,9 +21,6 @@ export const Route = createFileRoute("/settings")({ 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 } 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 ( -
-
-
{icon}
- {current} - - / {total.toLocaleString()} {label} - -
-
-
-
-
- ) -} - -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 ( -
- {badge ? ( - - {badge} - - ) : null} -
- - {name} -
-

{price}

-
- {cadence} - - - - {brand} •••• {last4} - -
- {!active ? ( -
- ) : null} -
- ) -} - 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 (
-
-
- - -
-
- } - label="standard credits" - current={875} - total={1000} - gradient="linear-gradient(90deg, #7da2ff, #a36bff, #d870ff)" - /> - } - label="premium credits" - current={56} - total={100} - gradient="linear-gradient(90deg, #ff7dcf, #7df3ff, #4f5bff)" - /> -
- - +
+ {/* Plan Card */} +
+ {isSubscribed && ( + + Active + + )} + +
+ +

Linsa Pro

-
-
- +
+ $8 + / month +
+ +
    +
  • + + Unlimited bookmark saving +
  • +
  • + + Access to all stream archives +
  • +
  • + + Priority support +
  • +
+ + {loading ? ( +
+ ) : isSubscribed ? ( + + ) : ( + + )} +
+ + {!isSubscribed && !loading && ( +

+ Cancel anytime. No questions asked. +

+ )} +
) }