From 0fc413e41ba8ce2bab2e691002dd5badc8accb13 Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Tue, 14 Apr 2026 00:28:38 +0200 Subject: [PATCH 01/16] test: credits Signed-off-by: gioelecerati --- frontend/src/App.tsx | 31 +- frontend/src/components/ExportDialog.tsx | 6 +- frontend/src/components/Header.tsx | 68 ++- frontend/src/components/LoRAManager.tsx | 2 +- frontend/src/components/PaywallModal.tsx | 218 ++++++++ frontend/src/components/SettingsDialog.tsx | 11 + frontend/src/components/TransitionBanner.tsx | 54 ++ frontend/src/components/VideoOutput.tsx | 10 + frontend/src/components/graph/GraphEditor.tsx | 6 +- .../src/components/settings/BillingTab.tsx | 315 ++++++++++++ .../src/components/settings/DiscoverTab.tsx | 4 +- .../src/components/settings/GeneralTab.tsx | 2 +- frontend/src/components/settings/LoRAsTab.tsx | 2 +- .../src/components/settings/WorkflowsTab.tsx | 4 +- frontend/src/components/ui/play-overlay.tsx | 27 +- frontend/src/contexts/BillingContext.tsx | 467 ++++++++++++++++++ frontend/src/lib/api.ts | 2 +- frontend/src/lib/auth.ts | 4 +- frontend/src/lib/billing.ts | 169 +++++++ frontend/src/lib/cloudAdapter.ts | 44 ++ frontend/src/lib/daydreamExport.ts | 2 +- frontend/src/lib/deviceId.ts | 24 + frontend/src/pages/StreamPage.tsx | 6 +- 23 files changed, 1434 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/PaywallModal.tsx create mode 100644 frontend/src/components/TransitionBanner.tsx create mode 100644 frontend/src/components/settings/BillingTab.tsx create mode 100644 frontend/src/contexts/BillingContext.tsx create mode 100644 frontend/src/lib/billing.ts create mode 100644 frontend/src/lib/deviceId.ts 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/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx index 61baa9aa2..9c0ab5afd 100644 --- a/frontend/src/components/ExportDialog.tsx +++ b/frontend/src/components/ExportDialog.tsx @@ -96,12 +96,12 @@ export function ExportDialog({
{isAuthenticated - ? "Export to daydream.live" - : "Log in to export to daydream.live"} + ? "Export to daydream.monster" + : "Log in to export to daydream.monster"} {isAuthenticated - ? "Publish your workflow on daydream.live" + ? "Publish your workflow on daydream.monster" : "Sign in to your Daydream account to publish"}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 57ef860a8..5f7376183 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -6,12 +6,24 @@ import { Plug, Workflow, Monitor, + Clock, + AlertTriangle, } from "lucide-react"; import { Button } from "./ui/button"; 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"; + +function formatTrialTime(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const m = Math.floor(s / 60); + const sec = s % 60; + return `${m}:${String(sec).padStart(2, "0")}`; +} + interface HeaderProps { className?: string; onPipelinesRefresh?: () => Promise; @@ -45,7 +57,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,6 +68,9 @@ export function Header({ const { isConnected, isConnecting, lastCloseCode, lastCloseReason } = useCloudStatus(); + // Billing state + const billing = useBilling(); + // Track the last close code we've shown a toast for to avoid duplicates const lastNotifiedCloseCodeRef = useRef(null); @@ -121,6 +136,7 @@ export function Header({ | "api-keys" | "loras" | "osc" + | "billing" ); setSettingsOpen(true); } @@ -230,6 +246,54 @@ export function Header({ : "Connect to Cloud"} + {/* Subscribe CTA (free tier only) */} + {billing.tier === "free" && !billing.credits && ( + + )} + {isConnected && + billing.tier === "free" && + billing.trial && + !billing.trial.exhausted && ( + + + Trial:{" "} + {formatTrialTime( + billing.trial.secondsLimit - billing.trial.secondsUsed, + )} + + )} + {/* Billing unavailable fallback */} + {isConnected && + billing.billingError && + !billing.credits && + !billing.trial && ( + + + Billing unavailable + + )} + ))} + + + {/* Overage option — only show when credits exhausted and user has subscription */} + {paywallReason === "credits_exhausted" && subscription && ( + + )} + + {/* Redeem code — show for trial exhausted or credits exhausted */} + {(paywallReason === "trial_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/GraphEditor.tsx b/frontend/src/components/graph/GraphEditor.tsx index 15446cc80..51f708f18 100644 --- a/frontend/src/components/graph/GraphEditor.tsx +++ b/frontend/src/components/graph/GraphEditor.tsx @@ -456,14 +456,14 @@ export const GraphEditor = forwardRef( } else { openExternalUrl(result.createUrl); } - toast.success("Opening daydream.live...", { + toast.success("Opening daydream.monster...", { description: - "Your workflow has been sent to daydream.live for publishing.", + "Your workflow has been sent to daydream.monster for publishing.", }); setShowExportDialog(false); } catch (err) { pendingTab?.close(); - console.error("Export to daydream.live failed:", err); + console.error("Export to daydream.monster failed:", err); toast.error("Export failed", { description: err instanceof Error ? err.message : String(err), }); diff --git a/frontend/src/components/settings/BillingTab.tsx b/frontend/src/components/settings/BillingTab.tsx new file mode 100644 index 000000000..837a83f10 --- /dev/null +++ b/frontend/src/components/settings/BillingTab.tsx @@ -0,0 +1,315 @@ +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, + setShowPaywall, + setPaywallReason, + } = useBilling(); + + const [showOverageConfirm, setShowOverageConfirm] = useState(false); + + const handleSubscribe = () => { + setPaywallReason("subscribe"); + setShowPaywall(true); + }; + + 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 remote + inference. +

+ {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/DiscoverTab.tsx b/frontend/src/components/settings/DiscoverTab.tsx index 625601467..e123a62e0 100644 --- a/frontend/src/components/settings/DiscoverTab.tsx +++ b/frontend/src/components/settings/DiscoverTab.tsx @@ -6,10 +6,10 @@ import { Badge } from "../ui/badge"; const DAYDREAM_API_BASE = (import.meta.env.VITE_DAYDREAM_API_BASE as string | undefined) || - "https://api.daydream.live"; + "https://api.daydream.monster"; const DAYDREAM_APP_BASE = (import.meta.env.VITE_DAYDREAM_APP_BASE as string | undefined) || - "https://app.daydream.live"; + "https://app.daydream.monster"; function GitHubIcon({ className }: { className?: string }) { return ( diff --git a/frontend/src/components/settings/GeneralTab.tsx b/frontend/src/components/settings/GeneralTab.tsx index bd67598d8..580a29d3e 100644 --- a/frontend/src/components/settings/GeneralTab.tsx +++ b/frontend/src/components/settings/GeneralTab.tsx @@ -28,7 +28,7 @@ export function GeneralTab({ }: GeneralTabProps) { const handleDocsClick = () => { window.open( - "https://docs.daydream.live/knowledge-hub/tutorials/scope", + "https://docs.daydream.monster/knowledge-hub/tutorials/scope", "_blank" ); }; diff --git a/frontend/src/components/settings/LoRAsTab.tsx b/frontend/src/components/settings/LoRAsTab.tsx index f560f732c..5ae335885 100644 --- a/frontend/src/components/settings/LoRAsTab.tsx +++ b/frontend/src/components/settings/LoRAsTab.tsx @@ -104,7 +104,7 @@ export function LoRAsTab({

Install LoRAs using the URL input above, or follow the{" "} - {isPlaying ? ( - - ) : ( - +

); diff --git a/frontend/src/contexts/BillingContext.tsx b/frontend/src/contexts/BillingContext.tsx new file mode 100644 index 000000000..13734f079 --- /dev/null +++ b/frontend/src/contexts/BillingContext.tsx @@ -0,0 +1,467 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useRef, + type ReactNode, +} from "react"; +import { + fetchCreditsBalance, + sendTrialHeartbeat, + createCheckoutSession, + createPortalSession, + setOverageEnabled, + requestInferenceToken, +} from "../lib/billing"; +import { getDaydreamAPIKey } from "../lib/auth"; +import { getDeviceId } from "../lib/deviceId"; +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; + trial: { + secondsUsed: number; + secondsLimit: number; + exhausted: 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: "trial_exhausted" | "credits_exhausted" | "subscribe" | null; + setPaywallReason: ( + reason: "trial_exhausted" | "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, + trial: 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); +} + +function openExternalUrl(url: string) { + if (typeof window !== "undefined" && "scope" in window) { + (window as any).scope.openExternal(url); + } else { + window.open(url, "_blank"); + } +} + +export function BillingProvider({ children }: { children: ReactNode }) { + const [state, setState] = useState({ + tier: "free", + credits: null, + subscription: null, + trial: null, + creditsPerMin: 7.5, + allRates: null, + isLoading: true, + billingError: false, + }); + const [showPaywall, setShowPaywall] = useState(false); + const [paywallReason, setPaywallReason] = useState< + "trial_exhausted" | "credits_exhausted" | "subscribe" | null + >(null); + + const { isConnected } = useCloudStatus(); + const pollRef = useRef | null>(null); + const heartbeatRef = useRef | null>(null); + const prevTrialExhausted = useRef(false); + + // 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 trialWarningShown = useRef<"none" | "2min" | "30sec">("none"); + const upsellShown = useRef(false); + + const refresh = useCallback(async () => { + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) { + // Not authenticated — try trial status + const deviceId = getDeviceId(); + const { secondsUsed, secondsLimit, exhausted } = + await sendTrialHeartbeat(null, deviceId); + setState(prev => ({ + ...prev, + tier: "free", + credits: null, + subscription: null, + trial: { secondsUsed, secondsLimit, exhausted }, + isLoading: false, + billingError: 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, + trial: data.trial, + 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]); + + // Trial heartbeat every 60s when streaming on free trial + useEffect(() => { + if ( + isConnected && + state.tier === "free" && + state.trial && + !state.trial.exhausted + ) { + heartbeatRef.current = setInterval(async () => { + try { + const apiKey = getDaydreamAPIKey(); + const deviceId = getDeviceId(); + const result = await sendTrialHeartbeat(apiKey, deviceId); + setState(prev => ({ + ...prev, + trial: { + secondsUsed: result.secondsUsed, + secondsLimit: result.secondsLimit, + exhausted: result.exhausted, + }, + })); + } catch (err) { + console.error("[Billing] Trial heartbeat failed:", err); + } + }, 60_000); + } else { + if (heartbeatRef.current) clearInterval(heartbeatRef.current); + heartbeatRef.current = null; + } + return () => { + if (heartbeatRef.current) clearInterval(heartbeatRef.current); + }; + }, [isConnected, state.tier, state.trial?.exhausted]); + + // Show paywall when trial exhausts + useEffect(() => { + if (state.trial?.exhausted && !prevTrialExhausted.current) { + setPaywallReason("trial_exhausted"); + setShowPaywall(true); + } + prevTrialExhausted.current = state.trial?.exhausted ?? false; + }, [state.trial?.exhausted]); + + // 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]); + + // Trial time warnings — toast at 2 min and 30 sec remaining + useEffect(() => { + if (!isConnected || !state.trial || state.trial.exhausted) { + trialWarningShown.current = "none"; + return; + } + const remaining = state.trial.secondsLimit - state.trial.secondsUsed; + + if (remaining <= 30 && trialWarningShown.current !== "30sec") { + trialWarningShown.current = "30sec"; + toast.warning( + "Free trial ending in 30 seconds. Subscribe to continue.", + { + duration: 30000, + }, + ); + } else if ( + remaining <= 120 && + remaining > 30 && + trialWarningShown.current === "none" + ) { + trialWarningShown.current = "2min"; + toast.warning( + "Free trial ending in 2 minutes. Subscribe or switch to local inference.", + ); + } + }, [isConnected, state.trial]); + + // 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 openCheckout = useCallback(async (tier: "pro" | "max") => { + try { + const apiKey = getDaydreamAPIKey(); + if (!apiKey) { + toast.error("Please sign in first"); + return; + } + const { checkoutUrl } = await createCheckoutSession(apiKey, tier); + openExternalUrl(checkoutUrl); + toast.info("Opening Stripe Checkout in your browser..."); + } catch (err) { + console.error("[Billing] Checkout failed:", err); + const msg = err instanceof Error ? err.message : "Unknown error"; + toast.error(`Failed to open checkout: ${msg}`, { + description: "If this persists, contact support@daydream.monster", + }); + } + }, []); + + 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.monster", + }); + } + }, []); + + 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.monster", + }); + } + }, + [refresh], + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fc8ce79c5..2862eb5ee 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1189,7 +1189,7 @@ export const saveDmxConfig = async ( const DAYDREAM_API_BASE = (import.meta.env.VITE_DAYDREAM_API_BASE as string | undefined) || - "https://api.daydream.live"; + "https://api.daydream.monster"; export const fetchDaydreamWorkflow = async ( workflowId: string diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index b09477f9a..debb5db89 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -24,10 +24,10 @@ function getRedirectUrl(): string { const DAYDREAM_AUTH_URL = (import.meta.env.VITE_DAYDREAM_AUTH_URL as string | undefined) || - `https://app.daydream.live/sign-in/local`; + `https://app.daydream.monster/sign-in/local`; const DAYDREAM_API_BASE = (import.meta.env.VITE_DAYDREAM_API_BASE as string | undefined) || - "https://api.daydream.live"; + "https://api.daydream.monster"; const AUTH_STORAGE_KEY = "daydream_auth"; const AUTH_STATE_KEY = "daydream_auth_state"; diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts new file mode 100644 index 000000000..42a61070e --- /dev/null +++ b/frontend/src/lib/billing.ts @@ -0,0 +1,169 @@ +/** + * 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.monster"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface TrialStatus { + secondsUsed: number; + secondsLimit: number; + exhausted: boolean; +} + +export interface TrialHeartbeatResponse extends TrialStatus { + hasSubscription: boolean; +} + +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; + trial: TrialStatus | 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 sendTrialHeartbeat( + apiKey: string | null, + deviceId: string, +): Promise { + const res = await fetch(`${DAYDREAM_API_BASE}/credits/trial/heartbeat`, { + method: "POST", + headers: headers(apiKey), + body: JSON.stringify({ deviceId }), + }); + if (!res.ok) + throw new Error(`Failed to send trial heartbeat: ${res.status}`); + return res.json(); +} + +export async function fetchTrialStatus( + deviceId: string, +): Promise { + const res = await fetch( + `${DAYDREAM_API_BASE}/credits/trial/status?deviceId=${encodeURIComponent(deviceId)}`, + ); + if (!res.ok) throw new Error(`Failed to fetch trial status: ${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/daydreamExport.ts b/frontend/src/lib/daydreamExport.ts index 385094cf2..2e5bf1597 100644 --- a/frontend/src/lib/daydreamExport.ts +++ b/frontend/src/lib/daydreamExport.ts @@ -1,6 +1,6 @@ const DAYDREAM_API_BASE = (import.meta.env.VITE_DAYDREAM_API_BASE as string | undefined) || - "https://api.daydream.live"; + "https://api.daydream.monster"; interface ImportSessionResponse { token: string; 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; +} diff --git a/frontend/src/pages/StreamPage.tsx b/frontend/src/pages/StreamPage.tsx index c9db65363..6cd092561 100644 --- a/frontend/src/pages/StreamPage.tsx +++ b/frontend/src/pages/StreamPage.tsx @@ -458,13 +458,13 @@ export function StreamPage() { } else { openExternalUrl(result.createUrl); } - toast.success("Opening daydream.live...", { + toast.success("Opening daydream.monster...", { description: - "Your workflow has been sent to daydream.live for publishing.", + "Your workflow has been sent to daydream.monster for publishing.", }); } catch (err) { pendingTab?.close(); - console.error("Export to daydream.live failed:", err); + console.error("Export to daydream.monster failed:", err); toast.error("Export failed", { description: err instanceof Error ? err.message : String(err), }); From 410337528a4fff177ee72bcd10836f46006250b0 Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Tue, 14 Apr 2026 15:48:14 +0200 Subject: [PATCH 02/16] placeholder auth & credit deduction Signed-off-by: gioelecerati --- src/scope/cloud/fal_app.py | 189 ++++++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/src/scope/cloud/fal_app.py b/src/scope/cloud/fal_app.py index ea8d4ea64..10df493c2 100644 --- a/src/scope/cloud/fal_app.py +++ b/src/scope/cloud/fal_app.py @@ -279,6 +279,148 @@ def _should_forward_log(line: str) -> bool: ) TIMEOUT_CHECK_INTERVAL_SECONDS = 60 # Check for timeout every 60 seconds +# Credit heartbeat settings +HEARTBEAT_INTERVAL_SECONDS = 15 +HEARTBEAT_MAX_CONSECUTIVE_FAILURES = 4 # 4 x 15s = 1 minute grace on transient errors + + +def _gpu_source_from_machine_type(machine_type: str | None) -> str: + """Map fal.ai `machine_type` (e.g. "GPU-H100") to a credit rate source + key (e.g. "h100"). Unknown types default to "h100" so we never + under-bill on a misconfigured runner.""" + if not machine_type: + return "h100" + normalized = machine_type.lower().replace("gpu-", "").replace("rtx-", "") + normalized = normalized.replace("-", "").strip() + if "h100" in normalized: + return "h100" + if "5090" in normalized: + return "5090" + if "4090" in normalized: + return "4090" + return "h100" + + +# TODO(billing): replace with real inference-JWT minting before production. +# Must match FAL_PLACEHOLDER_TOKEN in pipelines/apps/api/src/modules/credits/credits.routes.ts. +# Under this placeholder path the pipelines API trusts the userId sent in the +# request body — anyone with this token can bill any user. Do not deploy. +_FAL_PLACEHOLDER_TOKEN = "dev-placeholder-fal-runner-token-do-not-use-in-prod" + + +async def run_credit_heartbeat( + user_id: str, + connection_id: str, + gpu_type: str, + safe_send_json, + close_ws, + stop_event: asyncio.Event, + log_prefix: str, +) -> None: + """Periodically report streaming usage to the pipelines API so paid + Scope cloud sessions get billed and terminated when credits run out. + + Runs inside the fal runner (not the scope subprocess, not the client) so + that a tampered local client cannot skip the call. + + TODO(billing): auth is currently a hardcoded shared secret + (_FAL_PLACEHOLDER_TOKEN) and userId is sent in the body. Before production, + switch to a real inference JWT minted with INFERENCE_JWT_SECRET so the + pipelines API can trust the userId from the token rather than the body. + """ + import httpx + + source = _gpu_source_from_machine_type(gpu_type) + url = f"{get_daydream_api_base()}/credits/stream/heartbeat" + consecutive_failures = 0 + print( + f"[{log_prefix}] [credits] Heartbeat started " + f"(url={url} gpuType={source} interval={HEARTBEAT_INTERVAL_SECONDS}s)" + ) + + try: + while not stop_event.is_set(): + try: + await asyncio.wait_for( + stop_event.wait(), timeout=HEARTBEAT_INTERVAL_SECONDS + ) + break # stop_event fired → exit cleanly + except (asyncio.TimeoutError, TimeoutError): # noqa: UP041 + pass # interval elapsed → send a beat + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, + headers={ + "Authorization": f"Bearer {_FAL_PLACEHOLDER_TOKEN}", + "Content-Type": "application/json", + }, + json={ + # TODO(billing): drop `userId` once auth uses JWT. + "userId": user_id, + "connectionId": connection_id, + "durationSeconds": HEARTBEAT_INTERVAL_SECONDS, + "gpuType": source, + }, + timeout=10.0, + ) + + if response.status_code == 402: + try: + data = response.json() + except Exception: + data = {} + print( + f"[{log_prefix}] [credits] Credits exhausted " + f"(balance={data.get('balance')}) — terminating session" + ) + await safe_send_json( + { + "type": "credits_exhausted", + "reason": "credits_exhausted", + "balance": data.get("balance"), + } + ) + await close_ws() + return + + if response.status_code == 200: + consecutive_failures = 0 + continue + + consecutive_failures += 1 + print( + f"[{log_prefix}] [credits] Heartbeat returned " + f"{response.status_code}: {response.text[:200]} " + f"(failure {consecutive_failures}/{HEARTBEAT_MAX_CONSECUTIVE_FAILURES})" + ) + except Exception as e: + consecutive_failures += 1 + print( + f"[{log_prefix}] [credits] Heartbeat error: " + f"{type(e).__name__}: {e} " + f"(failure {consecutive_failures}/{HEARTBEAT_MAX_CONSECUTIVE_FAILURES})" + ) + + if consecutive_failures >= HEARTBEAT_MAX_CONSECUTIVE_FAILURES: + print( + f"[{log_prefix}] [credits] Too many consecutive heartbeat " + "failures — terminating session (fail-closed)" + ) + await safe_send_json( + { + "type": "credits_exhausted", + "reason": "heartbeat_failed", + } + ) + await close_ws() + return + except asyncio.CancelledError: + pass + finally: + print(f"[{log_prefix}] [credits] Heartbeat stopped") + def cleanup_session_data(): """Clean up session-specific data when WebSocket disconnects. @@ -621,6 +763,12 @@ def log_prefix() -> str: # Track WebRTC session ID for ICE candidate routing session_id = None + # Credit heartbeat — started on first successful WebRTC offer, cancelled + # on disconnect. Runs inside the fal runner so paid users can't skip it + # by tampering with their local client. + credit_heartbeat_task: asyncio.Task | None = None + credit_heartbeat_stop = asyncio.Event() + # Track connection start time for max duration timeout connection_start_time = time.time() @@ -636,6 +784,16 @@ async def safe_send_json(payload: dict): except (RuntimeError, WebSocketDisconnect): pass + async def safe_close_ws(): + """Close the WebSocket, tolerating already-closed state. The main + loop's `finally` block then tears down the WebRTC session and + cleans up subprocess state.""" + try: + if ws.application_state == WebSocketState.CONNECTED: + await ws.close(code=4002, reason="Credits exhausted") + except (RuntimeError, WebSocketDisconnect): + pass + async def forward_logs_to_client(): """Forward subprocess log lines to WebSocket client in batches. @@ -717,7 +875,7 @@ async def handle_get_ice_servers(payload: dict): async def handle_offer(payload: dict): """Proxy POST /api/v1/webrtc/offer""" - nonlocal session_id + nonlocal session_id, credit_heartbeat_task request_id = payload.get("request_id") try: @@ -738,6 +896,20 @@ async def handle_offer(payload: dict): if response.status_code == 200: data = response.json() session_id = data.get("sessionId") + # Start the credit heartbeat on the first successful + # offer. Subsequent offers (renegotiation) reuse it. + if credit_heartbeat_task is None and user_id: + credit_heartbeat_task = asyncio.create_task( + run_credit_heartbeat( + user_id=user_id, + connection_id=connection_id, + gpu_type=connection_info.get("gpu_type", ""), + safe_send_json=safe_send_json, + close_ws=safe_close_ws, + stop_event=credit_heartbeat_stop, + log_prefix=log_prefix(), + ) + ) return { "type": "answer", "request_id": request_id, @@ -1176,6 +1348,21 @@ async def handle_message(payload: dict) -> dict | None: print(f"[{log_prefix()}] WebSocket error ({type(e).__name__}): {e}") await safe_send_json({"type": "error", "error": f"{type(e).__name__}: {e}"}) finally: + # Stop the credit heartbeat first so the last partial interval + # doesn't race the session teardown. + credit_heartbeat_stop.set() + if credit_heartbeat_task is not None: + try: + await asyncio.wait_for(credit_heartbeat_task, timeout=5.0) + except (asyncio.TimeoutError, TimeoutError): # noqa: UP041 + credit_heartbeat_task.cancel() + try: + await credit_heartbeat_task + except asyncio.CancelledError: + pass + except asyncio.CancelledError: + pass + # Cancel log forwarder task if log_forwarder_task is not None: log_forwarder_task.cancel() From 7b41242aa2de5b1b94b39d95c7934940f1582b1c Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Tue, 14 Apr 2026 16:51:49 +0200 Subject: [PATCH 03/16] fix build Signed-off-by: gioelecerati --- frontend/src/contexts/BillingContext.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/frontend/src/contexts/BillingContext.tsx b/frontend/src/contexts/BillingContext.tsx index 13734f079..374655968 100644 --- a/frontend/src/contexts/BillingContext.tsx +++ b/frontend/src/contexts/BillingContext.tsx @@ -17,6 +17,7 @@ import { } from "../lib/billing"; import { getDaydreamAPIKey } from "../lib/auth"; import { getDeviceId } from "../lib/deviceId"; +import { openExternalUrl } from "../lib/openExternal"; import { useCloudStatus } from "../hooks/useCloudStatus"; import { toast } from "sonner"; @@ -86,14 +87,6 @@ export function useBilling() { return useContext(BillingContext); } -function openExternalUrl(url: string) { - if (typeof window !== "undefined" && "scope" in window) { - (window as any).scope.openExternal(url); - } else { - window.open(url, "_blank"); - } -} - export function BillingProvider({ children }: { children: ReactNode }) { const [state, setState] = useState({ tier: "free", From 0948c0baaec7902c6e167b08e4da9a9a901bde37 Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Tue, 14 Apr 2026 16:54:50 +0200 Subject: [PATCH 04/16] prettier Signed-off-by: gioelecerati --- frontend/src/components/Header.tsx | 2 +- frontend/src/components/PaywallModal.tsx | 8 +++--- .../src/components/settings/BillingTab.tsx | 15 +++++------ frontend/src/contexts/BillingContext.tsx | 27 +++++++++---------- frontend/src/lib/billing.ts | 23 +++++++--------- 5 files changed, 32 insertions(+), 43 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 5f7376183..e559c5cc8 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -277,7 +277,7 @@ export function Header({ Trial:{" "} {formatTrialTime( - billing.trial.secondsLimit - billing.trial.secondsUsed, + billing.trial.secondsLimit - billing.trial.secondsUsed )} )} diff --git a/frontend/src/components/PaywallModal.tsx b/frontend/src/components/PaywallModal.tsx index 09c26b1fe..4ea6755b9 100644 --- a/frontend/src/components/PaywallModal.tsx +++ b/frontend/src/components/PaywallModal.tsx @@ -24,7 +24,7 @@ const TIERS = [ ]; function getHeadline( - reason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null, + reason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null ): string { switch (reason) { case "trial_exhausted": @@ -86,15 +86,13 @@ export function PaywallModal() { } const result = await redeemCreditCode(apiKey, trimmed); toast.success( - `${result.credits} credits added${result.label ? ` — ${result.label}` : ""}`, + `${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", - ); + toast.error(err instanceof Error ? err.message : "Failed to redeem code"); } finally { setIsRedeeming(false); } diff --git a/frontend/src/components/settings/BillingTab.tsx b/frontend/src/components/settings/BillingTab.tsx index 837a83f10..a12a31a45 100644 --- a/frontend/src/components/settings/BillingTab.tsx +++ b/frontend/src/components/settings/BillingTab.tsx @@ -33,14 +33,12 @@ function RedeemCodeSection({ onRedeemed }: { onRedeemed: () => void }) { } const result = await redeemCreditCode(apiKey, trimmed); toast.success( - `${result.credits} credits added${result.label ? ` — ${result.label}` : ""}`, + `${result.credits} credits added${result.label ? ` — ${result.label}` : ""}` ); setCode(""); onRedeemed(); } catch (err) { - toast.error( - err instanceof Error ? err.message : "Failed to redeem code", - ); + toast.error(err instanceof Error ? err.message : "Failed to redeem code"); } finally { setIsRedeeming(false); } @@ -229,8 +227,8 @@ export function BillingTab() { Overage billing

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

@@ -244,8 +242,7 @@ export function BillingTab() { Enable overage billing? - When your monthly credits run out, you'll be automatically - charged{" "} + When your monthly credits run out, you'll be automatically charged{" "} $10 for 500 additional credits @@ -278,7 +275,7 @@ export function BillingTab() { onClick={() => window.open( "https://app.daydream.monster/dashboard/usage", - "_blank", + "_blank" ) } > diff --git a/frontend/src/contexts/BillingContext.tsx b/frontend/src/contexts/BillingContext.tsx index 374655968..932741f4d 100644 --- a/frontend/src/contexts/BillingContext.tsx +++ b/frontend/src/contexts/BillingContext.tsx @@ -55,7 +55,7 @@ interface BillingContextValue extends BillingState { setShowPaywall: (show: boolean) => void; paywallReason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null; setPaywallReason: ( - reason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null, + reason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null ) => void; /** Get a valid inference token (requests new one if expired) */ getInferenceToken: () => Promise; @@ -116,7 +116,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { // Warning thresholds (tracked so we only toast once per threshold) const creditWarningShown = useRef<"none" | "low" | "critical" | "grace">( - "none", + "none" ); const trialWarningShown = useRef<"none" | "2min" | "30sec">("none"); const upsellShown = useRef(false); @@ -274,7 +274,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { setShowPaywall(true); }, }, - }, + } ); } else if ( pct <= 0.05 && @@ -284,7 +284,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { creditWarningShown.current = "critical"; toast.warning( `Credits critically low — ${Math.round(balance)} credits remaining (~${minutesLeft} min)`, - { duration: 10000 }, + { duration: 10000 } ); } else if ( pct <= 0.15 && @@ -293,7 +293,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { ) { creditWarningShown.current = "low"; toast.warning( - `Credits running low — ${Math.round(balance)} credits remaining (~${minutesLeft} min)`, + `Credits running low — ${Math.round(balance)} credits remaining (~${minutesLeft} min)` ); } @@ -307,7 +307,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { upsellShown.current = true; toast.info( "Running low on credits? Upgrade to Max for more credits per month.", - { duration: 8000 }, + { duration: 8000 } ); } }, [isConnected, state.credits, state.tier, state.creditsPerMin]); @@ -322,12 +322,9 @@ export function BillingProvider({ children }: { children: ReactNode }) { if (remaining <= 30 && trialWarningShown.current !== "30sec") { trialWarningShown.current = "30sec"; - toast.warning( - "Free trial ending in 30 seconds. Subscribe to continue.", - { - duration: 30000, - }, - ); + toast.warning("Free trial ending in 30 seconds. Subscribe to continue.", { + duration: 30000, + }); } else if ( remaining <= 120 && remaining > 30 && @@ -335,7 +332,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { ) { trialWarningShown.current = "2min"; toast.warning( - "Free trial ending in 2 minutes. Subscribe or switch to local inference.", + "Free trial ending in 2 minutes. Subscribe or switch to local inference." ); } }, [isConnected, state.trial]); @@ -425,7 +422,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { if (!apiKey) return; await setOverageEnabled(apiKey, enabled); toast.success( - enabled ? "Overage billing enabled" : "Overage billing disabled", + enabled ? "Overage billing enabled" : "Overage billing disabled" ); await refresh(); } catch (err) { @@ -436,7 +433,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { }); } }, - [refresh], + [refresh] ); return ( diff --git a/frontend/src/lib/billing.ts b/frontend/src/lib/billing.ts index 42a61070e..061de1702 100644 --- a/frontend/src/lib/billing.ts +++ b/frontend/src/lib/billing.ts @@ -48,7 +48,7 @@ function headers(apiKey: string | null): Record { export async function fetchCreditsBalance( apiKey: string, - deviceId?: string, + deviceId?: string ): Promise { const url = deviceId ? `${DAYDREAM_API_BASE}/credits/balance?deviceId=${encodeURIComponent(deviceId)}` @@ -61,23 +61,20 @@ export async function fetchCreditsBalance( export async function sendTrialHeartbeat( apiKey: string | null, - deviceId: string, + deviceId: string ): Promise { const res = await fetch(`${DAYDREAM_API_BASE}/credits/trial/heartbeat`, { method: "POST", headers: headers(apiKey), body: JSON.stringify({ deviceId }), }); - if (!res.ok) - throw new Error(`Failed to send trial heartbeat: ${res.status}`); + if (!res.ok) throw new Error(`Failed to send trial heartbeat: ${res.status}`); return res.json(); } -export async function fetchTrialStatus( - deviceId: string, -): Promise { +export async function fetchTrialStatus(deviceId: string): Promise { const res = await fetch( - `${DAYDREAM_API_BASE}/credits/trial/status?deviceId=${encodeURIComponent(deviceId)}`, + `${DAYDREAM_API_BASE}/credits/trial/status?deviceId=${encodeURIComponent(deviceId)}` ); if (!res.ok) throw new Error(`Failed to fetch trial status: ${res.status}`); return res.json(); @@ -85,7 +82,7 @@ export async function fetchTrialStatus( export async function createCheckoutSession( apiKey: string, - tier: "pro" | "max", + tier: "pro" | "max" ): Promise<{ checkoutUrl: string }> { const res = await fetch(`${DAYDREAM_API_BASE}/credits/checkout`, { method: "POST", @@ -98,7 +95,7 @@ export async function createCheckoutSession( } export async function createPortalSession( - apiKey: string, + apiKey: string ): Promise<{ portalUrl: string }> { const res = await fetch(`${DAYDREAM_API_BASE}/credits/portal`, { method: "POST", @@ -111,7 +108,7 @@ export async function createPortalSession( export async function setOverageEnabled( apiKey: string, - enabled: boolean, + enabled: boolean ): Promise { const res = await fetch(`${DAYDREAM_API_BASE}/credits/overage`, { method: "POST", @@ -138,7 +135,7 @@ export interface InferenceTokenResponse { export async function requestInferenceToken( apiKey: string, - deviceId?: string, + deviceId?: string ): Promise { const res = await fetch(`${DAYDREAM_API_BASE}/credits/inference-token`, { method: "POST", @@ -154,7 +151,7 @@ export async function requestInferenceToken( export async function redeemCreditCode( apiKey: string, - code: string, + code: string ): Promise { const res = await fetch(`${DAYDREAM_API_BASE}/credits/codes/redeem`, { method: "POST", From 1be63c6d0d3aaa21678435c99221f3f8d8a7360d Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Tue, 14 Apr 2026 17:50:13 +0200 Subject: [PATCH 05/16] subscribe redirect Signed-off-by: gioelecerati --- frontend/src/components/Header.tsx | 5 +--- .../src/components/settings/BillingTab.tsx | 6 ++--- frontend/src/contexts/BillingContext.tsx | 26 +++++-------------- 3 files changed, 9 insertions(+), 28 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index e559c5cc8..854f4610b 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -251,10 +251,7 @@ export function Header({ + + + Daydream Cloud inference requires credit purchases. For more + information, please refer to our{" "} +
{ + e.preventDefault(); + openExternalUrl("https://daydream.live/pricing"); + }} + > + Pricing page + + . + + + + + + )} - {/* Subscribe CTA (free tier only) */} - {billing.tier === "free" && !billing.credits && ( + {/* Upgrade CTA / Plan badge */} + {!isSignedIn ? ( + + ) : billing.tier === "free" ? ( + + ) : ( )} {isConnected && diff --git a/frontend/src/components/settings/BillingTab.tsx b/frontend/src/components/settings/BillingTab.tsx index 9891eb740..78871a88b 100644 --- a/frontend/src/components/settings/BillingTab.tsx +++ b/frontend/src/components/settings/BillingTab.tsx @@ -101,8 +101,8 @@ export function BillingTab() { Subscription & Billing

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

{credits && credits.balance > 0 && (
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."} From 94af6f07f3a454fdfec239f8b1b0bc65a00c4226 Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Wed, 15 Apr 2026 20:15:26 +0200 Subject: [PATCH 07/16] point to production Signed-off-by: gioelecerati --- frontend/src/components/ExportDialog.tsx | 6 +++--- frontend/src/components/Header.tsx | 6 ++---- frontend/src/components/LoRAManager.tsx | 2 +- frontend/src/components/PaywallModal.tsx | 2 +- frontend/src/components/graph/GraphEditor.tsx | 6 +++--- frontend/src/components/settings/BillingTab.tsx | 14 +++++--------- frontend/src/components/settings/DiscoverTab.tsx | 4 ++-- frontend/src/components/settings/GeneralTab.tsx | 2 +- frontend/src/components/settings/LoRAsTab.tsx | 2 +- frontend/src/components/settings/WorkflowsTab.tsx | 4 ++-- frontend/src/contexts/BillingContext.tsx | 6 +++--- frontend/src/lib/api.ts | 2 +- frontend/src/lib/auth.ts | 4 ++-- frontend/src/lib/billing.ts | 2 +- frontend/src/lib/daydreamExport.ts | 2 +- frontend/src/pages/StreamPage.tsx | 6 +++--- 16 files changed, 32 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx index 9c0ab5afd..61baa9aa2 100644 --- a/frontend/src/components/ExportDialog.tsx +++ b/frontend/src/components/ExportDialog.tsx @@ -96,12 +96,12 @@ export function ExportDialog({

{isAuthenticated - ? "Export to daydream.monster" - : "Log in to export to daydream.monster"} + ? "Export to daydream.live" + : "Log in to export to daydream.live"} {isAuthenticated - ? "Publish your workflow on daydream.monster" + ? "Publish your workflow on daydream.live" : "Sign in to your Daydream account to publish"}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 8ed98bae6..4e48ee5e6 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -28,7 +28,7 @@ import { openExternalUrl } from "../lib/openExternal"; const DAYDREAM_APP_BASE = (import.meta.env.VITE_DAYDREAM_APP_BASE as string | undefined) || - "https://app.daydream.monster"; + "https://app.daydream.live"; function formatTrialTime(totalSeconds: number): string { const s = Math.max(0, Math.floor(totalSeconds)); @@ -281,9 +281,7 @@ export function Header({ - - - Daydream Cloud inference requires credit purchases. For more - information, please refer to our{" "} - { - e.preventDefault(); - openExternalUrl("https://daydream.live/pricing"); - }} - > - Pricing page - - . - - - - - - )} - {/* Upgrade CTA / Plan badge */} - {!isSignedIn ? ( - - ) : billing.tier === "free" ? ( - - ) : ( - + ) : billing.tier === "free" ? ( + + ) : ( + + )} + + )} + {/* Billing unavailable fallback */} + {isConnected && billing.billingError && !billing.credits && ( + - {billing.tier === "pro" ? "Pro" : "Max"} - + + Billing unavailable + )} - {isConnected && - billing.tier === "free" && - billing.trial && - !billing.trial.exhausted && ( - + + + + + {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 + + . + + + + + +
+ + + )} + { + setPluginsInitialTab("discover"); + setPluginsOpen(true); + }} > - - Billing unavailable -
- )} - - - + + Nodes + + { + setPluginsInitialTab("workflows"); + setPluginsOpen(true); + }} + > + + Workflows + + setSettingsOpen(true)} + > + + Settings + + + diff --git a/frontend/src/components/PaywallModal.tsx b/frontend/src/components/PaywallModal.tsx index c2a0b6721..10c273658 100644 --- a/frontend/src/components/PaywallModal.tsx +++ b/frontend/src/components/PaywallModal.tsx @@ -23,12 +23,8 @@ const TIERS = [ }, ]; -function getHeadline( - reason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null -): string { +function getHeadline(reason: "credits_exhausted" | "subscribe" | null): string { switch (reason) { - case "trial_exhausted": - return "Your free remote inference time is up"; case "credits_exhausted": return "You've run out of credits"; case "subscribe": @@ -161,9 +157,8 @@ export function PaywallModal() { )} - {/* Redeem code — show for trial exhausted or credits exhausted */} - {(paywallReason === "trial_exhausted" || - paywallReason === "credits_exhausted") && ( + {/* Redeem code — show when credits exhausted */} + {paywallReason === "credits_exhausted" && (
| null; isLoading: boolean; @@ -54,10 +48,8 @@ interface BillingContextValue extends BillingState { toggleOverage: (enabled: boolean) => Promise; showPaywall: boolean; setShowPaywall: (show: boolean) => void; - paywallReason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null; - setPaywallReason: ( - reason: "trial_exhausted" | "credits_exhausted" | "subscribe" | null - ) => 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; } @@ -66,7 +58,6 @@ const defaultState: BillingContextValue = { tier: "free", credits: null, subscription: null, - trial: null, creditsPerMin: 7.5, allRates: null, isLoading: true, @@ -93,7 +84,6 @@ export function BillingProvider({ children }: { children: ReactNode }) { tier: "free", credits: null, subscription: null, - trial: null, creditsPerMin: 7.5, allRates: null, isLoading: true, @@ -101,13 +91,11 @@ export function BillingProvider({ children }: { children: ReactNode }) { }); const [showPaywall, setShowPaywall] = useState(false); const [paywallReason, setPaywallReason] = useState< - "trial_exhausted" | "credits_exhausted" | "subscribe" | null + "credits_exhausted" | "subscribe" | null >(null); const { isConnected } = useCloudStatus(); const pollRef = useRef | null>(null); - const heartbeatRef = useRef | null>(null); - const prevTrialExhausted = useRef(false); // Inference token cache — refresh before 5-min expiry const inferenceTokenRef = useRef<{ @@ -119,26 +107,13 @@ export function BillingProvider({ children }: { children: ReactNode }) { const creditWarningShown = useRef<"none" | "low" | "critical" | "grace">( "none" ); - const trialWarningShown = useRef<"none" | "2min" | "30sec">("none"); const upsellShown = useRef(false); const refresh = useCallback(async () => { try { const apiKey = getDaydreamAPIKey(); if (!apiKey) { - // Not authenticated — try trial status - const deviceId = getDeviceId(); - const { secondsUsed, secondsLimit, exhausted } = - await sendTrialHeartbeat(null, deviceId); - setState(prev => ({ - ...prev, - tier: "free", - credits: null, - subscription: null, - trial: { secondsUsed, secondsLimit, exhausted }, - isLoading: false, - billingError: false, - })); + setState(prev => ({ ...prev, isLoading: false })); return; } @@ -158,7 +133,6 @@ export function BillingProvider({ children }: { children: ReactNode }) { tier: data.tier, credits: data.credits, subscription: data.subscription, - trial: data.trial, creditsPerMin: scopeRate, allRates: rateMap, isLoading: false, @@ -202,49 +176,6 @@ export function BillingProvider({ children }: { children: ReactNode }) { }; }, [isConnected, refresh]); - // Trial heartbeat every 60s when streaming on free trial - useEffect(() => { - if ( - isConnected && - state.tier === "free" && - state.trial && - !state.trial.exhausted - ) { - heartbeatRef.current = setInterval(async () => { - try { - const apiKey = getDaydreamAPIKey(); - const deviceId = getDeviceId(); - const result = await sendTrialHeartbeat(apiKey, deviceId); - setState(prev => ({ - ...prev, - trial: { - secondsUsed: result.secondsUsed, - secondsLimit: result.secondsLimit, - exhausted: result.exhausted, - }, - })); - } catch (err) { - console.error("[Billing] Trial heartbeat failed:", err); - } - }, 60_000); - } else { - if (heartbeatRef.current) clearInterval(heartbeatRef.current); - heartbeatRef.current = null; - } - return () => { - if (heartbeatRef.current) clearInterval(heartbeatRef.current); - }; - }, [isConnected, state.tier, state.trial?.exhausted]); - - // Show paywall when trial exhausts - useEffect(() => { - if (state.trial?.exhausted && !prevTrialExhausted.current) { - setPaywallReason("trial_exhausted"); - setShowPaywall(true); - } - prevTrialExhausted.current = state.trial?.exhausted ?? false; - }, [state.trial?.exhausted]); - // Low credit warnings — toast once per threshold, with grace period warning useEffect(() => { if (!isConnected || !state.credits || state.tier === "free") { @@ -313,31 +244,6 @@ export function BillingProvider({ children }: { children: ReactNode }) { } }, [isConnected, state.credits, state.tier, state.creditsPerMin]); - // Trial time warnings — toast at 2 min and 30 sec remaining - useEffect(() => { - if (!isConnected || !state.trial || state.trial.exhausted) { - trialWarningShown.current = "none"; - return; - } - const remaining = state.trial.secondsLimit - state.trial.secondsUsed; - - if (remaining <= 30 && trialWarningShown.current !== "30sec") { - trialWarningShown.current = "30sec"; - toast.warning("Free trial ending in 30 seconds. Subscribe to continue.", { - duration: 30000, - }); - } else if ( - remaining <= 120 && - remaining > 30 && - trialWarningShown.current === "none" - ) { - trialWarningShown.current = "2min"; - toast.warning( - "Free trial ending in 2 minutes. Subscribe or switch to local inference." - ); - } - }, [isConnected, state.trial]); - // Listen for credits-exhausted events from API error handling useEffect(() => { const handler = () => { 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 index 9200ee462..787565ccd 100644 --- a/frontend/src/lib/billing.ts +++ b/frontend/src/lib/billing.ts @@ -8,16 +8,6 @@ const DAYDREAM_API_BASE = // ─── Types ─────────────────────────────────────────────────────────────────── -export interface TrialStatus { - secondsUsed: number; - secondsLimit: number; - exhausted: boolean; -} - -export interface TrialHeartbeatResponse extends TrialStatus { - hasSubscription: boolean; -} - export interface CreditsBalance { tier: "free" | "pro" | "max"; credits: { @@ -34,7 +24,6 @@ export interface CreditsBalance { cancelAtPeriodEnd: boolean; overageEnabled: boolean; } | null; - trial: TrialStatus | null; creditsPerMin: number | Record; } @@ -59,27 +48,6 @@ export async function fetchCreditsBalance( return res.json(); } -export async function sendTrialHeartbeat( - apiKey: string | null, - deviceId: string -): Promise { - const res = await fetch(`${DAYDREAM_API_BASE}/credits/trial/heartbeat`, { - method: "POST", - headers: headers(apiKey), - body: JSON.stringify({ deviceId }), - }); - if (!res.ok) throw new Error(`Failed to send trial heartbeat: ${res.status}`); - return res.json(); -} - -export async function fetchTrialStatus(deviceId: string): Promise { - const res = await fetch( - `${DAYDREAM_API_BASE}/credits/trial/status?deviceId=${encodeURIComponent(deviceId)}` - ); - if (!res.ok) throw new Error(`Failed to fetch trial status: ${res.status}`); - return res.json(); -} - export async function createCheckoutSession( apiKey: string, tier: "pro" | "max" From 98c84c7ba2c13cb34edc4f1bd8eb036ddfc4d7c9 Mon Sep 17 00:00:00 2001 From: Hunter Hillman Date: Wed, 15 Apr 2026 12:26:24 -0700 Subject: [PATCH 10/16] tone down billing CTAs and redesign paywall - Soften Upgrade/Top Up gradients, add external-link icon, match Connected color to Run button's emerald - Gate Top Up button to Pro/Max subscribers - Redesign paywall: new Pro/Max tiles with credits/hrs/description, different copy + Manage Subscription button for existing subscribers, remove "Maybe later" Co-Authored-By: Claude Opus 4.6 Signed-off-by: Hunter Hillman --- frontend/src/components/Header.tsx | 32 +++-- frontend/src/components/PaywallModal.tsx | 149 +++++++++++++---------- 2 files changed, 106 insertions(+), 75 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index fbf794d8d..49f80ed82 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -9,6 +9,7 @@ import { Menu as MenuIcon, AlertTriangle, HelpCircle, + ExternalLink, } from "lucide-react"; import { Button } from "./ui/button"; import { @@ -252,7 +253,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" @@ -290,17 +291,19 @@ export function Header({ ) : billing.tier === "free" ? ( ) : ( + {(billing.tier === "pro" || billing.tier === "max") && ( + + )}
diff --git a/frontend/src/components/PaywallModal.tsx b/frontend/src/components/PaywallModal.tsx index 10c273658..65e6aafdc 100644 --- a/frontend/src/components/PaywallModal.tsx +++ b/frontend/src/components/PaywallModal.tsx @@ -1,29 +1,39 @@ 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", - price: "$10/mo", - credits: "500 credits", + 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", - price: "$30/mo", - credits: "1,750 credits", + 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): string { +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"; @@ -34,28 +44,42 @@ function getHeadline(reason: "credits_exhausted" | "subscribe" | null): string { } } +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, - openCheckout, - toggleOverage, - subscription, - creditsPerMin, + tier, refresh, } = useBilling(); const [redeemCode, setRedeemCode] = useState(""); const [isRedeeming, setIsRedeeming] = useState(false); - const handleTierClick = (tier: "pro" | "max") => { - openCheckout(tier); + const isSubscribed = tier === "pro" || tier === "max"; + + const handleSubscribe = (_tierId: "pro" | "max") => { + openExternalUrl(DASHBOARD_USAGE_URL); setShowPaywall(false); }; - const handleOverage = () => { - toggleOverage(true); + const handleManageSubscription = () => { + openExternalUrl(DASHBOARD_USAGE_URL); setShowPaywall(false); }; @@ -94,67 +118,72 @@ export function PaywallModal() { } }; - const rateDisplay = - creditsPerMin > 0 - ? `Your current workflow uses ${creditsPerMin} credits/min.` - : "Credit usage varies by workflow and GPU type."; - return ( !open && setShowPaywall(false)} > - +

- {getHeadline(paywallReason)} + {getHeadline(paywallReason, isSubscribed)}

-

{rateDisplay}

+

+ {getSubcopy(paywallReason, isSubscribed)} +

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

- {tier.credits} +

+ {tier.hours}

+

+ {tier.description} +

+
- - {tier.price} - - - ))} -
- - {/* Overage option — only show when credits exhausted and user has subscription */} - {paywallReason === "credits_exhausted" && subscription && ( - + ))} +
)} {/* Redeem code — show when credits exhausted */} @@ -187,12 +216,6 @@ export function PaywallModal() { > Run locally instead -

From 4ab9dd3ab9bcf3ec15f9f42cedf186b333f5dd2a Mon Sep 17 00:00:00 2001 From: Hunter Hillman Date: Wed, 15 Apr 2026 12:45:11 -0700 Subject: [PATCH 11/16] stack Top Up under credits and match Upgrade gradient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Top Up button was wrapping ("Top / Up") in the narrow menu dropdown. Stack it below the credits line and use the same teal→blue gradient as the Upgrade CTA so the two read as a pair. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Hunter Hillman --- frontend/src/components/Header.tsx | 4 ++-- frontend/src/components/PaywallModal.tsx | 9 ++------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 49f80ed82..432445066 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -345,7 +345,7 @@ export function Header({ {isSignedIn && billing.credits && ( <> -

+
{billing.credits.balance.toFixed(2)} @@ -395,7 +395,7 @@ export function Header({ `${DAYDREAM_APP_BASE}/dashboard/usage` ) } - className="inline-flex items-center gap-1 h-6 px-2 rounded-md text-[11px] font-semibold border border-[#2FBEC5]/40 text-[#2FBEC5] hover:bg-[#2FBEC5]/10 transition-colors" + className="inline-flex items-center justify-center gap-1.5 h-7 px-3 rounded-md text-xs font-semibold text-white bg-gradient-to-r from-[#2FBEC5] to-[#36619D] hover:brightness-110 transition-all w-full" > Top Up diff --git a/frontend/src/components/PaywallModal.tsx b/frontend/src/components/PaywallModal.tsx index 65e6aafdc..5ccb4f283 100644 --- a/frontend/src/components/PaywallModal.tsx +++ b/frontend/src/components/PaywallModal.tsx @@ -60,13 +60,8 @@ function getSubcopy( } export function PaywallModal() { - const { - showPaywall, - setShowPaywall, - paywallReason, - tier, - refresh, - } = useBilling(); + const { showPaywall, setShowPaywall, paywallReason, tier, refresh } = + useBilling(); const [redeemCode, setRedeemCode] = useState(""); const [isRedeeming, setIsRedeeming] = useState(false); From 98656c2632b16c32dda5a6a8b4c2dab35b658cd4 Mon Sep 17 00:00:00 2001 From: Hunter Hillman Date: Wed, 15 Apr 2026 14:06:55 -0700 Subject: [PATCH 12/16] replace title with app icon and use Workflow/Perform toggle - Swap the "Daydream Scope" text for the app icon (frontend/public/icon.png) - Replace the single-button mode toggle with a ToggleGroup radio showing both Workflow and Perform, highlighting the active mode Co-Authored-By: Claude Opus 4.6 Signed-off-by: Hunter Hillman --- frontend/src/components/Header.tsx | 59 +++++++++++++++++------------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 432445066..eacbaab7e 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -12,6 +12,7 @@ import { ExternalLink, } from "lucide-react"; import { Button } from "./ui/button"; +import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group"; import { Tooltip, TooltipContent, @@ -215,35 +216,41 @@ export function Header({
-

- Daydream Scope -

+ Daydream Scope {onGraphModeToggle && ( - + + + Workflow + + + + Perform + + )}
From a16ef6a495739f8a025f6e3087956ddda95316f4 Mon Sep 17 00:00:00 2001 From: Hunter Hillman Date: Wed, 15 Apr 2026 14:26:55 -0700 Subject: [PATCH 13/16] use transparent white logo in header Replace the app icon (/icon.png, dark navy background) with a transparent-background white variant so the logo blends into the dark header instead of rendering as a rounded square. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Hunter Hillman --- frontend/public/icon-white.png | Bin 0 -> 18637 bytes frontend/src/components/Header.tsx | 6 +----- 2 files changed, 1 insertion(+), 5 deletions(-) create mode 100644 frontend/public/icon-white.png diff --git a/frontend/public/icon-white.png b/frontend/public/icon-white.png new file mode 100644 index 0000000000000000000000000000000000000000..f0a8eb096bc342a22f869ec1f8e1575734020c66 GIT binary patch literal 18637 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≪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
- Daydream Scope + Daydream Scope {onGraphModeToggle && ( Date: Wed, 15 Apr 2026 14:40:45 -0700 Subject: [PATCH 14/16] use proper transparent SVG logo in header Replace the PIL-generated icon-white.png (which had a faint full-tile alpha wash from the brightness-to-alpha conversion) with the real vector asset. Drops w-7 for w-auto to preserve the 99:87 aspect ratio. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Hunter Hillman --- frontend/public/icon-white.png | Bin 18637 -> 0 bytes frontend/public/icon-white.svg | 9 +++++++++ frontend/src/components/Header.tsx | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) delete mode 100644 frontend/public/icon-white.png create mode 100644 frontend/public/icon-white.svg diff --git a/frontend/public/icon-white.png b/frontend/public/icon-white.png deleted file mode 100644 index f0a8eb096bc342a22f869ec1f8e1575734020c66..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18637 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≪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 + + + + + + + + diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 87d568704..46503a287 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -216,7 +216,7 @@ export function Header({
- Daydream Scope + Daydream Scope {onGraphModeToggle && ( Date: Wed, 15 Apr 2026 14:48:19 -0700 Subject: [PATCH 15/16] shrink header logo, rename graph menu to Graph - Header logo h-7 -> h-5 (was too large relative to toolbar buttons) - Graph toolbar dropdown trigger renamed Menu -> Graph - Drop Settings and Nodes items from the Graph dropdown; both already live in the header Menu, so they were duplicates Co-Authored-By: Claude Opus 4.6 Signed-off-by: Hunter Hillman --- frontend/src/components/Header.tsx | 6 +++++- frontend/src/components/graph/GraphToolbar.tsx | 15 +-------------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 46503a287..aeea31757 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -216,7 +216,11 @@ export function Header({
- Daydream Scope + Daydream Scope {onGraphModeToggle && ( (null); @@ -78,7 +74,7 @@ export function GraphToolbar({ @@ -101,15 +97,6 @@ export function GraphToolbar({ )} - - - Settings - - - - Nodes - - Default Workflow From 5e2da4179d32402d4f42b71704f5832bb1c075f4 Mon Sep 17 00:00:00 2001 From: gioelecerati Date: Thu, 16 Apr 2026 11:57:49 +0200 Subject: [PATCH 16/16] added polling to credits Signed-off-by: gioelecerati --- frontend/src/contexts/BillingContext.tsx | 43 ++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/frontend/src/contexts/BillingContext.tsx b/frontend/src/contexts/BillingContext.tsx index ca20d8900..6c7a915f4 100644 --- a/frontend/src/contexts/BillingContext.tsx +++ b/frontend/src/contexts/BillingContext.tsx @@ -96,6 +96,7 @@ export function BillingProvider({ children }: { children: ReactNode }) { const { isConnected } = useCloudStatus(); const pollRef = useRef | null>(null); + const bgPollRef = useRef | null>(null); // Inference token cache — refresh before 5-min expiry const inferenceTokenRef = useRef<{ @@ -176,6 +177,48 @@ export function BillingProvider({ children }: { children: ReactNode }) { }; }, [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") {