diff --git a/frontend/public/icon-white.svg b/frontend/public/icon-white.svg new file mode 100644 index 000000000..eeae08b41 --- /dev/null +++ b/frontend/public/icon-white.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45e3d03ce..cf289cc1d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { ServerInfoProvider } from "./contexts/ServerInfoContext"; import { CloudProvider } from "./lib/cloudContext"; import { CloudStatusProvider } from "./hooks/useCloudStatus"; import { OnboardingProvider } from "./contexts/OnboardingContext"; +import { BillingProvider } from "./contexts/BillingContext"; import { handleOAuthCallback, initElectronAuthListener, @@ -107,20 +108,22 @@ function App() { return ( - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 57ef860a8..aeea31757 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -6,12 +6,40 @@ import { Plug, Workflow, Monitor, + Menu as MenuIcon, + AlertTriangle, + HelpCircle, + ExternalLink, } from "lucide-react"; import { Button } from "./ui/button"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; import { SettingsDialog } from "./SettingsDialog"; import { PluginsDialog } from "./PluginsDialog"; +import { PaywallModal } from "./PaywallModal"; import { toast } from "sonner"; import { useCloudStatus } from "../hooks/useCloudStatus"; +import { useBilling } from "../contexts/BillingContext"; +import { isAuthenticated, redirectToSignIn } from "../lib/auth"; +import { openExternalUrl } from "../lib/openExternal"; +import { useOnboarding } from "../contexts/OnboardingContext"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +const DAYDREAM_APP_BASE = + (import.meta.env.VITE_DAYDREAM_APP_BASE as string | undefined) || + "https://app.daydream.live"; + interface HeaderProps { className?: string; onPipelinesRefresh?: () => Promise; @@ -45,7 +73,7 @@ export function Header({ const [settingsOpen, setSettingsOpen] = useState(false); const [pluginsOpen, setPluginsOpen] = useState(false); const [initialTab, setInitialTab] = useState< - "general" | "account" | "api-keys" | "loras" | "osc" + "general" | "account" | "api-keys" | "loras" | "osc" | "billing" >("general"); const [initialPluginPath, setInitialPluginPath] = useState(""); const [pluginsInitialTab, setPluginsInitialTab] = useState< @@ -56,12 +84,34 @@ export function Header({ const { isConnected, isConnecting, lastCloseCode, lastCloseReason } = useCloudStatus(); + // Billing state + const billing = useBilling(); + + // Onboarding state — used to determine if upgrade CTA should show + const { state: onboardingState } = useOnboarding(); + + // Auth state — reactive to sign-in / sign-out + const [isSignedIn, setIsSignedIn] = useState(() => isAuthenticated()); + + useEffect(() => { + const handleAuthChange = () => setIsSignedIn(isAuthenticated()); + window.addEventListener("daydream-auth-change", handleAuthChange); + window.addEventListener("daydream-auth-success", handleAuthChange); + return () => { + window.removeEventListener("daydream-auth-change", handleAuthChange); + window.removeEventListener("daydream-auth-success", handleAuthChange); + }; + }, []); + // Track the last close code we've shown a toast for to avoid duplicates const lastNotifiedCloseCodeRef = useRef(null); // Only show "connection lost" after we've seen a successful connection this session const hasBeenConnectedRef = useRef(false); + // Track whether the user has clicked the cloud button this session + const [hasClickedCloud, setHasClickedCloud] = useState(false); + // Track previous connection state to detect transitions for pipeline refresh const prevConnectedRef = useRef(false); @@ -104,6 +154,7 @@ export function Header({ }, [isConnected, onPipelinesRefresh]); const handleCloudIconClick = () => { + setHasClickedCloud(true); setInitialTab("account"); setSettingsOpen(true); }; @@ -121,6 +172,7 @@ export function Header({ | "api-keys" | "loras" | "osc" + | "billing" ); setSettingsOpen(true); } @@ -164,35 +216,41 @@ export function Header({
-

- Daydream Scope -

+ Daydream Scope {onGraphModeToggle && ( - + + + Workflow + + + + Perform + + )}
@@ -202,7 +260,7 @@ export function Header({ onClick={handleCloudIconClick} className={`hover:opacity-80 transition-opacity h-8 gap-1.5 px-2 ${ isConnected - ? "text-green-500 opacity-100" + ? "text-emerald-600 opacity-100" : isConnecting ? "text-amber-400 opacity-100" : "text-muted-foreground opacity-80" @@ -230,44 +288,158 @@ export function Header({ : "Connect to Cloud"} - - - + {/* Upgrade CTA / Plan badge — only show when user has cloud intent */} + {(onboardingState.inferenceMode === "cloud" || + hasClickedCloud || + isConnected || + isConnecting) && ( + <> + {!isSignedIn ? ( + + ) : billing.tier === "free" ? ( + + ) : ( + + )} + + )} + {/* Billing unavailable fallback */} + {isConnected && billing.billingError && !billing.credits && ( + + + Billing unavailable + + )} + {/* Menu dropdown: credits, nodes, workflows, settings */} + + + + + + {isSignedIn && billing.credits && ( + <> +
+ + + {billing.credits.balance.toFixed(2)} + {" "} + credits remaining + + + + + + + Daydream Cloud inference requires credit purchases. + For more information, please refer to our{" "} + { + e.preventDefault(); + openExternalUrl( + "https://daydream.live/pricing" + ); + }} + > + Pricing page + + . + + + + + {(billing.tier === "pro" || billing.tier === "max") && ( + + )} +
+ + + )} + { + setPluginsInitialTab("discover"); + setPluginsOpen(true); + }} + > + + Nodes + + { + setPluginsInitialTab("workflows"); + setPluginsOpen(true); + }} + > + + Workflows + + setSettingsOpen(true)} + > + + Settings + +
+
@@ -288,6 +460,8 @@ export function Header({ onPipelinesRefresh={onPipelinesRefresh} cloudDisabled={cloudDisabled} /> + +
); } diff --git a/frontend/src/components/PaywallModal.tsx b/frontend/src/components/PaywallModal.tsx new file mode 100644 index 000000000..5ccb4f283 --- /dev/null +++ b/frontend/src/components/PaywallModal.tsx @@ -0,0 +1,229 @@ +import { useState } from "react"; +import { ExternalLink } from "lucide-react"; +import { Dialog, DialogContent } from "./ui/dialog"; +import { Button } from "./ui/button"; +import { useBilling } from "../contexts/BillingContext"; +import { redeemCreditCode } from "../lib/billing"; +import { getDaydreamAPIKey } from "../lib/auth"; +import { openExternalUrl } from "../lib/openExternal"; +import { toast } from "sonner"; + +const DASHBOARD_USAGE_URL = "https://app.daydream.live/dashboard/usage"; + +const TIERS = [ + { + id: "pro" as const, + name: "Pro", + creditsPerMo: "500 credits/mo", + hours: "~6 hrs on RTX 4090", + description: "Great for getting started with regular creative sessions.", + recommended: false, + }, + { + id: "max" as const, + name: "Max", + creditsPerMo: "1,750 credits/mo", + hours: "~23 hrs on RTX 4090", + description: "For creators who stream or iterate heavily every week.", + recommended: true, + }, +]; + +function getHeadline( + reason: "credits_exhausted" | "subscribe" | null, + isSubscribed: boolean +): string { + if (isSubscribed) return "You've run out of credits"; + switch (reason) { + case "credits_exhausted": + return "You've run out of credits"; + case "subscribe": + return "Choose a plan"; + default: + return "Subscribe to continue"; + } +} + +function getSubcopy( + reason: "credits_exhausted" | "subscribe" | null, + isSubscribed: boolean +): string { + if (isSubscribed) { + return "To continue generating, please purchase additional credits or enable auto-top-up."; + } + switch (reason) { + case "credits_exhausted": + return "To continue generating, please choose a subscription."; + default: + return "Choose a plan to continue generating."; + } +} + +export function PaywallModal() { + const { showPaywall, setShowPaywall, paywallReason, tier, refresh } = + useBilling(); + + const [redeemCode, setRedeemCode] = useState(""); + const [isRedeeming, setIsRedeeming] = useState(false); + + const isSubscribed = tier === "pro" || tier === "max"; + + const handleSubscribe = (_tierId: "pro" | "max") => { + openExternalUrl(DASHBOARD_USAGE_URL); + setShowPaywall(false); + }; + + const handleManageSubscription = () => { + openExternalUrl(DASHBOARD_USAGE_URL); + setShowPaywall(false); + }; + + const handleRunLocally = async () => { + try { + await fetch("/api/v1/cloud/disconnect", { method: "POST" }); + toast.info("Switched to local inference"); + } catch { + // Cloud may already be disconnected + } + setShowPaywall(false); + }; + + const handleRedeem = async () => { + const trimmed = redeemCode.trim(); + if (!trimmed) return; + + setIsRedeeming(true); + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) { + toast.error("Please sign in to redeem a code"); + return; + } + const result = await redeemCreditCode(apiKey, trimmed); + toast.success( + `${result.credits} credits added${result.label ? ` — ${result.label}` : ""}` + ); + setRedeemCode(""); + refresh(); + setShowPaywall(false); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to redeem code"); + } finally { + setIsRedeeming(false); + } + }; + + return ( + !open && setShowPaywall(false)} + > + +
+
+

+ {getHeadline(paywallReason, isSubscribed)} +

+

+ {getSubcopy(paywallReason, isSubscribed)} +

+
+ + {isSubscribed ? ( +
+ +
+ ) : ( +
+ {TIERS.map(tier => ( +
+ {tier.recommended && ( + + Recommended + + )} +
+ + {tier.name} + + + {tier.creditsPerMo} + +
+

+ {tier.hours} +

+

+ {tier.description} +

+ +
+ ))} +
+ )} + + {/* Redeem code — show when credits exhausted */} + {paywallReason === "credits_exhausted" && ( +
+ setRedeemCode(e.target.value.toUpperCase())} + onKeyDown={e => e.key === "Enter" && handleRedeem()} + placeholder="Have a code? DD-XXXX-XXXX" + className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + disabled={isRedeeming} + /> + +
+ )} + +
+ +
+ +

+ Questions?{" "} + + Contact support + +

+
+
+
+ ); +} diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 33c33ebc7..83cc6fcef 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -11,6 +11,7 @@ import { LoRAsTab } from "./settings/LoRAsTab"; import { OscTab } from "./settings/OscTab"; import { DmxTab } from "./settings/DmxTab"; import { ShortcutsTab } from "./settings/ShortcutsTab"; +import { BillingTab } from "./settings/BillingTab"; import { installLoRAFile, deleteLoRAFile } from "@/lib/api"; import { useServerInfoContext } from "@/contexts/ServerInfoContext"; import { toast } from "sonner"; @@ -21,6 +22,7 @@ interface SettingsDialogProps { initialTab?: | "general" | "account" + | "billing" | "api-keys" | "loras" | "osc" @@ -146,6 +148,12 @@ export function SettingsDialog({ > Account + + Billing + + + + diff --git a/frontend/src/components/TransitionBanner.tsx b/frontend/src/components/TransitionBanner.tsx new file mode 100644 index 000000000..d1046adc9 --- /dev/null +++ b/frontend/src/components/TransitionBanner.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; +import { X } from "lucide-react"; +import { useBilling } from "../contexts/BillingContext"; + +const DISMISSED_KEY = "billing_transition_dismissed_at"; +const DISMISS_DURATION_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +function isDismissed(): boolean { + try { + const ts = localStorage.getItem(DISMISSED_KEY); + if (!ts) return false; + return Date.now() - Number(ts) < DISMISS_DURATION_MS; + } catch { + return false; + } +} + +export function TransitionBanner() { + const { tier, credits } = useBilling(); + const [dismissed, setDismissed] = useState(isDismissed); + + // Only show for users with welcome grant credits but no subscription + const hasWelcomeCredits = + tier === "free" && credits !== null && credits.balance > 0; + + if (dismissed || !hasWelcomeCredits) return null; + + const handleDismiss = () => { + setDismissed(true); + try { + localStorage.setItem(DISMISSED_KEY, String(Date.now())); + } catch { + // ignore + } + }; + + return ( +
+

+ Scope now uses credits for cloud inference. You've been granted{" "} + + {Math.round(credits.balance)} free credits + + . +

+ +
+ ); +} diff --git a/frontend/src/components/VideoOutput.tsx b/frontend/src/components/VideoOutput.tsx index 78fe2d76e..2fa704ad1 100644 --- a/frontend/src/components/VideoOutput.tsx +++ b/frontend/src/components/VideoOutput.tsx @@ -3,6 +3,8 @@ import { Volume2, VolumeX } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Spinner } from "./ui/spinner"; import { PlayOverlay } from "./ui/play-overlay"; +import { useCloudStatus } from "../hooks/useCloudStatus"; +import { useBilling } from "../contexts/BillingContext"; interface VideoOutputProps { className?: string; @@ -48,6 +50,9 @@ export function VideoOutput({ videoContainerRef, videoScaleMode = "fit", }: VideoOutputProps) { + const { isConnected: isCloudActive } = useCloudStatus(); + const billing = useBilling(); + const videoRef = useRef(null); const internalContainerRef = useRef(null); const [showOverlay, setShowOverlay] = useState(false); @@ -296,6 +301,11 @@ export function VideoOutput({ onClick={onStartStream} size="lg" variant="themed" + costLabel={ + isCloudActive && billing.creditsPerMin > 0 + ? `Run for ${billing.creditsPerMin} credits/min` + : undefined + } data-testid="start-stream-button" aria-label="Start stream" /> diff --git a/frontend/src/components/graph/GraphToolbar.tsx b/frontend/src/components/graph/GraphToolbar.tsx index 47239434e..bb9b2a43d 100644 --- a/frontend/src/components/graph/GraphToolbar.tsx +++ b/frontend/src/components/graph/GraphToolbar.tsx @@ -7,8 +7,6 @@ import { Download, Trash2, Loader2, - Settings, - Plug, RotateCcw, } from "lucide-react"; import { NODE_TOKENS } from "./ui"; @@ -58,8 +56,6 @@ export function GraphToolbar({ onExport, onClear, onDefaultWorkflow, - onOpenSettings, - onOpenPlugins, fileInputRef: externalFileInputRef, }: GraphToolbarProps) { const internalFileInputRef = useRef(null); @@ -78,7 +74,7 @@ export function GraphToolbar({ @@ -101,15 +97,6 @@ export function GraphToolbar({ )} - - - Settings - - - - Nodes - - Default Workflow diff --git a/frontend/src/components/onboarding/InferenceModeStep.tsx b/frontend/src/components/onboarding/InferenceModeStep.tsx index ffbb9be78..7aabf0101 100644 --- a/frontend/src/components/onboarding/InferenceModeStep.tsx +++ b/frontend/src/components/onboarding/InferenceModeStep.tsx @@ -20,7 +20,7 @@ const MODES: { icon: Cloud, title: "Use Daydream Cloud", description: "Use cloud GPU provided by Daydream", - detail: "Requires Pro account after free trial", + detail: "Requires credits — get started with free credits", }, { mode: "local", diff --git a/frontend/src/components/settings/BillingTab.tsx b/frontend/src/components/settings/BillingTab.tsx new file mode 100644 index 000000000..42db7fcea --- /dev/null +++ b/frontend/src/components/settings/BillingTab.tsx @@ -0,0 +1,306 @@ +import { useState } from "react"; +import { useBilling } from "../../contexts/BillingContext"; +import { redeemCreditCode } from "../../lib/billing"; +import { getDaydreamAPIKey } from "../../lib/auth"; +import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { toast } from "sonner"; + +function RedeemCodeSection({ onRedeemed }: { onRedeemed: () => void }) { + const [code, setCode] = useState(""); + const [isRedeeming, setIsRedeeming] = useState(false); + + const handleRedeem = async () => { + const trimmed = code.trim(); + if (!trimmed) return; + + setIsRedeeming(true); + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) { + toast.error("Please sign in to redeem a code"); + return; + } + const result = await redeemCreditCode(apiKey, trimmed); + toast.success( + `${result.credits} credits added${result.label ? ` — ${result.label}` : ""}` + ); + setCode(""); + onRedeemed(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to redeem code"); + } finally { + setIsRedeeming(false); + } + }; + + return ( +
+
Redeem Code
+
+ setCode(e.target.value.toUpperCase())} + onKeyDown={e => e.key === "Enter" && handleRedeem()} + placeholder="DD-XXXX-XXXX" + className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + disabled={isRedeeming} + /> + +
+

+ Enter a credit code to add credits to your balance. +

+
+ ); +} + +export function BillingTab() { + const { + tier, + credits, + subscription, + creditsPerMin, + toggleOverage, + refresh, + openCheckout, + } = useBilling(); + + const [showOverageConfirm, setShowOverageConfirm] = useState(false); + + const handleSubscribe = () => { + openCheckout("pro"); + }; + + const estimatedMinutes = + credits && creditsPerMin > 0 && credits.balance > 0 + ? Math.round(credits.balance / creditsPerMin) + : null; + + if (tier === "free") { + return ( +
+

+ Subscription & Billing +

+

+ You're on the free plan. Subscribe to get credits for Daydream Cloud. +

+ {credits && credits.balance > 0 && ( +
+ + {Math.round(credits.balance)} + {" "} + welcome credits remaining + {estimatedMinutes !== null && ( + + {" "} + (~{estimatedMinutes} min) + + )} +
+ )} + +
+ +
+

+ Questions about billing?{" "} + + Contact support + +

+
+ ); + } + + const tierLabel = tier === "pro" ? "Pro" : "Max"; + const tierPrice = tier === "pro" ? "$10/mo" : "$30/mo"; + const renewDate = subscription?.currentPeriodEnd + ? new Date(subscription.currentPeriodEnd).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }) + : "—"; + + return ( +
+

+ Subscription & Billing +

+ + {/* Plan info */} +
+
+ {tierLabel} + — {tierPrice} + {subscription?.cancelAtPeriodEnd && ( + + Cancels {renewDate} + + )} + {!subscription?.cancelAtPeriodEnd && ( + + Renews {renewDate} + + )} +
+
+ + {/* Credits */} + {credits && ( +
+
+ + {Math.round(credits.balance)} + {" "} + of {Math.round(credits.periodCredits)} credits + {estimatedMinutes !== null && ( + + {" "} + (~{estimatedMinutes} min remaining) + + )} +
+
+
credits.periodCredits * 0.2 + ? "bg-green-500" + : credits.balance > credits.periodCredits * 0.05 + ? "bg-amber-400" + : "bg-red-500" + }`} + style={{ + width: `${Math.min(100, (credits.balance / credits.periodCredits) * 100)}%`, + }} + /> +
+ {creditsPerMin > 0 && ( +

+ Current rate: {creditsPerMin} credits/min +

+ )} +
+ )} + + {/* Overage toggle */} +
+ { + if (checked) { + setShowOverageConfirm(true); + } else { + toggleOverage(false); + } + }} + className="mt-0.5" + /> +
+
+ Overage billing +
+

+ When your monthly credits run out, automatically add 500 credits for + $10 (up to 5 times per cycle, $50 max). +

+
+
+ + {/* Overage confirmation dialog */} + + + + Enable overage billing? + + When your monthly credits run out, you'll be automatically charged{" "} + + $10 for 500 additional credits + + . This can happen up to{" "} + + 5 times per billing cycle ($50 max) + + . You can disable this anytime in Settings. + + + + Cancel + { + toggleOverage(true); + setShowOverageConfirm(false); + }} + > + Enable Overage + + + + + + {/* Actions */} +
+ +
+ + {/* Redeem code */} +
+ +
+ + {/* Help & support */} +

+ Questions about billing?{" "} + + Contact support + + {" · "} + + How credits work + +

+
+ ); +} diff --git a/frontend/src/components/settings/DaydreamAccountSection.tsx b/frontend/src/components/settings/DaydreamAccountSection.tsx index 4885f738f..5b2232db8 100644 --- a/frontend/src/components/settings/DaydreamAccountSection.tsx +++ b/frontend/src/components/settings/DaydreamAccountSection.tsx @@ -220,7 +220,7 @@ export function DaydreamAccountSection({ />

- Use remote inference for running pipelines. + Use Daydream Cloud inference for running workflows. {!isSignedIn && !(status.connected || status.connecting) && " Log in required."} diff --git a/frontend/src/components/ui/play-overlay.tsx b/frontend/src/components/ui/play-overlay.tsx index e198c9dd9..5c6ced030 100644 --- a/frontend/src/components/ui/play-overlay.tsx +++ b/frontend/src/components/ui/play-overlay.tsx @@ -6,6 +6,7 @@ interface PlayOverlayProps { size?: "sm" | "md" | "lg"; className?: string; variant?: "default" | "themed"; + costLabel?: string; "data-testid"?: string; "aria-label"?: string; } @@ -34,6 +35,7 @@ export function PlayOverlay({ size = "lg", className = "", variant = "default", + costLabel, "data-testid": dataTestId, "aria-label": ariaLabel, }: PlayOverlayProps) { @@ -46,15 +48,22 @@ export function PlayOverlay({ if (variant === "themed") { return ( -

- {isPlaying ? ( - - ) : ( - +
+
+ {isPlaying ? ( + + ) : ( + + )} +
+ {costLabel && !isPlaying && ( + + {costLabel} + )}
); diff --git a/frontend/src/contexts/BillingContext.tsx b/frontend/src/contexts/BillingContext.tsx new file mode 100644 index 000000000..6c7a915f4 --- /dev/null +++ b/frontend/src/contexts/BillingContext.tsx @@ -0,0 +1,392 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, + type ReactNode, +} from "react"; +import { + fetchCreditsBalance, + createPortalSession, + setOverageEnabled, + requestInferenceToken, +} from "../lib/billing"; + +const SUBSCRIBE_URL = "https://app.daydream.live/dashboard/usage"; +import { getDaydreamAPIKey } from "../lib/auth"; +import { getDeviceId } from "../lib/deviceId"; +import { openExternalUrl } from "../lib/openExternal"; +import { useCloudStatus } from "../hooks/useCloudStatus"; +import { toast } from "sonner"; + +// Default GPU type used for rate lookups when the cloud backend doesn't expose +// one. Scope cloud streams currently default to h100, the highest tier; using +// it keeps the displayed cost a safe upper bound. +const DEFAULT_GPU_TYPE = "h100"; + +export interface BillingState { + tier: "free" | "pro" | "max"; + credits: { balance: number; periodCredits: number } | null; + subscription: { + status: string; + currentPeriodEnd: string; + cancelAtPeriodEnd: boolean; + overageEnabled: boolean; + } | null; + creditsPerMin: number; + allRates: Record | null; + isLoading: boolean; + billingError: boolean; +} + +interface BillingContextValue extends BillingState { + refresh: () => Promise; + openCheckout: (tier: "pro" | "max") => Promise; + openPortal: () => Promise; + toggleOverage: (enabled: boolean) => Promise; + showPaywall: boolean; + setShowPaywall: (show: boolean) => void; + paywallReason: "credits_exhausted" | "subscribe" | null; + setPaywallReason: (reason: "credits_exhausted" | "subscribe" | null) => void; + /** Get a valid inference token (requests new one if expired) */ + getInferenceToken: () => Promise; +} + +const defaultState: BillingContextValue = { + tier: "free", + credits: null, + subscription: null, + creditsPerMin: 7.5, + allRates: null, + isLoading: true, + billingError: false, + refresh: async () => {}, + openCheckout: async () => {}, + openPortal: async () => {}, + toggleOverage: async () => {}, + showPaywall: false, + setShowPaywall: () => {}, + paywallReason: null, + setPaywallReason: () => {}, + getInferenceToken: async () => null, +}; + +const BillingContext = createContext(defaultState); + +export function useBilling() { + return useContext(BillingContext); +} + +export function BillingProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + tier: "free", + credits: null, + subscription: null, + creditsPerMin: 7.5, + allRates: null, + isLoading: true, + billingError: false, + }); + const [showPaywall, setShowPaywall] = useState(false); + const [paywallReason, setPaywallReason] = useState< + "credits_exhausted" | "subscribe" | null + >(null); + + const { isConnected } = useCloudStatus(); + const pollRef = useRef | null>(null); + const bgPollRef = useRef | null>(null); + + // Inference token cache — refresh before 5-min expiry + const inferenceTokenRef = useRef<{ + token: string; + expiresAt: number; + } | null>(null); + + // Warning thresholds (tracked so we only toast once per threshold) + const creditWarningShown = useRef<"none" | "low" | "critical" | "grace">( + "none" + ); + const upsellShown = useRef(false); + + const refresh = useCallback(async () => { + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) { + setState(prev => ({ ...prev, isLoading: false })); + return; + } + + const deviceId = getDeviceId(); + const data = await fetchCreditsBalance(apiKey, deviceId); + // creditsPerMin can be a number (old API) or Record (new API) + const rawRate = data.creditsPerMin; + const rateMap = + typeof rawRate === "object" && rawRate !== null + ? (rawRate as Record) + : null; + const scopeRate = rateMap + ? (rateMap[DEFAULT_GPU_TYPE] ?? rateMap.h100 ?? 7.5) + : (rawRate as number); + + setState({ + tier: data.tier, + credits: data.credits, + subscription: data.subscription, + creditsPerMin: scopeRate, + allRates: rateMap, + isLoading: false, + billingError: false, + }); + } catch (err) { + console.error("[Billing] Failed to refresh:", err); + setState(prev => ({ ...prev, isLoading: false, billingError: true })); + } + }, []); + + // Initial load + react to auth changes (sign-in / sign-out / token refresh). + // Independent of cloud status: a signed-in user should see their plan and + // credit balance even when they aren't actively streaming. + useEffect(() => { + refresh(); + const handler = () => { + refresh(); + }; + window.addEventListener("daydream-auth-change", handler); + window.addEventListener("daydream-auth-success", handler); + window.addEventListener("daydream-auth-error", handler); + return () => { + window.removeEventListener("daydream-auth-change", handler); + window.removeEventListener("daydream-auth-success", handler); + window.removeEventListener("daydream-auth-error", handler); + }; + }, [refresh]); + + // Poll balance every 15s while cloud-connected (live credit drain updates). + useEffect(() => { + if (isConnected) { + refresh(); + pollRef.current = setInterval(refresh, 15_000); + } else { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + } + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, [isConnected, refresh]); + + // Background poll every 30s when authenticated but not streaming, so top-ups, + // subscription changes, and credit deductions are reflected in the UI. + // Pauses when the window is hidden to save resources. + useEffect(() => { + if (isConnected) { + // Fast poll above handles this case — skip background poll. + if (bgPollRef.current) clearInterval(bgPollRef.current); + bgPollRef.current = null; + return; + } + + const apiKey = getDaydreamAPIKey(); + if (!apiKey) return; + + const startBgPoll = () => { + if (bgPollRef.current) clearInterval(bgPollRef.current); + bgPollRef.current = setInterval(refresh, 30_000); + }; + + const stopBgPoll = () => { + if (bgPollRef.current) clearInterval(bgPollRef.current); + bgPollRef.current = null; + }; + + const onVisibility = () => { + if (document.hidden) { + stopBgPoll(); + } else { + refresh(); // Immediately refresh when tab becomes visible + startBgPoll(); + } + }; + + if (!document.hidden) startBgPoll(); + document.addEventListener("visibilitychange", onVisibility); + + return () => { + stopBgPoll(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [isConnected, refresh]); + + // Low credit warnings — toast once per threshold, with grace period warning + useEffect(() => { + if (!isConnected || !state.credits || state.tier === "free") { + creditWarningShown.current = "none"; + upsellShown.current = false; + return; + } + const { balance, periodCredits } = state.credits; + const pct = periodCredits > 0 ? balance / periodCredits : 1; + const minutesLeft = + state.creditsPerMin > 0 ? Math.round(balance / state.creditsPerMin) : 0; + + // Grace period: ~1 min of credits left — warn that stream will end soon + if ( + minutesLeft <= 1 && + balance > 0 && + creditWarningShown.current !== "grace" + ) { + creditWarningShown.current = "grace"; + toast.warning( + "Your stream will end in about 1 minute. Add credits to keep going.", + { + duration: 60000, + action: { + label: "Add Credits", + onClick: () => { + setPaywallReason("credits_exhausted"); + setShowPaywall(true); + }, + }, + } + ); + } else if ( + pct <= 0.05 && + creditWarningShown.current !== "critical" && + creditWarningShown.current !== "grace" + ) { + creditWarningShown.current = "critical"; + toast.warning( + `Credits critically low — ${Math.round(balance)} credits remaining (~${minutesLeft} min)`, + { duration: 10000 } + ); + } else if ( + pct <= 0.15 && + pct > 0.05 && + creditWarningShown.current === "none" + ) { + creditWarningShown.current = "low"; + toast.warning( + `Credits running low — ${Math.round(balance)} credits remaining (~${minutesLeft} min)` + ); + } + + // Proactive upsell at 80% usage for Pro tier + if ( + state.tier === "pro" && + pct <= 0.2 && + pct > 0.05 && + !upsellShown.current + ) { + upsellShown.current = true; + toast.info( + "Running low on credits? Upgrade to Max for more credits per month.", + { duration: 8000 } + ); + } + }, [isConnected, state.credits, state.tier, state.creditsPerMin]); + + // Listen for credits-exhausted events from API error handling + useEffect(() => { + const handler = () => { + setPaywallReason("credits_exhausted"); + setShowPaywall(true); + }; + window.addEventListener("billing:credits-exhausted", handler); + return () => + window.removeEventListener("billing:credits-exhausted", handler); + }, []); + + const getInferenceToken = useCallback(async (): Promise => { + // Return cached token if still valid (with 60s buffer) + const cached = inferenceTokenRef.current; + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.token; + } + + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) return null; + const deviceId = getDeviceId(); + const result = await requestInferenceToken(apiKey, deviceId); + + if (!result.authorized || !result.token) { + inferenceTokenRef.current = null; + return null; + } + + inferenceTokenRef.current = { + token: result.token, + expiresAt: new Date(result.expiresAt!).getTime(), + }; + return result.token; + } catch (err) { + console.error("[Billing] Failed to get inference token:", err); + return null; + } + }, []); + + const openPortal = useCallback(async () => { + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) { + toast.error("Please sign in first"); + return; + } + const { portalUrl } = await createPortalSession(apiKey); + openExternalUrl(portalUrl); + toast.info("Opening subscription management in your browser..."); + } catch (err) { + console.error("[Billing] Portal failed:", err); + const msg = err instanceof Error ? err.message : "Unknown error"; + toast.error(`Failed to open subscription management: ${msg}`, { + description: "If this persists, contact support@daydream.live", + }); + } + }, []); + + const openCheckout = useCallback(async (_tier: "pro" | "max") => { + openExternalUrl(SUBSCRIBE_URL); + }, []); + + const toggleOverage = useCallback( + async (enabled: boolean) => { + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) return; + await setOverageEnabled(apiKey, enabled); + toast.success( + enabled ? "Overage billing enabled" : "Overage billing disabled" + ); + await refresh(); + } catch (err) { + console.error("[Billing] Overage toggle failed:", err); + const msg = err instanceof Error ? err.message : "Unknown error"; + toast.error(`Failed to update overage setting: ${msg}`, { + description: "If this persists, contact support@daydream.live", + }); + } + }, + [refresh] + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx index 2c6141bc8..290afe447 100644 --- a/frontend/src/contexts/OnboardingContext.tsx +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -66,6 +66,7 @@ type OnboardingAction = type: "LOADED"; completed: boolean; onboardingStyle?: "teaching" | "simple" | null; + inferenceMode?: "local" | "cloud" | null; }; // --------------------------------------------------------------------------- @@ -184,6 +185,7 @@ function reducer( ...state, phase: "idle", onboardingStyle: action.onboardingStyle ?? null, + inferenceMode: action.inferenceMode ?? null, }; // Check if we're resuming after an auth redirect (sessionStorage flag // is set right before the redirect and consumed here exactly once) @@ -268,6 +270,8 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { type: "LOADED", completed: status.completed, onboardingStyle: status.onboarding_style ?? null, + inferenceMode: + (status.inference_mode as "local" | "cloud" | null) ?? null, }); }); }, []); diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts new file mode 100644 index 000000000..787565ccd --- /dev/null +++ b/frontend/src/lib/billing.ts @@ -0,0 +1,134 @@ +/** + * Billing API client for communicating with the Daydream API credits endpoints. + */ + +const DAYDREAM_API_BASE = + (import.meta.env.VITE_DAYDREAM_API_BASE as string | undefined) || + "https://api.daydream.live"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface CreditsBalance { + tier: "free" | "pro" | "max"; + credits: { + balance: number; + periodCredits: number; + rolloverBalance?: number; + total?: number; + apiBalance?: number; + lastApiResetMonth?: string | null; + } | null; + subscription: { + status: string; + currentPeriodEnd: string; + cancelAtPeriodEnd: boolean; + overageEnabled: boolean; + } | null; + creditsPerMin: number | Record; +} + +// ─── API functions ─────────────────────────────────────────────────────────── + +function headers(apiKey: string | null): Record { + const h: Record = { "Content-Type": "application/json" }; + if (apiKey) h["Authorization"] = `Bearer ${apiKey}`; + return h; +} + +export async function fetchCreditsBalance( + apiKey: string, + deviceId?: string +): Promise { + const url = deviceId + ? `${DAYDREAM_API_BASE}/credits/balance?deviceId=${encodeURIComponent(deviceId)}` + : `${DAYDREAM_API_BASE}/credits/balance`; + const res = await fetch(url, { headers: headers(apiKey) }); + if (!res.ok) + throw new Error(`Failed to fetch credits balance: ${res.status}`); + return res.json(); +} + +export async function createCheckoutSession( + apiKey: string, + tier: "pro" | "max" +): Promise<{ checkoutUrl: string }> { + const res = await fetch(`${DAYDREAM_API_BASE}/credits/checkout`, { + method: "POST", + headers: headers(apiKey), + body: JSON.stringify({ tier }), + }); + if (!res.ok) + throw new Error(`Failed to create checkout session: ${res.status}`); + return res.json(); +} + +export async function createPortalSession( + apiKey: string +): Promise<{ portalUrl: string }> { + const res = await fetch(`${DAYDREAM_API_BASE}/credits/portal`, { + method: "POST", + headers: headers(apiKey), + }); + if (!res.ok) + throw new Error(`Failed to create portal session: ${res.status}`); + return res.json(); +} + +export async function setOverageEnabled( + apiKey: string, + enabled: boolean +): Promise { + const res = await fetch(`${DAYDREAM_API_BASE}/credits/overage`, { + method: "POST", + headers: headers(apiKey), + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error(`Failed to set overage: ${res.status}`); +} + +export interface RedeemCodeResponse { + credits: number; + label: string | null; + newBalance: number; +} + +// ─── Inference token ────────────────────────────────────────────────────── + +export interface InferenceTokenResponse { + authorized: boolean; + token?: string; + expiresAt?: string; + reason?: string; +} + +export async function requestInferenceToken( + apiKey: string, + deviceId?: string +): Promise { + const res = await fetch(`${DAYDREAM_API_BASE}/credits/inference-token`, { + method: "POST", + headers: headers(apiKey), + body: JSON.stringify({ deviceId }), + }); + // 403 is expected when unauthorized — parse the body, don't throw + if (!res.ok && res.status !== 403) { + throw new Error(`Failed to request inference token: ${res.status}`); + } + return res.json(); +} + +export async function redeemCreditCode( + apiKey: string, + code: string +): Promise { + const res = await fetch(`${DAYDREAM_API_BASE}/credits/codes/redeem`, { + method: "POST", + headers: headers(apiKey), + body: JSON.stringify({ code }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.message ?? `Failed to redeem code: ${res.status}`); + } + return res.json(); +} diff --git a/frontend/src/lib/cloudAdapter.ts b/frontend/src/lib/cloudAdapter.ts index fce477e3b..850a5b198 100644 --- a/frontend/src/lib/cloudAdapter.ts +++ b/frontend/src/lib/cloudAdapter.ts @@ -58,6 +58,19 @@ interface PendingRequest { timeout: ReturnType; } +function dispatchCreditsExhausted(source: string, detail?: unknown): void { + try { + console.warn("[CloudAdapter] credits exhausted detected:", source, detail); + window.dispatchEvent( + new CustomEvent("billing:credits-exhausted", { + detail: { source, ...(detail ? { info: detail } : {}) }, + }) + ); + } catch (err) { + console.error("[CloudAdapter] failed to dispatch credits-exhausted:", err); + } +} + export class CloudAdapter { private ws: WebSocket | null = null; private wsUrl: string; @@ -144,6 +157,17 @@ export class CloudAdapter { this.isReady = false; this.ws = null; + if ( + event.code === 4020 || + (typeof event.reason === "string" && + event.reason.toLowerCase().includes("credit")) + ) { + dispatchCreditsExhausted("ws_close", { + code: event.code, + reason: event.reason, + }); + } + // Reject all pending requests for (const [requestId, pending] of this.pendingRequests) { clearTimeout(pending.timeout); @@ -209,6 +233,20 @@ export class CloudAdapter { } private handleMessage(message: ApiResponse): void { + // Credit-exhaustion push messages from the cloud runner + if ( + message.type === "credits_exhausted" || + message.type === "stream_terminated" + ) { + const reason = (message as unknown as { reason?: string }).reason; + if ( + message.type === "credits_exhausted" || + (typeof reason === "string" && reason.toLowerCase().includes("credit")) + ) { + dispatchCreditsExhausted(message.type, { reason }); + } + } + // Handle response to pending request if (message.request_id && this.pendingRequests.has(message.request_id)) { const pending = this.pendingRequests.get(message.request_id)!; @@ -388,6 +426,12 @@ export class CloudAdapter { ); if (response.status && response.status >= 400) { + if (response.status === 402) { + dispatchCreditsExhausted("http_402", { + path, + error: response.error, + }); + } throw new Error( response.error || `API request failed with status ${response.status}` ); diff --git a/frontend/src/lib/deviceId.ts b/frontend/src/lib/deviceId.ts new file mode 100644 index 000000000..3e8ff8112 --- /dev/null +++ b/frontend/src/lib/deviceId.ts @@ -0,0 +1,24 @@ +const STORAGE_KEY = "daydream_device_id"; + +/** + * Get or create a stable device identifier. + * Persisted in localStorage. Falls back to generating a new UUID on first launch. + */ +export function getDeviceId(): string { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) return stored; + } catch { + // localStorage not available + } + + const id = crypto.randomUUID(); + + try { + localStorage.setItem(STORAGE_KEY, id); + } catch { + // localStorage not available — device ID is ephemeral this session + } + + return id; +}